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 にはテレメトリー・トラッキング・強制ログインは一切ありません。 +## スポンサー + + + + + + + + +
Hoplite
Cloud coding agents
Paseo のスポンサーになる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는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다. +## 스폰서 + + + + + + + + +
Hoplite
Cloud coding agents
Paseo 스폰서 되기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 @@ Paseo mobile app

-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 + + + + + + + + +
Hoplite
Cloud coding agents
Become a sponsorBecome a sponsor
+ ## 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 没有遥测、追踪,也不会强制登录。 +## 赞助商 + + + + + + + + +
Hoplite
Cloud coding agents
成为 Paseo 赞助商成为 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", + "", + "![sibling](./sibling.png)", + "", + "![hero](../data/cache/hero.png?v=2#preview)", + "", + ].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: ![${image.alt}](${image.relativePath})`, + `Emit settled assistant image Markdown: ![${image.alt}](${destination})`, ); 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
+ +
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 ( +
+ + {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 = + '

RetriggerConfidence 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}>IconCaption\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

BeforeIconAfter

'; + + 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("

Heading

")).toEqual([ + { kind: "markdown", text: "
Heading
" }, + ]); + }); + const inlineImageBody = [ 'Priority 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 = "
OpenStill 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 : ``; + 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)}`; + 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: ``, + raw: readRawTag() ?? ``, }); }, 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. */} + + + ); @@ -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 ( + + + {children} + + + + + + ); +} + +const styles = StyleSheet.create({ + scope: { + flex: 1, + minHeight: 0, + }, + host: { + ...StyleSheet.absoluteFillObject, + }, +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 3f9c9113d0..457181c53f 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -40,6 +40,13 @@ type IsolatedBottomSheetModalProps = Omit< BottomSheetModalProps, "enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent" > & { + /** + * Whether closing (drag, backdrop, Back) also dismisses, which unmounts the content. Default + * true: the owner hears `onDismiss` and drops its state. Pass `false` for a sheet whose close the + * owner may veto: it parks at index -1 with its content mounted, the owner hears `onChange(-1)`, + * and either snaps it back or unmounts it, which dismisses it from the stack. + */ + enableDismissOnClose?: boolean; /** * Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge * would have to reach around it. @@ -84,19 +91,24 @@ export const IsolatedBottomSheetModal = forwardRef< presentation = "push", contextBridge, backdropOpacity, + enableDismissOnClose = true, ...bottomSheetProps } = props; const renderBackdrop = useCallback( (backdropProps: BottomSheetBackdropProps) => ( - + ), - [backdropOpacity], + [backdropOpacity, enableDismissOnClose], ); const modal = ( @@ -152,3 +164,7 @@ export function useIsolatedBottomSheetVisibility({ handleSheetDismiss, }; } + +export { SheetVisibleFrame } from "./visible-frame"; +export { systemBackPress } from "./back-press"; +export { SheetHostScope } from "./host-scope"; diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx index 1232566462..8c85ef77ca 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx @@ -1,5 +1,15 @@ import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; -export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { +/** + * What a backdrop tap does. `dismiss` takes the top modal off the stack and unmounts it; `close` + * only slides this sheet down, so a sheet that keeps its content through a close can veto it. + */ +export type SheetBackdropPress = "dismiss" | "close"; + +export function SheetBackdrop({ + pressBehavior: _pressBehavior, + ...props +}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) { + // Gorhom's native backdrop closes its own sheet, which here is always the top one. return ; } diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx index ba1e397a9e..749d89359c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx @@ -1,16 +1,30 @@ import { BottomSheetBackdrop, + useBottomSheet, useBottomSheetModal, type BottomSheetBackdropProps, } from "@gorhom/bottom-sheet"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, StyleSheet, View } from "react-native"; +import type { SheetBackdropPress } from "./sheet-backdrop"; -export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { +export type { SheetBackdropPress }; + +export function SheetBackdrop({ + pressBehavior, + ...props +}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) { const { dismiss } = useBottomSheetModal(); + const { close } = useBottomSheet(); const { t } = useTranslation(); - const dismissTopSheet = useCallback(() => dismiss(), [dismiss]); + const handlePress = useCallback(() => { + // A sheet that may veto its close slides itself down and lets its owner decide. The other + // sheets ask the provider for the top modal, so a lower backdrop caught mid-transition still + // dismisses the sheet the user sees. + if (pressBehavior === "close") close(); + else dismiss(); + }, [close, dismiss, pressBehavior]); return ( @@ -28,7 +42,7 @@ export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: numb accessibilityLabel={t("common.bottomSheetBackdrop")} // The provider owns presentation order, including when a lower backdrop remounts. // Keep this target until the portal unmounts: animation must not expose the sheet below. - onPress={dismissTopSheet} + onPress={handlePress} style={StyleSheet.absoluteFill} /> diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts index 89ecce2b1e..60b386bd45 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts @@ -245,3 +245,12 @@ describe("bottom sheet visibility tracker", () => { expect(context.backPress.press()).toBe(false); }); }); + +it("does not cancel the owner's editor when the compact sheet leaves the tree on resize", () => { + const context = setup(); + openSheet(context); + context.tracker.attachController(null); + context.tracker.handleSheetDismiss(); + expect(context.closeCount()).toBe(0); + expect(context.backPress.press()).toBe(false); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts index 54ee6f58cd..dec2dd535c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts @@ -121,6 +121,9 @@ export function createBottomSheetVisibilityTracker(opts: { } }, handleSheetDismiss() { + // Portal cleanup reports dismissal after the sheet leaves the tree. It is + // presentation teardown, so the owner keeps its desired state. + if (!controller) return; if (visible) { setPhase("dismissing"); notifyClose(); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts new file mode 100644 index 0000000000..9e98bd359f --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout"; + +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); + }); +}); + +// Below the lowest snap the frame must travel with the sheet, not shrink to the screen edge. +describe("sheet footer motion", () => { + it("keeps the frame height fixed while opening and panning below the lowest snap", () => { + const layout = { + containerHeight: 1000, + handleHeight: 24, + keyboardHeight: 0, + isKeyboardVisible: false, + lowestDetentPosition: 400, + }; + const heights = [1000, 800, 600, 400].map((contentPosition) => + getBottomSheetVisibleContentHeight({ ...layout, contentPosition }), + ); + expect(heights).toEqual([576, 576, 576, 576]); + }); +}); + +it("resizes above the lowest snap and never returns negative or unmeasured heights", () => { + const layout = { + containerHeight: 1000, + handleHeight: 24, + keyboardHeight: 0, + isKeyboardVisible: false, + lowestDetentPosition: 400, + }; + expect(getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100 })).toBe(876); + expect( + getBottomSheetVisibleContentHeight({ + ...layout, + contentPosition: 100, + keyboardHeight: 950, + isKeyboardVisible: true, + }), + ).toBe(0); + expect( + getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, handleHeight: -999 }), + ).toBe(0); + expect( + getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, containerHeight: -999 }), + ).toBe(0); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts new file mode 100644 index 0000000000..316f56048a --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts @@ -0,0 +1,25 @@ +interface BottomSheetVisibleContentHeightInput { + containerHeight: number; + contentPosition: number; + lowestDetentPosition?: number; + handleHeight: number; + keyboardHeight: number; + isKeyboardVisible: boolean; +} + +export function getBottomSheetVisibleContentHeight({ + containerHeight, + contentPosition, + lowestDetentPosition, + handleHeight, + keyboardHeight, + isKeyboardVisible, +}: BottomSheetVisibleContentHeightInput): number { + "worklet"; + if (containerHeight < 0 || handleHeight < 0) return 0; + const position = Math.min(contentPosition, lowestDetentPosition ?? contentPosition); + return Math.max( + 0, + containerHeight - position - handleHeight - (isKeyboardVisible ? keyboardHeight : 0), + ); +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx new file mode 100644 index 0000000000..15ead99edf --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { View, StyleSheet } from "react-native"; +import { KEYBOARD_STATUS, useBottomSheetInternal } from "@gorhom/bottom-sheet"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout"; + +/** Fixed-detent sheet content. The footer shares the sheet's translation below its lowest snap. */ +export function SheetVisibleFrame({ + children, + footer, +}: { + children: ReactNode; + footer?: ReactNode; +}) { + const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } = + useBottomSheetInternal(); + const visibleStyle = useAnimatedStyle(() => { + const { containerHeight, handleHeight } = animatedLayoutState.get(); + const keyboard = animatedKeyboardState.get(); + return { + height: getBottomSheetVisibleContentHeight({ + containerHeight, + handleHeight, + contentPosition: animatedPosition.get(), + lowestDetentPosition: animatedDetentsState.get().detents?.[0], + keyboardHeight: keyboard.heightWithinContainer, + isKeyboardVisible: keyboard.status === KEYBOARD_STATUS.SHOWN, + }), + }; + }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); + return ( + + {children} + {footer} + + ); +} + +const styles = StyleSheet.create({ + frame: { minHeight: 0, overflow: "hidden" }, + body: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 3b76d4cf2b..2f73149244 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -126,8 +126,14 @@ export interface MenuSurfaceProps { * popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time. */ export function MenuSurface(props: MenuSurfaceProps): ReactElement | null { - const { presentation } = useMenuContext("MenuSurface"); + const { presentation, open, setOpen } = useMenuContext("MenuSurface"); const active = useRetainedPanelActive(); + // A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes + // for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet + // and promoted it to a tab, say, would otherwise bring the list back on the next tab switch. + useEffect(() => { + if (!active && open) setOpen(false); + }, [active, open, setOpen]); // Portals escape the hidden panel. Remove the surface in its inactive commit, // before freezing can suspend an async menu action's closing update. if (!active) return null; diff --git a/packages/app/src/components/ui/pane-content-toolbar.tsx b/packages/app/src/components/ui/pane-content-toolbar.tsx index 03634670d1..4ff947afc9 100644 --- a/packages/app/src/components/ui/pane-content-toolbar.tsx +++ b/packages/app/src/components/ui/pane-content-toolbar.tsx @@ -16,6 +16,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; import { iconButtonChromeGlyphSize, iconButtonChromeStyle, + useIconButtonChromeStyle, smallIconButtonChromeFrameSize, } from "@/components/ui/icon-button-chrome"; import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives"; @@ -60,6 +61,8 @@ interface ToolbarButtonCommonProps extends Omit< tooltip?: ReactNode; selected?: boolean; compact?: boolean; + /** A circular highlight, for buttons inside a pill-shaped container. */ + round?: boolean; shortcut?: ShortcutKey[][] | null; tooltipSide?: "left" | "right" | "top" | "bottom"; style?: StyleProp; @@ -81,6 +84,7 @@ export function ToolbarButton({ tooltip: tooltipBody, selected = false, compact = false, + round = false, shortcut, tooltipSide = "bottom", style, @@ -90,17 +94,14 @@ export function ToolbarButton({ testID, ...props }: ToolbarButtonProps) { - const buttonStyle = useMemo( - () => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) => - iconButtonChromeStyle({ - size: "small", - compact, - state: { ...state, active: selected }, - disabled: Boolean(disabled), - style, - }), - [compact, disabled, selected, style], - ); + const buttonStyle = useIconButtonChromeStyle({ + size: "small", + compact, + active: selected, + disabled: Boolean(disabled), + round, + style, + }); const accessibilityState = useMemo( () => ({ disabled: Boolean(disabled), selected }), [disabled, selected], diff --git a/packages/app/src/components/ui/scroll-reveal.test.ts b/packages/app/src/components/ui/scroll-reveal.test.ts new file mode 100644 index 0000000000..b793b2f46b --- /dev/null +++ b/packages/app/src/components/ui/scroll-reveal.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { getScrollOffsetToRevealItem } from "./scroll-reveal"; + +describe("getScrollOffsetToRevealItem", () => { + it("keeps the offset when the item is already inside the viewport", () => { + expect( + getScrollOffsetToRevealItem({ + currentOffset: 40, + viewportHeight: 200, + itemTop: 100, + itemHeight: 40, + }), + ).toBe(40); + }); + + it("scrolls up to the item top when the item is above the viewport", () => { + expect( + getScrollOffsetToRevealItem({ + currentOffset: 300, + viewportHeight: 200, + itemTop: 240, + itemHeight: 40, + }), + ).toBe(240); + }); + + it("scrolls down just far enough to reveal the item bottom", () => { + expect( + getScrollOffsetToRevealItem({ + currentOffset: 0, + viewportHeight: 200, + itemTop: 380, + itemHeight: 40, + }), + ).toBe(220); + }); + + it("stays put before the viewport has been measured", () => { + expect( + getScrollOffsetToRevealItem({ + currentOffset: 120, + viewportHeight: 0, + itemTop: 400, + itemHeight: 40, + }), + ).toBe(120); + }); +}); diff --git a/packages/app/src/components/ui/scroll-reveal.ts b/packages/app/src/components/ui/scroll-reveal.ts new file mode 100644 index 0000000000..50194c3ac8 --- /dev/null +++ b/packages/app/src/components/ui/scroll-reveal.ts @@ -0,0 +1,110 @@ +import { useCallback, useLayoutEffect, useRef, type RefObject } from "react"; +import type { + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, + View, +} from "react-native"; + +export function getScrollOffsetToRevealItem(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; +} + +export interface RevealActiveItemHandlers { + activeItemRef: RefObject; + contentRef: RefObject; + onItemLayout: () => void; + onViewportLayout: (event: LayoutChangeEvent) => void; + onScroll: (event: NativeSyntheticEvent) => void; +} + +/** + * Reveals the keyboard-selected row using its current position in the scroll + * content. Measuring on selection/list changes also covers retained rows that + * move or keep their size without emitting another onLayout event on web. + * A list replacement resets to the top only when selection restarts there. + * Bottom-anchored lists share only getScrollOffsetToRevealItem. + */ +export function useRevealActiveItem(options: { + activeIndex: number; + listKey: string; + scrollRef: RefObject; +}): RevealActiveItemHandlers { + const { activeIndex, listKey, scrollRef } = options; + const activeItemRef = useRef(null); + const contentRef = useRef(null); + const viewportHeightRef = useRef(0); + const offsetRef = useRef(0); + const measurementRef = useRef(0); + + const reveal = useCallback(() => { + const request = ++measurementRef.current; + const item = activeItemRef.current; + const scroll = scrollRef.current; + const content = contentRef.current; + if (!item || !scroll || !content) return; + + item.measureLayout(content, (_left, top, _width, height) => { + if (request !== measurementRef.current || activeItemRef.current !== item) return; + const nextOffset = getScrollOffsetToRevealItem({ + currentOffset: offsetRef.current, + viewportHeight: viewportHeightRef.current, + itemTop: top, + itemHeight: height, + }); + if (Math.abs(nextOffset - offsetRef.current) < 1) return; + offsetRef.current = nextOffset; + scroll.scrollTo({ y: nextOffset, animated: false }); + }); + }, [scrollRef]); + + const prevListKeyRef = useRef(listKey); + useLayoutEffect(() => { + if (prevListKeyRef.current !== listKey && activeIndex === 0) { + offsetRef.current = 0; + scrollRef.current?.scrollTo({ y: 0, animated: false }); + } + prevListKeyRef.current = listKey; + reveal(); + return () => { + // Measurements are asynchronous; a previous selection must not scroll us. + measurementRef.current += 1; + }; + }, [activeIndex, listKey, reveal, scrollRef]); + + const onViewportLayout = useCallback( + (event: LayoutChangeEvent) => { + viewportHeightRef.current = event.nativeEvent.layout.height; + reveal(); + }, + [reveal], + ); + + const onScroll = useCallback((event: NativeSyntheticEvent) => { + offsetRef.current = event.nativeEvent.contentOffset.y; + }, []); + + return { activeItemRef, contentRef, onItemLayout: reveal, onViewportLayout, onScroll }; +} diff --git a/packages/app/src/components/ui/text-input/text-input.native.test.tsx b/packages/app/src/components/ui/text-input/text-input.native.test.tsx index 4fa604d657..b52d7b9923 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.test.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.test.tsx @@ -56,6 +56,16 @@ afterEach(() => { function noop() {} +function createHandleRecorder() { + const handleRef = createRef(); + const received: (EditingTextInputHandle | null)[] = []; + function assignHandle(handle: EditingTextInputHandle | null) { + received.push(handle); + handleRef.current = handle; + } + return { handleRef, received, assignHandle }; +} + describe("EditingTextInputNative", () => { it("uses the bottom-sheet input only inside a bottom sheet", () => { act(() => { @@ -76,6 +86,32 @@ describe("EditingTextInputNative", () => { expect(bottomSheetTextInputRender.mock.calls[0]?.[0]).toMatchObject({ testID: "inside" }); }); + it("keeps its editing handle and selection when native editing rerenders the leaf", () => { + const { handleRef, received, assignHandle } = createHandleRecorder(); + act(() => + root?.render(), + ); + const input = container?.querySelector("textarea"); + if (!input) throw new Error("Expected native editing surface"); + const handle = handleRef.current; + act(() => { + handle?.focus(); + const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + if (!setValue) throw new Error("Expected input value setter"); + setValue.call(input, "native edit"); + input.setSelectionRange(2, 5); + input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "native edit" })); + }); + expect(handleRef.current).toBe(handle); + expect(received).toEqual([handle]); + expect(handle?.getText()).toBe("native edit"); + expect([input.selectionStart, input.selectionEnd]).toEqual([2, 5]); + expect(document.activeElement).toBe(input); + act(() => root?.unmount()); + root = null; + expect(received).toEqual([handle, null]); + }); + it("clears text via clear() when replaceText receives an empty string", () => { const handleRef = createRef(); @@ -105,12 +141,14 @@ describe("EditingTextInputNative", () => { ); }); const grownInput = container?.querySelector("input"); + const handle = handleRef.current; act(() => { handleRef.current?.reset(); }); expect(container?.querySelector("input")).not.toBe(grownInput); + expect(handleRef.current).toBe(handle); }); it("restores focus after replacing a reset native input", () => { diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index 72a15a9b8e..4ff773cefa 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -69,52 +69,63 @@ export const EditingTextInput = forwardRef ({ ...current, autoFocus })); }, []); - useImperativeHandle(ref, () => ({ - focus: () => { - if (isAwaitingReplacementRef.current) { - setReplacementFocus(true); - return; - } - inputRef.current?.focus(); - }, - blur: () => { - if (isAwaitingReplacementRef.current) { - setReplacementFocus(false); - return; - } - inputRef.current?.blur(); - }, - isFocused: () => inputRef.current?.isFocused?.() ?? false, - getText: () => textRef.current, - replaceText: (nextText, selection) => { - textRef.current = nextText; - if (inputRef.current?.replaceText) { - inputRef.current.replaceText(nextText, selection); - return; - } - if (nextText === "") { - inputRef.current?.clear?.(); - return; - } - inputRef.current?.setNativeProps?.({ - text: nextText, - ...(selection ? { selection } : {}), - }); - if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); - }, - reset: () => { - textRef.current = ""; - const autoFocus = inputRef.current?.isFocused?.() ?? false; - if (inputRef.current?.replaceText) { - inputRef.current.replaceText(""); - } else { - inputRef.current?.clear?.(); - } - isAwaitingReplacementRef.current = true; - setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); - }, - getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, - })); + useImperativeHandle( + ref, + () => ({ + focus: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(true); + return; + } + inputRef.current?.focus(); + }, + blur: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(false); + return; + } + inputRef.current?.blur(); + }, + isFocused: () => inputRef.current?.isFocused?.() ?? false, + getText: () => textRef.current, + replaceText: (nextText, selection) => { + textRef.current = nextText; + if (inputRef.current?.replaceText) { + inputRef.current.replaceText(nextText, selection); + return; + } + if (nextText === "") { + inputRef.current?.clear?.(); + return; + } + inputRef.current?.setNativeProps?.({ + text: nextText, + ...(selection ? { selection } : {}), + }); + if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); + }, + setSelection: (selection) => { + if (inputRef.current?.setSelection) { + inputRef.current.setSelection(selection.start, selection.end); + return; + } + inputRef.current?.setNativeProps?.({ selection }); + }, + reset: () => { + textRef.current = ""; + const autoFocus = inputRef.current?.isFocused?.() ?? false; + if (inputRef.current?.replaceText) { + inputRef.current.replaceText(""); + } else { + inputRef.current?.clear?.(); + } + isAwaitingReplacementRef.current = true; + setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); + }, + getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, + }), + [setReplacementFocus], + ); const handleChangeText = useCallback( (nextText: string) => { diff --git a/packages/app/src/components/ui/text-input/text-input.web.tsx b/packages/app/src/components/ui/text-input/text-input.web.tsx index 100ae185df..021e3b8e0c 100644 --- a/packages/app/src/components/ui/text-input/text-input.web.tsx +++ b/packages/app/src/components/ui/text-input/text-input.web.tsx @@ -57,31 +57,39 @@ export const EditingTextInput = forwardRef ({ - focus: () => inputRef.current?.focus(), - blur: () => inputRef.current?.blur(), - isFocused: () => document.activeElement === inputRef.current, - getText: () => { - const input = inputRef.current as WebTextInputElement | null; - const nextText = input?.value ?? textRef.current; - textRef.current = nextText; - return nextText; - }, - replaceText: (nextText, selection) => { - textRef.current = nextText; - const input = inputRef.current as WebTextInputElement | null; - if (input && "value" in input) input.value = nextText; - if (selection && typeof input?.setSelectionRange === "function") { - input.setSelectionRange(selection.start, selection.end); - } - }, - reset: () => { - textRef.current = ""; - const input = inputRef.current as WebTextInputElement | null; - if (input && "value" in input) input.value = ""; - }, - getNativeRef: () => inputRef.current, - })); + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + isFocused: () => document.activeElement === inputRef.current, + getText: () => { + const input = inputRef.current as WebTextInputElement | null; + const nextText = input?.value ?? textRef.current; + textRef.current = nextText; + return nextText; + }, + replaceText: (nextText, selection) => { + textRef.current = nextText; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = nextText; + if (selection && typeof input?.setSelectionRange === "function") { + input.setSelectionRange(selection.start, selection.end); + } + }, + setSelection: (selection) => { + const input = inputRef.current as WebTextInputElement | null; + input?.setSelectionRange?.(selection.start, selection.end); + }, + reset: () => { + textRef.current = ""; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = ""; + }, + getNativeRef: () => inputRef.current, + }), + [], + ); return ( mode.id); - const reconciledMode = modeOptionIds.includes(composerState.selectedMode) - ? composerState.selectedMode - : (modeOptionIds[0] ?? ""); return { provider, cwd: workspaceDirectory, workspaceId, - ...(reconciledMode !== "" ? { modeId: reconciledMode } : {}), + ...(composerState.effectiveModeId ? { modeId: composerState.effectiveModeId } : {}), ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), ...(composerState.effectiveThinkingOptionId ? { thinkingOptionId: composerState.effectiveThinkingOptionId } diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index bca5a250bf..6929b447ea 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -298,7 +298,6 @@ export function CompactModelSheet({ onClose={close} snapPoints={SNAP_POINTS} scrollable={false} - sizeContentToCurrentSnapPoint={usesBottomSheet} contentStyle={styles.sheetBody} testID="agent-controls-model-sheet" > @@ -346,7 +345,6 @@ export function CompactModelSheet({ onClose={closeModelBrowser} snapPoints={SNAP_POINTS} scrollable={false} - sizeContentToCurrentSnapPoint presentation="push" contentStyle={styles.sheetBody} testID="agent-controls-model-browser-sheet" diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index 718d49a245..4b6407c872 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -1,27 +1,30 @@ import { ComposerDockBackground } from "./internal/background"; export { ComposerDockBackground } from "./internal/background"; +export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen"; import { ScrollView } from "@/components/ui/scroll-view"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings"; import { KeyboardTranslateView } from "@/keyboard/shift"; import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native"; +import { StyleSheet as ThemedStyleSheet } from "react-native-unistyles"; import Animated, { useAnimatedStyle, useSharedValue, - useDerivedValue, useAnimatedReaction, type SharedValue, } from "react-native-reanimated"; import { useKeyboardShift } from "@/keyboard/shift"; import { resolveCenteredClearance, + resolveComposerSlotCapacity, updateComposerCapacity, type ComposerCapacity, } from "./internal/capacity"; -const ViewportCapacity = createContext | null>(null); +const ViewportSizing = createContext | null>(null); interface ComposerViewportProps extends ViewProps { bottomInset?: number; @@ -53,7 +56,6 @@ function ComposerViewport({ }, [], ); - const capacity = useDerivedValue(() => sizing.value?.capacity); const measureViewport = useCallback( (event: LayoutChangeEvent) => { measuredHeight.value = event.nativeEvent.layout.height; @@ -64,19 +66,24 @@ function ComposerViewport({ return ( - {children} + {children} ); } /** Bound the complete composer (including its controls), not just the text input. */ -function ComposerViewportContent({ style, ...props }: ViewProps) { - const capacity = useContext(ViewportCapacity); - if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport"); +function ComposerViewportContent({ + style, + fullscreen = false, + ...props +}: ViewProps & { fullscreen?: boolean }) { + const compact = useIsCompactFormFactor(); + const sizing = useContext(ViewportSizing); + if (!sizing) throw new Error("ComposerViewportContent requires ComposerViewport"); const constraint = useAnimatedStyle(() => ({ // Only viewport changes and keyboard start/end events trigger layout. Motion // stays in KeyboardTranslateView and never changes this constraint per frame. - maxHeight: capacity.value, + maxHeight: resolveComposerSlotCapacity(sizing.value, { compact, fullscreen }), })); return ( @@ -105,37 +112,71 @@ function useCenteredClearance(safeAreaBottom: number) { interface ComposerDockProps { children: [ReactNode, ReactNode, ReactNode?]; centered?: boolean; + /** + * Fullscreen is a layout mode of the same mounted composer: the slot fills the + * stationary viewport over the content and lays out the keyboard itself, so the + * surface stops translating. The screen that owns the chrome hides it. + */ + fullscreen?: boolean; } /** The stationary viewport, translated surface, and bounded composer are one owner. */ export function ComposerDock({ children: [content, composer, overlay], centered = false, + fullscreen = false, }: ComposerDockProps) { const insets = useSafeAreaInsets(); const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings); const centeredClearance = useCenteredClearance(insets.bottom); // Preserve the existing centered form's visual balance on tablets. - const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0; + const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0; + const fullscreenSurface = [ + themedStyles.fullscreenComposer, + { + paddingTop: insets.top, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + paddingRight: insets.right, + }, + ]; if (centered) { + // Same tree in both modes. Fullscreen stops centering and translating, hides the + // setup fields, and lets the composer slot take the whole stationary viewport. return ( - - + + {content} - - {composer} + + {composer} {overlay} @@ -145,7 +186,7 @@ export function ComposerDock({ } return ( - + - - {composer} + + + {composer} + {overlay} @@ -175,9 +227,16 @@ const dockStyles = StyleSheet.create({ surface: { flex: 1 }, content: { flex: 1, justifyContent: "flex-end" }, composer: { width: "100%", flexShrink: 1 }, + fullscreenSlot: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" }, centered: { flexShrink: 1, width: "100%" }, // Reserve the composer's own capped height before the setup scroll view shrinks. centeredComposer: { width: "100%", flexShrink: 0 }, setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, + hiddenSetup: { display: "none" }, }); + +// The fullscreen composer paints its own surface, including under the safe areas. +const themedStyles = ThemedStyleSheet.create((theme) => ({ + fullscreenComposer: { flex: 1, backgroundColor: theme.colors.surface1 }, +})); diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx index d39439f325..421002352e 100644 --- a/packages/app/src/composer/dock/index.tsx +++ b/packages/app/src/composer/dock/index.tsx @@ -1,36 +1,69 @@ export { ComposerDockBackground } from "./internal/background"; +export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen"; import { ScrollView } from "@/components/ui/scroll-view"; -import type { ReactNode } from "react"; +import { useState, useCallback, useMemo, useRef, type ReactNode } from "react"; import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { resolveInlineComposerCapacity } from "./internal/capacity"; +import { useFullscreenSlotFrame } from "./internal/fullscreen"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; interface ComposerDockProps { children: [ReactNode, ReactNode, ReactNode?]; centered?: boolean; + /** See the native dock: the same mounted composer fills the visible surface. */ + fullscreen?: boolean; } export function ComposerDock({ children: [content, composer, overlay], centered = false, + fullscreen = false, }: ComposerDockProps) { + const compact = useIsCompactFormFactor(); + const [surfaceSize, setSurfaceSize] = useState<{ width: number; height: number }>(); + const surfaceRef = useRef(null); + const fullscreenFrame = useFullscreenSlotFrame(fullscreen, surfaceRef, surfaceSize); + const constraint = useMemo( + () => ({ maxHeight: resolveInlineComposerCapacity(surfaceSize?.height, compact) }), + [surfaceSize?.height, compact], + ); + const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + if (height > 0) setSurfaceSize({ width, height }); + }, []); if (centered) { return ( - + {content} - {composer} + + {composer} + {overlay} ); } return ( - + {content} - {composer} + + {composer} + {overlay} ); @@ -40,6 +73,10 @@ const styles = StyleSheet.create((theme) => ({ surface: { flex: 1, overflow: "hidden" }, content: { flex: 1, justifyContent: "flex-end" }, composer: { width: "100%", flexShrink: 1 }, + // Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor + // never reaches the zoomed visual viewport, while a fixed box chains to the viewport + // directly. React Native Web renders `fixed`; React Native's style type omits it. + fullscreenComposer: { position: "fixed" as "absolute" }, centered: { flex: 1, alignItems: "center", diff --git a/packages/app/src/composer/dock/internal/capacity.test.ts b/packages/app/src/composer/dock/internal/capacity.test.ts index 87796f23d4..44e79e6ef9 100644 --- a/packages/app/src/composer/dock/internal/capacity.test.ts +++ b/packages/app/src/composer/dock/internal/capacity.test.ts @@ -1,11 +1,39 @@ import { describe, expect, it } from "vitest"; import { resolveCenteredClearance, + resolveComposerSlotCapacity, + resolveInlineComposerCapacity, resolveComposerCapacity, updateComposerCapacity, } from "./capacity"; describe("composer viewport", () => { + it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => { + const open = updateComposerCapacity(undefined, { + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 308, + }); + expect(resolveInlineComposerCapacity(open.capacity, true)).toBe(147); + expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245); + expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined(); + }); + it("lets a fullscreen composer fill the viewport while inline keeps its cap", () => { + const open = updateComposerCapacity(undefined, { + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 308, + }); + expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: true })).toBe(582); + expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: false })).toBe(147); + expect(resolveComposerSlotCapacity(undefined, { compact: true, fullscreen: true })).toBe( + undefined, + ); + }); it("preserves the editing capacity when the keyboard closes", () => { const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false }; const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }); diff --git a/packages/app/src/composer/dock/internal/capacity.ts b/packages/app/src/composer/dock/internal/capacity.ts index eb5e4a66bd..9c460e1729 100644 --- a/packages/app/src/composer/dock/internal/capacity.ts +++ b/packages/app/src/composer/dock/internal/capacity.ts @@ -10,6 +10,8 @@ interface ComposerGeometry { export interface ComposerCapacity { keyboardReserve: number; capacity: number; + /** The stationary viewport; a fullscreen composer fills it and lays out the keyboard itself. */ + viewportHeight: number; } export function resolveComposerCapacity(input: ComposerGeometry): number { @@ -48,5 +50,32 @@ export function updateComposerCapacity( return { keyboardReserve, capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }), + viewportHeight: input.height, }; } + +/** Compact editing leaves conversation space above the complete composer. */ +export function resolveInlineComposerCapacity( + capacity: number | undefined, + compact: boolean, +): number | undefined { + "worklet"; + if (capacity === undefined) return undefined; + return compact ? capacity * 0.6 : capacity; +} + +export interface ComposerSlotLayout { + compact: boolean; + fullscreen: boolean; +} + +/** The bound on the composer slot: inline capacity, or the whole viewport in fullscreen. */ +export function resolveComposerSlotCapacity( + sizing: ComposerCapacity | undefined, + layout: ComposerSlotLayout, +): number | undefined { + "worklet"; + if (sizing === undefined) return undefined; + if (layout.fullscreen) return sizing.viewportHeight; + return resolveInlineComposerCapacity(sizing.capacity, layout.compact); +} diff --git a/packages/app/src/composer/dock/internal/fullscreen.native.tsx b/packages/app/src/composer/dock/internal/fullscreen.native.tsx new file mode 100644 index 0000000000..9f27befa4c --- /dev/null +++ b/packages/app/src/composer/dock/internal/fullscreen.native.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { StyleSheet, type ViewProps } from "react-native"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { KeyboardTranslateView, useKeyboardShift } from "@/keyboard/shift"; + +/** + * The editing region of a composer. Inline it only continues the shrink chain. In + * fullscreen it fills the stationary frame and reserves the keyboard's space at + * motion boundaries, the same boundary-time layout the dock capacity uses: the + * keyboard start/end events write `layoutShift`; frames in between move the footer. + */ +export function ComposerFullscreenBody({ + fullscreen, + children, +}: { + fullscreen: boolean; + children: ReactNode; +}) { + const { layoutShift } = useKeyboardShift(); + const reservation = useAnimatedStyle(() => ({ + marginBottom: fullscreen ? layoutShift.value : 0, + })); + return ( + + {children} + + ); +} + +/** The Send row of a fullscreen composer rides the keyboard like the inline dock surface. */ +export function ComposerFullscreenFooter({ children, ...props }: ViewProps) { + return {children}; +} + +// Plain React Native styles: these land on Reanimated views (docs/unistyles.md). +const styles = StyleSheet.create({ + inline: { flexShrink: 1 }, + fullscreen: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/composer/dock/internal/fullscreen.tsx b/packages/app/src/composer/dock/internal/fullscreen.tsx new file mode 100644 index 0000000000..36291181c1 --- /dev/null +++ b/packages/app/src/composer/dock/internal/fullscreen.tsx @@ -0,0 +1,89 @@ +import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; +import { StyleSheet, View, type ViewProps } from "react-native"; + +/** The editing region of a composer; see the native file for the keyboard contract. */ +export function ComposerFullscreenBody({ + fullscreen, + children, +}: { + fullscreen: boolean; + children: ReactNode; +}) { + return {children}; +} + +export function ComposerFullscreenFooter({ children, ...props }: ViewProps) { + return {children}; +} + +export interface ComposerSlotFrame { + top: number; + left: number; + width: number; + height: number; +} + +/** + * Where a fullscreen composer sits, in layout-viewport coordinates for a fixed box: the + * part of the dock surface the browser's visible viewport shows. A mobile browser + * keyboard shrinks or pans that viewport without resizing the page, so the frame + * follows it instead of the layout. + */ +export function useFullscreenSlotFrame( + active: boolean, + surfaceRef: RefObject, + surfaceSize: { width: number; height: number } | undefined, +): ComposerSlotFrame | undefined { + const [frame, setFrame] = useState(); + useLayoutEffect(() => { + if (!active) { + setFrame(undefined); + return; + } + const update = () => { + const surface = surfaceRef.current as unknown as HTMLElement | null; + if (!surface) return; + setFrame(resolveVisibleSurfaceFrame(surface.getBoundingClientRect(), readVisibleViewport())); + }; + const visualViewport = window.visualViewport; + update(); + visualViewport?.addEventListener("resize", update); + visualViewport?.addEventListener("scroll", update); + window.addEventListener("resize", update); + return () => { + visualViewport?.removeEventListener("resize", update); + visualViewport?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, [active, surfaceRef, surfaceSize]); + return frame; +} + +function readVisibleViewport() { + const viewport = window.visualViewport; + return { + top: viewport?.offsetTop ?? 0, + left: viewport?.offsetLeft ?? 0, + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight, + }; +} + +function resolveVisibleSurfaceFrame( + surface: { top: number; left: number; right: number; bottom: number }, + viewport: { top: number; left: number; width: number; height: number }, +): ComposerSlotFrame { + const top = Math.max(viewport.top, surface.top); + const left = Math.max(viewport.left, surface.left); + return { + top, + left, + width: Math.max(0, Math.min(viewport.left + viewport.width, surface.right) - left), + height: Math.max(0, Math.min(viewport.top + viewport.height, surface.bottom) - top), + }; +} + +const styles = StyleSheet.create({ + inline: { flexShrink: 1 }, + fullscreen: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 33041cceb6..8bc31bb176 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -210,6 +210,8 @@ describe("useAgentInputDraft live contract", () => { }); const hydratedTextReplacement = getLatest().textReplacement; + // The editor applies a retained caret to the hydrated text, so hydration announces itself. + expect(hydratedTextReplacement.kind).toBe("hydrated"); await act(async () => { getLatest().editText("hello world"); @@ -223,6 +225,7 @@ describe("useAgentInputDraft live contract", () => { }); expect(getLatest().textReplacement).not.toBe(hydratedTextReplacement); + expect(getLatest().textReplacement.kind).toBe("replace"); expect(getLatest().textReplacement.text).toBe("replacement text"); await act(async () => { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 0812fb280d..c14db9a119 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -17,6 +17,7 @@ import { } from "@/composer/draft/input-draft-core"; import { buildDraftCommandTarget, + resolveEffectiveComposerModeId, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, type ProviderSelectionState, @@ -46,6 +47,7 @@ interface UseAgentInputDraftInput { type DraftComposerState = UseAgentFormStateResult & { workingDir: string; + effectiveModeId: string; effectiveModelId: string; effectiveThinkingOptionId: string; featureValues: Record | undefined; @@ -115,14 +117,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const isHydrated = hydratedDraftKey === draftKey; const textReplacementRevisionRef = useRef(0); const [textReplacement, setTextReplacement] = useState(() => ({ + kind: "initial", key: `${draftKey}:0`, text: textSource.getSnapshot(), })); const publishTextReplacement = useCallback( - (nextText: string) => { + (kind: "hydrated" | "replace", nextText: string) => { textReplacementRevisionRef.current += 1; setTextReplacement({ + kind, key: `${draftKey}:${textReplacementRevisionRef.current}`, text: nextText, }); @@ -172,7 +176,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr (nextText: string) => { textPublication.cancel(); useDraftStore.getState().editDraftText({ draftKey, text: nextText }); - publishTextReplacement(nextText); + publishTextReplacement("replace", nextText); }, [draftKey, publishTextReplacement, textPublication], ); @@ -225,7 +229,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr await useDraftStore.getState().hydrateDraftInput({ draftKey }); if (!cancelled) { const hydratedText = useDraftStore.getState().getDraftInput(draftKey)?.text ?? ""; - publishTextReplacement(hydratedText); + publishTextReplacement("hydrated", hydratedText); setHydratedDraftKey(draftKey); } })(); @@ -254,6 +258,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ], ); + const effectiveModeId = useMemo( + () => resolveEffectiveComposerModeId(providerSelection, formState.isModelLoading), + [formState.isModelLoading, providerSelection], + ); + const effectiveModelId = useMemo( () => resolveEffectiveComposerModelId(providerSelection), [providerSelection], @@ -313,6 +322,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return { ...formState, workingDir, + effectiveModeId, effectiveModelId, effectiveThinkingOptionId, featureValues: draftFeatureValues, @@ -327,6 +337,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }, [ commandDraft, composerOptions, + effectiveModeId, effectiveModelId, effectiveThinkingOptionId, draftFeatures, @@ -353,6 +364,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr export const __private__ = { resolveDraftKey, + resolveEffectiveComposerModeId, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, buildDraftCommandTarget, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 6e9c45eab5..b54b344a36 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -6,6 +6,7 @@ import { ComposerDock } from "@/composer/dock"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; +import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { ComposerImportPill } from "@/composer/draft/import-pill"; import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles"; @@ -86,46 +87,30 @@ function resolveAutoSubmitConfig( }; } -// Reconcile the form's selected mode against the currently discovered modes. -// The mode picker displays modeOptions[0] when the stored mode isn't in the -// list (e.g. a globally-remembered "plan" that this workspace's OpenCode config -// no longer defines), so the submitted mode must match that display — otherwise -// we'd send a stale mode the provider rejects while the UI showed a valid one. -function reconcileSelectedMode(modeOptionIds: readonly string[], selectedMode: string): string { - if (modeOptionIds.length === 0) { - return ""; - } - return modeOptionIds.includes(selectedMode) ? selectedMode : (modeOptionIds[0] ?? ""); -} - function resolveDraftModeIdOverride(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionIds: readonly string[]; - selectedMode: string; + effectiveModeId: string; }): { modeId: string } | Record { - const { autoSubmitConfig, modeOptionIds, selectedMode } = input; + const { autoSubmitConfig, effectiveModeId } = input; if (autoSubmitConfig?.modeId) { return { modeId: autoSubmitConfig.modeId }; } - const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); - if (reconciled !== "") { - return { modeId: reconciled }; + if (effectiveModeId !== "") { + return { modeId: effectiveModeId }; } return {}; } function resolveDraftModeId(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionIds: readonly string[]; - selectedMode: string; + effectiveModeId: string; }): string | null { - const { autoSubmitConfig, modeOptionIds, selectedMode } = input; + const { autoSubmitConfig, effectiveModeId } = input; if (autoSubmitConfig?.modeId !== undefined) { return autoSubmitConfig.modeId; } - const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); - if (reconciled !== "") { - return reconciled; + if (effectiveModeId !== "") { + return effectiveModeId; } return null; } @@ -143,8 +128,7 @@ async function submitDraftCreateRequest(input: { autoSubmitConfig: AutoSubmitConfig | null; composerState: { selectedProvider: string | null; - selectedMode: string; - modeOptions: readonly { id: string }[]; + effectiveModeId: string; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; featureValues: Record | undefined; @@ -177,8 +161,7 @@ async function submitDraftCreateRequest(input: { } const modeIdOverride = resolveDraftModeIdOverride({ autoSubmitConfig, - modeOptionIds: composerState.modeOptions.map((mode) => mode.id), - selectedMode: composerState.selectedMode, + effectiveModeId: composerState.effectiveModeId, }); const config = buildWorkspaceDraftAgentConfig({ provider, @@ -219,8 +202,7 @@ function buildDraftAgentSnapshot(input: { composerState: { effectiveModelId: string | null; effectiveThinkingOptionId: string | null; - modeOptions: readonly { id: string }[]; - selectedMode: string; + effectiveModeId: string; selectedProvider: string | null; agentControls: { features?: Agent["features"] }; }; @@ -234,8 +216,7 @@ function buildDraftAgentSnapshot(input: { autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null); const modeId = resolveDraftModeId({ autoSubmitConfig, - modeOptionIds: composerState.modeOptions.map((mode) => mode.id), - selectedMode: composerState.selectedMode, + effectiveModeId: composerState.effectiveModeId, }); const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; if (!provider) { @@ -405,6 +386,8 @@ export function WorkspaceDraftAgentTab({ }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); + const editingSession = useComposerEditingSession("chat"); + const isComposerFullscreen = useComposerFullscreen(editingSession); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( COMPACT_FORM_FACTOR_WIDTH, { initialIsBelow: isCompactFormFactor }, @@ -648,10 +631,10 @@ export function WorkspaceDraftAgentTab({ return ( - + {dockContent} - {importPillPress ? ( + {importPillPress && !isComposerFullscreen ? ( @@ -675,6 +658,7 @@ export function WorkspaceDraftAgentTab({ onChangeAttachments={draftInput.setAttachments} cwd={composerState.workingDir} clearDraft={draftInput.clear} + editingSession={editingSession} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} autoFocusKey={String(draftInput.attachmentFocusRequestId)} onFocusInput={handleFocusInputCallback} @@ -692,6 +676,8 @@ const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", flexShrink: 1, + // Content-sized inline; fills the dock's fullscreen slot. + flexGrow: 1, }, }); diff --git a/packages/app/src/composer/editing-session/index.test.ts b/packages/app/src/composer/editing-session/index.test.ts new file mode 100644 index 0000000000..31c3765426 --- /dev/null +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { ComposerEditingSession, ComposerEditingSessions } from "./index"; + +describe("composer editing session", () => { + it("publishes fullscreen changes once per transition", () => { + const session = new ComposerEditingSession(); + const changes: boolean[] = []; + session.subscribe(() => changes.push(session.getFullscreen())); + session.setFullscreen(true); + session.setFullscreen(true); + session.setFullscreen(false); + expect(changes).toEqual([true, false]); + }); + + it("hands the retained caret and focus intent to exactly one later mount", () => { + const session = new ComposerEditingSession(); + expect(session.takeEditor()).toBeNull(); + session.retainEditor({ selection: { start: 4, end: 17 }, focused: true }); + expect(session.takeEditor()).toEqual({ selection: { start: 4, end: 17 }, focused: true }); + expect(session.takeEditor()).toBeNull(); + }); +}); + +describe("composer editing sessions", () => { + const workspace = "server:workspace"; + const target = JSON.stringify({ kind: "agent", agentId: "a" }); + + it("keeps one session per tab and purpose until the tab target changes", () => { + const sessions = new ComposerEditingSessions(); + const chat = sessions.get(workspace, "tab-1", target, "chat"); + expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(chat); + expect(sessions.get(workspace, "tab-1", target, "terminal")).not.toBe(chat); + const retargeted = JSON.stringify({ kind: "agent", agentId: "b" }); + expect(sessions.get(workspace, "tab-1", retargeted, "chat")).not.toBe(chat); + }); + + it("reports a tab as fullscreen while any of its composers is fullscreen", () => { + const sessions = new ComposerEditingSessions(); + const notifications: boolean[] = []; + sessions.subscribe(() => notifications.push(sessions.isTabFullscreen(workspace, "tab-1"))); + const chat = sessions.get(workspace, "tab-1", target, "chat"); + sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true); + expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(false); + expect(sessions.isTabFullscreen(workspace, null)).toBe(false); + chat.setFullscreen(true); + expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(true); + chat.setFullscreen(false); + expect(notifications).toEqual([false, true, false]); + }); + + it("drops sessions for closed tabs and keeps retained ones", () => { + const sessions = new ComposerEditingSessions(); + const kept = sessions.get(workspace, "tab-1", target, "chat"); + kept.setFullscreen(true); + sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true); + sessions.retain(workspace, ["tab-1"]); + expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(kept); + expect(sessions.isTabFullscreen(workspace, "tab-2")).toBe(false); + expect(sessions.get(workspace, "tab-2", target, "chat").getFullscreen()).toBe(false); + }); +}); diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx new file mode 100644 index 0000000000..6ad84d632d --- /dev/null +++ b/packages/app/src/composer/editing-session/index.tsx @@ -0,0 +1,180 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { ComposerInputMode } from "@/composer/input-mode"; + +export interface ComposerSelection { + start: number; + end: number; +} + +/** What an unmounting editor leaves for the next mount of the same session. */ +export interface ComposerRetainedEditor { + selection: ComposerSelection; + /** The editor was focused, or meant to regain focus once its pane became active. */ + focused: boolean; +} + +/** + * Editing state that outlives the editor's mount. Draft text belongs to its draft + * owner; the editor keeps its own live buffer across layout modes. Switching between + * the compact and desktop screens remounts the editor, so the session carries the + * caret and focus intent across that remount while the draft owner carries the text. + */ +export class ComposerEditingSession { + private fullscreen = false; + private retained: ComposerRetainedEditor | null = null; + private listeners = new Set<() => void>(); + constructor(private readonly onChange?: () => void) {} + getFullscreen = () => this.fullscreen; + /** Called by the unmounting editor; the next mount of this session takes it. */ + retainEditor(retained: ComposerRetainedEditor) { + this.retained = retained; + } + takeEditor(): ComposerRetainedEditor | null { + const retained = this.retained; + this.retained = null; + return retained; + } + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + setFullscreen(fullscreen: boolean) { + if (fullscreen === this.fullscreen) return; + this.fullscreen = fullscreen; + for (const listener of this.listeners) listener(); + this.onChange?.(); + } +} + +interface TabSessions { + targetKey: string; + sessions: Map; +} + +export class ComposerEditingSessions { + private workspaces = new Map>(); + private listeners = new Set<() => void>(); + private notify = () => { + for (const listener of this.listeners) listener(); + }; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) { + let workspace = this.workspaces.get(workspaceKey); + if (!workspace) { + workspace = new Map(); + this.workspaces.set(workspaceKey, workspace); + } + let tab = workspace.get(tabId); + if (!tab || tab.targetKey !== targetKey) { + tab = { targetKey, sessions: new Map() }; + workspace.set(tabId, tab); + } + let session = tab.sessions.get(purpose); + if (!session) { + session = new ComposerEditingSession(this.notify); + tab.sessions.set(purpose, session); + } + return session; + } + /** A tab is fullscreen when any of its composers is; the screen hides its chrome for it. */ + isTabFullscreen(workspaceKey: string, tabId: string | null): boolean { + if (tabId === null) return false; + const tab = this.workspaces.get(workspaceKey)?.get(tabId); + if (!tab) return false; + for (const session of tab.sessions.values()) if (session.getFullscreen()) return true; + return false; + } + retain(workspaceKey: string, tabIds: readonly string[]) { + const workspace = this.workspaces.get(workspaceKey); + if (!workspace) return; + const retained = new Set(tabIds); + let changed = false; + for (const tabId of workspace.keys()) { + if (retained.has(tabId)) continue; + workspace.delete(tabId); + changed = true; + } + if (workspace.size === 0) this.workspaces.delete(workspaceKey); + if (changed) this.notify(); + } +} + +const SessionsContext = createContext(null); +const WorkspaceContext = createContext(null); +const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null); + +/** App-container ownership survives replacement of both the gesture shell and tab layout. */ +export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) { + const [sessions] = useState(() => new ComposerEditingSessions()); + return {children}; +} +export function WorkspaceComposerEditingSessionsProvider({ + workspaceKey, + tabIds, + children, +}: { + workspaceKey: string; + tabIds: readonly string[]; + children: ReactNode; +}) { + const sessions = useContext(SessionsContext); + useEffect(() => sessions?.retain(workspaceKey, tabIds), [sessions, workspaceKey, tabIds]); + return {children}; +} +export function ComposerEditingScope({ + tabId, + targetKey, + children, +}: { + tabId: string; + targetKey: string; + children: ReactNode; +}) { + const scope = useMemo(() => ({ tabId, targetKey }), [tabId, targetKey]); + return {children}; +} +export function useComposerEditingSession( + purpose: ComposerInputMode, + provided?: ComposerEditingSession, +): ComposerEditingSession { + const sessions = useContext(SessionsContext); + const scope = useContext(ScopeContext); + const workspaceKey = useContext(WorkspaceContext); + const [local] = useState(() => provided ?? new ComposerEditingSession()); + if (provided) return provided; + return sessions && scope && workspaceKey + ? sessions.get(workspaceKey, scope.tabId, scope.targetKey, purpose) + : local; +} +export function useComposerFullscreen(session: ComposerEditingSession): boolean { + return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen); +} +/** Whether the composer of a workspace tab is fullscreen, for the chrome that tab sits under. */ +export function useComposerTabFullscreen(workspaceKey: string, tabId: string | null): boolean { + const sessions = useContext(SessionsContext); + const subscribe = useCallback( + (listener: () => void) => sessions?.subscribe(listener) ?? (() => {}), + [sessions], + ); + const read = useCallback( + () => sessions?.isTabFullscreen(workspaceKey, tabId) ?? false, + [sessions, workspaceKey, tabId], + ); + return useSyncExternalStore(subscribe, read, read); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c36d7f9e41..10d670fcae 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,3 +1,8 @@ +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "./editing-session"; import type { ComposerTextSource } from "./text-source"; import { createStore, type StoreApi } from "zustand/vanilla"; import { useStore } from "zustand"; @@ -26,6 +31,7 @@ import { import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { usePanelStore } from "@/stores/panel-store"; import { useHasFinePointer } from "@/hooks/use-fine-pointer"; import { useShallow } from "zustand/shallow"; import { @@ -966,6 +972,12 @@ interface ComposerProps { submitLabel?: string; /** Overrides the mode's default placeholder, for text only the caller can build. */ placeholder?: string; + /** + * The host that also lays out the dock and chrome shares this session so its + * fullscreen state and the composer's agree. Defaults to the tab's session for + * `inputMode`. + */ + editingSession?: ComposerEditingSession; } const EMPTY_ARRAY: readonly QueuedMessage[] = []; @@ -1003,8 +1015,10 @@ function ComposerAutocompleteBinding({ cursorIndex: Math.min(cursorIndex, userInput.length), onAutocompleteApplied: () => inputRef.current?.focus(), }); - useImperativeHandle(ref, () => ({ onKeyPress: autocomplete.onKeyPress }), [ - autocomplete.onKeyPress, + const handleKeyPress = autocomplete.onKeyPress; + useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [ + show, + handleKeyPress, ]); const selectOption = autocomplete.onSelectOption; const onSelect = useCallback( @@ -1179,8 +1193,19 @@ function ComposerVoiceModeButton({ } export function Composer({ isPaneFocused, ...props }: ComposerProps) { + // A compact overlay panel (agent list, explorer) covers the pane without taking its + // focus. The composer gives up input from the moment the user asks for the panel, + // not when its motion settles, so the exit action and autocomplete never race it. + // Pane focus stays put, so the keyboard the panel dismisses is a blur in the + // user's own pane: closing the panel does not bring focus back. + const isCompact = useIsCompactFormFactor(); + const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent"); + const coveredByOverlayPanel = isCompact && overlayPanelRequested; return ( - + @@ -1231,6 +1256,7 @@ function ComposerContentImpl({ inputWrapperStyle, isCompactLayout: isCompactLayoutOverride, inputMode = "chat", + editingSession: providedEditingSession, readOnly = false, submitLabel, placeholder, @@ -1336,6 +1362,8 @@ function ComposerContentImpl({ const nextPendingFileId = useRef(0); const isUploadingFile = pendingFiles.length > 0; const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); + const editingSession = useComposerEditingSession(inputMode, providedEditingSession); + const isFullscreen = useComposerFullscreen(editingSession); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); @@ -2245,9 +2273,23 @@ function ComposerContentImpl({ [githubSearchItems, selectedAttachments, handleToggleGithubItem], ); + // Fullscreen fills the dock slot: the chain down to the input stretches and drops + // the inline padding, which the fullscreen presentation supplies itself. const inputAreaContainerStyle = useMemo( - () => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked], - [isComposerLocked], + () => [ + styles.inputAreaContainer, + isComposerLocked && styles.inputAreaLocked, + isFullscreen && styles.inputAreaFullscreen, + ], + [isComposerLocked, isFullscreen], + ); + const inputAreaContentStyle = useMemo( + () => [styles.inputAreaContent, isFullscreen && styles.fullscreenFill], + [isFullscreen], + ); + const messageInputContainerStyle = useMemo( + () => [styles.messageInputContainer, isFullscreen && styles.fullscreenFill], + [isFullscreen], ); const attachmentTray = useMemo( @@ -2358,21 +2400,25 @@ function ComposerContentImpl({ focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction} isMessageInputFocused={isMessageInputFocused} /> - + {/* Input area */} - - {queueList} - {sendErrorNode} + + {isFullscreen ? null : queueList} + {!isFullscreen && sendErrorNode} - + @@ -2387,6 +2433,8 @@ function ComposerContentImpl({ ({ @@ -2489,6 +2543,18 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputAreaLocked: { opacity: 0.6, }, + inputAreaFullscreen: { + flex: 1, + minHeight: 0, + alignItems: "stretch", + paddingHorizontal: 0, + paddingBottom: 0, + }, + fullscreenFill: { + flex: 1, + minHeight: 0, + maxWidth: "100%", + }, inputAreaContent: { flexShrink: 1, width: "100%", diff --git a/packages/app/src/composer/input/fullscreen-exit.native.ts b/packages/app/src/composer/input/fullscreen-exit.native.ts new file mode 100644 index 0000000000..c2344d50b0 --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.native.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { BackHandler } from "react-native"; + +/** The hardware back button leaves fullscreen instead of the screen. */ +export function useComposerFullscreenExit( + active: boolean, + onExit: () => void, +): ((node: unknown) => void) | undefined { + useEffect(() => { + if (!active) return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + onExit(); + return true; + }); + return () => subscription.remove(); + }, [active, onExit]); + return undefined; +} diff --git a/packages/app/src/composer/input/fullscreen-exit.ts b/packages/app/src/composer/input/fullscreen-exit.ts new file mode 100644 index 0000000000..b76eeed5fc --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.ts @@ -0,0 +1,23 @@ +import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root"; + +/** + * Escape leaves fullscreen before app shortcuts see it. The registration only claims + * keys; focus stays where the user left it because the editor never remounts. + */ +export function useComposerFullscreenExit( + active: boolean, + onExit: () => void, +): ((node: unknown) => void) | undefined { + const layer = useOverlayLayer("modal"); + return useWebOverlayRegistration({ + active, + layer, + manageFocus: false, + onKeyDown: (event) => { + if (event.key !== "Escape") return false; + event.preventDefault(); + onExit(); + return true; + }, + }); +} diff --git a/packages/app/src/composer/input/height.native.ts b/packages/app/src/composer/input/height.native.ts index 21178db4e1..a36839e2e7 100644 --- a/packages/app/src/composer/input/height.native.ts +++ b/packages/app/src/composer/input/height.native.ts @@ -7,6 +7,8 @@ interface ComposerHeightArgs { textareaRef: RefObject; minHeight: number; maxHeight: number; + onRenderedLinesChange?: (lines: number) => void; + presentationKey?: boolean; } export function useComposerHeight({ diff --git a/packages/app/src/composer/input/height.web.ts b/packages/app/src/composer/input/height.web.ts index c732bf3599..6b8c1ec561 100644 --- a/packages/app/src/composer/input/height.web.ts +++ b/packages/app/src/composer/input/height.web.ts @@ -7,6 +7,8 @@ interface ComposerHeightArgs { textareaRef: RefObject; minHeight: number; maxHeight: number; + onRenderedLinesChange?: (lines: number) => void; + presentationKey?: boolean; } const COPIED_STYLES = [ @@ -36,11 +38,13 @@ export function useComposerHeight({ textareaRef, minHeight, maxHeight, + onRenderedLinesChange, + presentationKey, }: ComposerHeightArgs): ComposerHeightResult { const [height, setHeight] = useState(minHeight); const heightRef = useRef(minHeight); - const paramsRef = useRef({ getText, minHeight, maxHeight }); - paramsRef.current = { getText, minHeight, maxHeight }; + const paramsRef = useRef({ getText, minHeight, maxHeight, onRenderedLinesChange }); + paramsRef.current = { getText, minHeight, maxHeight, onRenderedLinesChange }; const mirrorRef = useRef(null); const setBoundedHeight = useCallback((nextHeight: number) => { @@ -65,6 +69,13 @@ export function useComposerHeight({ } mirror.style.width = `${sourceWidth}px`; mirror.value = text.endsWith("\n") ? `${text} ` : text; + const padding = + parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom); + const lineHeight = parseFloat(computedStyle.lineHeight); + if (lineHeight > 0) + paramsRef.current.onRenderedLinesChange?.( + Math.round((mirror.scrollHeight - padding) / lineHeight), + ); setBoundedHeight(mirror.scrollHeight); }, [setBoundedHeight, textareaRef], @@ -101,7 +112,7 @@ export function useComposerHeight({ useLayoutEffect(() => { measure(getText()); - }, [maxHeight, minHeight, getText, measure]); + }, [maxHeight, minHeight, getText, measure, presentationKey]); useEffect(() => { const source = textareaRef.current; @@ -115,7 +126,7 @@ export function useComposerHeight({ }); observer.observe(source); return () => observer.disconnect(); - }, [measure, textareaRef]); + }, [measure, textareaRef, presentationKey]); const onTextChange = useCallback( (_previousText: string, nextText: string) => measure(nextText), diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index e15a00018e..c6c267a38f 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,8 @@ +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, @@ -23,6 +28,7 @@ import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import { useDictation } from "@/hooks/use-dictation"; +import { hasFinePointer } from "@/hooks/use-fine-pointer"; import { DictationOverlay } from "@/components/dictation-controls"; import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -56,6 +62,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; import { useComposerHeight } from "./height"; +import { ComposerInputPresentation } from "./presentation"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import type { NativePastedFile } from "@/composer/native-pasted-image"; import { @@ -77,6 +84,8 @@ import { applyDictationTranscript, computeCanStartDictation, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, + resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -106,6 +115,8 @@ export interface ComposerKeyPressEvent { } export interface MessageInputProps { + editingSession?: ComposerEditingSession; + submissionError?: React.ReactNode; value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; @@ -206,6 +217,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< >; interface TextAreaHandle { + value?: string; scrollHeight?: number; clientHeight?: number; offsetHeight?: number; @@ -215,7 +227,7 @@ interface TextAreaHandle { style?: { height?: string; overflowY?: string; - } & Record; + }; } function AttachButtonIcon({ @@ -433,7 +445,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM } interface PasteImagesEffectArgs { - getWebTextArea: () => TextAreaHandle | null; + textarea: HTMLElement | null; isConnected: boolean; disabled: boolean; isDictating: boolean; @@ -443,7 +455,7 @@ interface PasteImagesEffectArgs { function usePasteImagesEffect(args: PasteImagesEffectArgs): void { const { - getWebTextArea, + textarea, isConnected, disabled, isDictating, @@ -454,19 +466,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { useEffect(() => { if (!isWeb || !onAddImages) return; - const textarea = getWebTextArea() as - | (TextAreaHandle & { - addEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; - removeEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; - }) - | null; - if ( - !textarea || - typeof textarea.addEventListener !== "function" || - typeof textarea.removeEventListener !== "function" - ) { - return; - } + if (!textarea) return; let disposed = false; const handlePaste = (event: ClipboardEvent) => { @@ -493,14 +493,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { disposed = true; textarea.removeEventListener?.("paste", handlePaste); }; - }, [ - disabled, - getWebTextArea, - isConnected, - isDictating, - isRealtimeVoiceForCurrentAgent, - onAddImages, - ]); + }, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]); } function useAutoFocusOnWebEffect( @@ -532,8 +525,8 @@ function MessageInputAutoFocus({ autoFocusKey: string | undefined; textInputRef: React.MutableRefObject; }) { - const { isActiveComposer } = useComposerKeyboardScope(); - useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey); + const { isPaneFocused } = useComposerKeyboardScope(); + useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey); return null; } @@ -628,6 +621,7 @@ function FocusHint({ interface ComposerTextSurfaceProps { readOnly: boolean; value: string; + textReplacementKey: string; textInputRef: React.Ref; textInputStyle: EditingTextInputProps["style"]; readOnlyTextStyle: React.ComponentProps["style"]; @@ -643,6 +637,9 @@ interface ComposerTextSurfaceProps { onSelectionChange: (event: NativeSyntheticEvent) => void; onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined; onPasteError: (message: string) => void; + fullscreen: boolean; + compact: boolean; + onRenderedLinesChange: (lines: number) => void; focusHintVisible: boolean; focusInputKeys: ShortcutChord | null | undefined; focusHintLabel: string; @@ -654,6 +651,23 @@ interface ComposerTextSurfaceProps { * state of this composer rather than a second one. */ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement { + const { onChangeText, onRenderedLinesChange } = props; + const [measurementText, setMeasurementText] = useState(props.value); + useEffect(() => { + if (!isWeb) setMeasurementText(props.value); + }, [props.value, props.textReplacementKey]); + const handleTextChange = useCallback( + (text: string) => { + if (!isWeb) setMeasurementText(text); + onChangeText(text); + }, + [onChangeText, setMeasurementText], + ); + const measureLines = useCallback( + (event: import("react-native").TextLayoutEvent) => + onRenderedLinesChange(event.nativeEvent.lines.length), + [onRenderedLinesChange], + ); if (props.readOnly) { return ( @@ -664,12 +678,22 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen ); } return ( - + + {!isWeb && props.compact && !props.fullscreen ? ( + + {`${measurementText}\u200b`} + + ) : null} Promise | void; toast: { error: (msg: string) => void }; interruptBeforeVoiceMessage: string; + exitFullscreen: () => void; } function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { @@ -858,6 +883,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { ctx.toast.error(ctx.interruptBeforeVoiceMessage); return; } + ctx.exitFullscreen(); void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => { console.error("[MessageInput] Failed to start realtime voice", error); const message = extractErrorMessage(error); @@ -872,6 +898,7 @@ interface StartDictationContext { canStartDictation: () => boolean; toast: { error: (msg: string) => void }; startDictation: () => Promise; + exitFullscreen: () => void; } async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise { @@ -882,6 +909,7 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis if (!ctx.canStartDictation()) { return; } + ctx.exitFullscreen(); await ctx.startDictation(); } @@ -915,6 +943,7 @@ interface SendMessageContext { onSubmit: (payload: MessagePayload) => void; onMinimizeHeight: () => void; preserveHeightOnSubmit: boolean; + exitFullscreen: () => void; } function sendMessageImpl(ctx: SendMessageContext): void { @@ -927,6 +956,7 @@ function sendMessageImpl(ctx: SendMessageContext): void { ) { return; } + ctx.exitFullscreen(); ctx.onSubmit({ text: trimmed, attachments: ctx.attachments, @@ -947,12 +977,14 @@ interface QueueMessageContext { onQueue: ((payload: MessagePayload) => void) | undefined; replaceText: (text: string) => void; onMinimizeHeight: () => void; + exitFullscreen: () => void; } function queueMessageImpl(ctx: QueueMessageContext): void { if (!ctx.onQueue) return; const trimmed = ctx.value.trim(); if (!trimmed && ctx.attachments.length === 0) return; + ctx.exitFullscreen(); ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd }); ctx.replaceText(""); ctx.onMinimizeHeight(); @@ -983,7 +1015,8 @@ function computeIsDictationStartEnabled( return (isReadyForDictation ?? isConnected) && !disabled; } -function resolveMaxInputHeight(windowHeight: number): number { +function resolveMaxInputHeight(windowHeight: number, compact: boolean): number { + if (compact) return DEFAULT_MAX_INPUT_HEIGHT; if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT; return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO)); } @@ -1008,8 +1041,8 @@ function getComposerInputSnapshot( fallbackText: string, fallbackSelection: ComposerInputSnapshot["selection"], ): ComposerInputSnapshot { - const text = current?.getText() ?? fallbackText; const textArea = getWebTextAreaImpl(current); + const text = textArea?.value ?? current?.getText() ?? fallbackText; const start = textArea?.selectionStart ?? fallbackSelection.start; const end = textArea?.selectionEnd ?? fallbackSelection.end; return { text, selection: { start, end } }; @@ -1139,6 +1172,28 @@ function extractErrorMessage(error: unknown): string | null { return null; } +function resolvePresentationSendDisabled(input: { + fullscreen: boolean; + disabled: boolean; + hasText: boolean; + attachments: ComposerAttachment[]; + hasExternalContent: boolean; + allowEmpty: boolean; +}): boolean { + return ( + input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input)) + ); +} + +function shouldShowFullscreenControl( + compact: boolean, + readOnly: boolean, + overlayVisible: boolean, + renderedLines: number, +) { + return compact && !readOnly && !overlayVisible && renderedLines > 2; +} + export const MessageInput = forwardRef( function MessageInput(props, ref) { const { @@ -1186,11 +1241,15 @@ export const MessageInput = forwardRef( textReplacement, submitLabel, } = resolveMessageInputProps(props); + const { isPaneFocused } = useComposerKeyboardScope(); const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const { height: windowHeight } = useWindowDimensions(); - const maxInputHeight = resolveMaxInputHeight(windowHeight); + const editingSession = useComposerEditingSession(inputMode, props.editingSession); + const isFullscreen = useComposerFullscreen(editingSession); + const [renderedLines, setRenderedLines] = useState(1); + const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const toast = useToast(); const voice = useVoiceOptional(); @@ -1207,27 +1266,14 @@ export const MessageInput = forwardRef( const inputWrapperRef = useRef(null); const textInputRef = useRef(null); const isInputFocusedRef = useRef(false); + const focusIntentRef = useRef(false); const valueRef = useRef(value); const selectionRef = useRef({ start: value.length, end: value.length }); + const paneFocusedRef = useRef(isPaneFocused); + paneFocusedRef.current = isPaneFocused; + const [webTextArea, setWebTextArea] = useState(null); const appliedTextReplacementKeyRef = useRef(textReplacement.key); const webTextareaRef = useRef(null); - const getLiveText = useCallback(() => valueRef.current, []); - const composerHeight = useComposerHeight({ - getText: getLiveText, - textareaRef: webTextareaRef, - minHeight: MIN_INPUT_HEIGHT, - maxHeight: maxInputHeight, - }); - const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight; - const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined; - const updateComposerHeightForText = measuredComposerHeight?.onTextChange; - const resetComposerHeight = measuredComposerHeight?.reset; - - const handleComposerLayout = useCallback( - (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height), - [onHeightChange], - ); - const updateLiveTextPresence = useCallback((text: string) => { const nextHasLiveText = text.trim().length > 0; if (hasLiveTextRef.current === nextHasLiveText) return; @@ -1235,6 +1281,80 @@ export const MessageInput = forwardRef( setHasLiveText(nextHasLiveText); }, []); + // One editor stays mounted through every layout mode, so this ref only follows + // the editor's own mount and unmount. + const assignTextInput = useCallback((input: ComposerTextInputHandle | null) => { + textInputRef.current = input; + if (!isWeb) return; + const element = getWebTextAreaImpl(input) as HTMLElement | null; + webTextareaRef.current = element; + setWebTextArea(element); + }, []); + // Only mouse-driven UIs restore editing focus on navigation. Touch users return + // to the conversation; replaying old focus would reopen their software keyboard. + // Read pointer capability when restoring, without making a pointer change itself + // a focus trigger. + useLayoutEffect(() => { + if (isPaneFocused && focusIntentRef.current && hasFinePointer()) { + textInputRef.current?.focus(); + } + }, [isPaneFocused]); + // The compact and desktop screens mount the editor in different trees, so a + // viewport that crosses the breakpoint remounts it. The draft owner brings the + // text back; the session brings the caret and focus intent back. On web the + // owner may still hold an unpublished edit while the new editor mounts, so the + // caret is placed now against the text on screen and again when the owner's + // hydrated text arrives. + const retainedSelectionRef = useRef<{ start: number; end: number } | null>(null); + useLayoutEffect(() => { + const retained = editingSession.takeEditor(); + if (retained) { + const text = textInputRef.current?.getText() ?? valueRef.current; + const selection = resolveRetainedSelection(retained.selection, text.length); + retainedSelectionRef.current = retained.selection; + selectionRef.current = selection; + textInputRef.current?.setSelection(selection); + if (retained.focused && hasFinePointer()) { + focusIntentRef.current = true; + if (paneFocusedRef.current) textInputRef.current?.focus(); + } + } + return () => { + editingSession.retainEditor({ + selection: getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ).selection, + focused: isInputFocusedRef.current || focusIntentRef.current, + }); + }; + }, [editingSession]); + const getLiveText = useCallback(() => valueRef.current, []); + const composerHeight = useComposerHeight({ + getText: getLiveText, + textareaRef: webTextareaRef, + minHeight: MIN_INPUT_HEIGHT, + maxHeight: maxInputHeight, + onRenderedLinesChange: setRenderedLines, + presentationKey: isFullscreen, + }); + const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight; + const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined; + const updateComposerHeightForText = measuredComposerHeight?.onTextChange; + const resetComposerHeight = measuredComposerHeight?.reset; + + const exitFullscreen = useCallback(() => editingSession.setFullscreen(false), [editingSession]); + const enterFullscreen = useCallback(() => editingSession.setFullscreen(true), [editingSession]); + + // The conversation sizes itself against the inline composer; fullscreen covers it. + const handleComposerLayout = useCallback( + (event: LayoutChangeEvent) => { + if (!isFullscreen) onHeightChange?.(event.nativeEvent.layout.height); + }, + [isFullscreen, onHeightChange], + ); + const replaceText = useCallback( (nextText: string, selection?: { start: number; end: number }) => { updateComposerHeightForText?.(valueRef.current, nextText); @@ -1297,8 +1417,15 @@ export const MessageInput = forwardRef( updateComposerHeightForText?.(valueRef.current, textReplacement.text); valueRef.current = textReplacement.text; updateLiveTextPresence(textReplacement.text); + const retainedSelection = + textReplacement.kind === "hydrated" ? retainedSelectionRef.current : null; + retainedSelectionRef.current = null; if (textReplacement.text === "") { textInputRef.current?.reset(); + } else if (retainedSelection) { + const selection = resolveRetainedSelection(retainedSelection, textReplacement.text.length); + selectionRef.current = selection; + textInputRef.current?.replaceText(textReplacement.text, selection); } else { textInputRef.current?.replaceText(textReplacement.text); } @@ -1412,8 +1539,9 @@ export const MessageInput = forwardRef( canStartDictation, toast, startDictation, + exitFullscreen, }), - [canStartDictation, dictationUnavailableMessage, startDictation, toast], + [canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen], ); const handleVoicePress = useCallback( @@ -1485,10 +1613,12 @@ export const MessageInput = forwardRef( handleStopRealtimeVoice, toast, interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"), + exitFullscreen, }); }, [ disabled, handleStopRealtimeVoice, + exitFullscreen, isAgentRunning, isConnected, t, @@ -1517,6 +1647,7 @@ export const MessageInput = forwardRef( onSubmit, onMinimizeHeight: minimizeInputHeight, preserveHeightOnSubmit, + exitFullscreen, }); }, [ allowEmptySubmit, @@ -1528,6 +1659,7 @@ export const MessageInput = forwardRef( minimizeInputHeight, preserveHeightOnSubmit, updateLiveTextPresence, + exitFullscreen, ]); const handleQueueMessage = useCallback( @@ -1539,8 +1671,9 @@ export const MessageInput = forwardRef( onQueue, replaceText, onMinimizeHeight: minimizeInputHeight, + exitFullscreen, }), - [attachments, cwd, onQueue, replaceText, minimizeInputHeight], + [attachments, cwd, onQueue, replaceText, minimizeInputHeight, exitFullscreen], ); const handleDefaultSendAction = useCallback(() => { @@ -1563,19 +1696,8 @@ export const MessageInput = forwardRef( }); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); - const getWebTextArea = useCallback( - (): TextAreaHandle | null => getWebTextAreaImpl(textInputRef.current), - [], - ); - - useLayoutEffect(() => { - if (isWeb) { - webTextareaRef.current = getWebTextArea() as HTMLElement | null; - } - }, [getWebTextArea]); - usePasteImagesEffect({ - getWebTextArea, + textarea: webTextArea, isConnected, disabled, isDictating, @@ -1596,16 +1718,30 @@ export const MessageInput = forwardRef( const shouldHandleWebKeyPress = isWeb; const shouldSubmitOnEnter = isWeb && !isCompact; - function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { - if (!shouldHandleWebKeyPress) return; - handleDesktopKeyPressImpl(event, { + const handleDesktopKeyPress = useCallback( + (event: WebTextInputKeyPressEvent) => { + if (!shouldHandleWebKeyPress) return; + handleDesktopKeyPressImpl(event, { + onKeyPressCallback, + input: getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ), + submitOnEnter: shouldSubmitOnEnter, + isAgentRunning, + onQueue, + isSubmitDisabled, + isSubmitLoading, + disabled, + handleAlternateSendAction, + handleDefaultSendAction, + }); + }, + [ + shouldHandleWebKeyPress, onKeyPressCallback, - input: getComposerInputSnapshot( - textInputRef.current, - valueRef.current, - selectionRef.current, - ), - submitOnEnter: shouldSubmitOnEnter, + shouldSubmitOnEnter, isAgentRunning, onQueue, isSubmitDisabled, @@ -1613,8 +1749,8 @@ export const MessageInput = forwardRef( disabled, handleAlternateSendAction, handleDefaultSendAction, - }); - } + ], + ); const primaryActionKind = resolvePrimaryActionKind({ hasSendableContent: hasSendableComposerContent({ @@ -1678,12 +1814,15 @@ export const MessageInput = forwardRef( ); const handleInputFocus = useCallback(() => { + focusIntentRef.current = true; isInputFocusedRef.current = true; setIsInputFocused(true); onFocusChange?.(true); }, [onFocusChange]); const handleInputBlur = useCallback(() => { + // A blur in the active pane is a dismissal; only hiding the pane preserves intent. + if (paneFocusedRef.current) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); @@ -1723,11 +1862,12 @@ export const MessageInput = forwardRef( const inputWrapperCombinedStyle = useMemo( () => [ styles.inputWrapper, + isCompact && !isFullscreen && styles.inputWrapperCompact, readOnly && styles.inputWrapperReadOnly, inputWrapperStyle, { opacity: surfacePresentation.input.opacity }, ], - [inputWrapperStyle, readOnly, surfacePresentation.input.opacity], + [inputWrapperStyle, isCompact, isFullscreen, readOnly, surfacePresentation.input.opacity], ); // `withUnistyles` maps this component's `style` into a `.hash > *` child // rule, which ties on specificity with react-native-web's own @@ -1735,8 +1875,13 @@ export const MessageInput = forwardRef( // `fontFamily` here is silently dropped while every other property lands. // An inline style outranks both classes. See docs/unistyles.md. const textInputStyle = useMemo( - () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle], - [composerHeightStyle, mode.isMonospace], + () => [ + styles.textInput, + mode.isMonospace && styles.textInputMonospace, + isFullscreen ? styles.fullscreenTextInput : composerHeightStyle, + isCompact && !isFullscreen && styles.compactTextInput, + ], + [composerHeightStyle, mode.isMonospace, isFullscreen, isCompact], ); // Static content has no textarea to mirror, so it grows with its own text // instead of the measured input height. @@ -1744,13 +1889,22 @@ export const MessageInput = forwardRef( () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText], [mode.isMonospace], ); + // Fullscreen always shows Send; without content it is disabled, and looks it. + const isPresentedSendDisabled = resolvePresentationSendDisabled({ + fullscreen: isFullscreen, + disabled: isSendButtonDisabled, + hasText: hasLiveText, + attachments, + hasExternalContent, + allowEmpty: allowEmptySubmit, + }); const sendButtonCombinedStyle = useMemo( () => [ styles.sendButton, submitLabel ? styles.sendButtonLabeled : undefined, - isSendButtonDisabled && styles.buttonDisabled, + isPresentedSendDisabled && styles.buttonDisabled, ], - [isSendButtonDisabled, submitLabel], + [isPresentedSendDisabled, submitLabel], ); const overlayContainerStyle = useMemo( () => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }], @@ -1780,107 +1934,177 @@ export const MessageInput = forwardRef( [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], ); - return ( - - ( + - {/* Regular input */} - - {attachmentSlot} - {/* Text input */} - - ( + + + + ), + [ + readOnly, + textReplacement, + assignTextInput, + textInputStyle, + readOnlyTextStyle, + placeholder, + t, + mode.accessibilityLabelKey, + handleInputChange, + handleInputFocus, + handleInputBlur, + isDictating, + isRealtimeVoiceForCurrentAgent, + disabled, + isComposerScrollEnabled, + shouldHandleWebKeyPress, + handleDesktopKeyPress, + handleSelectionChange, + onPasteImages, + handlePasteError, + isFullscreen, + isCompact, + isInputFocused, + value, + focusInputKeys, + ], + ); + const toolbar = useMemo( + () => ( + + {/* Toolbar left: attachment button + agent controls */} + + - + {leftContent} + - {/* Button row */} - - {/* Toolbar left: attachment button + agent controls */} - - - {leftContent} - - - {/* Right: voice button, contextual button (realtime/send/cancel) */} - - {beforeVoiceContent} - - {rightContent} - - + {/* Right: voice button, contextual button (realtime/send/cancel) */} + + {beforeVoiceContent} + + {rightContent} + {sendButton} - + ), + [ + mode.showAttachments, + isConnected, + disabled, + attachButtonStyle, + renderAttachButtonIcon, + attachmentMenuItems, + t, + leftContent, + beforeVoiceContent, + mode.showVoice, + handleVoicePress, + isDictationStartEnabled, + voiceButtonAccessibilityLabel, + voiceButtonStyle, + renderVoiceButtonIcon, + voiceTooltipText, + isRealtimeVoiceForCurrentAgent, + voiceMuteToggleKeys, + dictationToggleKeys, + rightContent, + sendButton, + ], + ); + const voiceOverlay = useMemo( + () => ( ( onRealtimeVoiceStop={handleRealtimeVoiceStop} /> + ), + [ + overlayContainerStyle, + surfacePresentation.overlay.pointerEvents, + showDictationOverlay, + showRealtimeOverlay, + voice, + dictationVolume, + dictationDuration, + isDictating, + isDictationProcessing, + dictationStatus, + dictationError, + handleCancelRecording, + handleAcceptRecording, + handleAcceptAndSendRecording, + handleRetryFailedRecording, + handleDiscardFailedRecording, + handleRealtimeVoiceStop, + ], + ); + return ( + + + ); }, @@ -1913,6 +2191,10 @@ const styles = StyleSheet.create((theme: Theme) => ({ flexShrink: 1, position: "relative", }, + fullscreenContainer: { + flex: 1, + position: "relative", + }, inputWrapper: { flexShrink: 1, flexDirection: "column", @@ -1942,11 +2224,32 @@ const styles = StyleSheet.create((theme: Theme) => ({ } : {}), }, + // Lifts the composer off the chat it sits under on phones. + inputWrapperCompact: { + boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)", + }, // Dotted says "this surface is the same box, but there is nothing to type // into it" without swapping the border colour, which reads as an error state. inputWrapperReadOnly: { borderStyle: "dotted", }, + // The band the fullscreen glyph sits in is the input's own padding, so a tap + // anywhere in it (under the glyph, or where the glyph would be) is a tap on the + // editor: it focuses, shows the keyboard, and places the caret. Reserved whether + // or not the glyph shows, so text never reflows when it appears. + compactTextInput: { paddingRight: 36 }, + fullscreenTextSurface: { flex: 1, minHeight: 0 }, + fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" }, + lineMeasurement: { + position: "absolute", + top: 0, + left: 0, + right: 0, + opacity: 0, + maxHeight: undefined, + minHeight: undefined, + height: undefined, + }, textInputScrollWrapper: { flexShrink: 1, position: "relative", diff --git a/packages/app/src/composer/input/presentation.tsx b/packages/app/src/composer/input/presentation.tsx new file mode 100644 index 0000000000..64697999f5 --- /dev/null +++ b/packages/app/src/composer/input/presentation.tsx @@ -0,0 +1,171 @@ +import { useCallback, useMemo, type ReactNode, type Ref } from "react"; +import { View, type ViewProps } from "react-native"; +import { Maximize2, Minimize2 } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock"; +import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; +import { isWeb } from "@/constants/platform"; +import { ICON_SIZE } from "@/styles/theme"; +import { StyleSheet } from "react-native-unistyles"; +import { useComposerFullscreenExit } from "./fullscreen-exit"; +import { resolveGlyphRailOffset } from "./state"; + +const CONTROL_SIZE = 28; +const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md; + +function keepEditorFocused(event: import("react-native").PointerEvent) { + if (isWeb) event.preventDefault(); +} + +/** + * One tree for both layout modes. The editing surface keeps its position so the + * mounted editor, its native buffer, selection, and focus survive every toggle; + * only the chrome around it changes. Fullscreen shows exit top right and Send + * bottom right, both on the rail the inline Send button uses; a send error sits + * in the same footer as Send. + */ +export function ComposerInputPresentation({ + fullscreen, + sendIconSize, + surface, + toolbar, + send, + error, + onExit, + inputWrapperRef, + inputWrapperStyle, + inputPointerEvents, + attachmentSlot, + showFullscreenButton, + onEnter, + overlay, +}: { + fullscreen: boolean; + sendIconSize: number; + surface: ReactNode; + toolbar: ReactNode; + send: ReactNode; + error: ReactNode; + onExit: () => void; + inputWrapperRef: Ref; + inputWrapperStyle: ViewProps["style"]; + inputPointerEvents: ViewProps["pointerEvents"]; + attachmentSlot: ReactNode; + showFullscreenButton: boolean; + onEnter: () => void; + overlay: ReactNode; +}) { + const { t } = useTranslation(); + // A retained tab keeps its fullscreen editor mounted while hidden; the global exit + // action (Escape, hardware back) belongs only to the pane the user is in. + const { isActiveComposer } = useComposerKeyboardScope(); + const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit); + const railOffset = resolveGlyphRailOffset({ + controlSize: CONTROL_SIZE, + sendIconSize, + glyphIconSize: FULLSCREEN_GLYPH_SIZE, + }); + const maximizeIcon = useCallback( + (color: string) => , + [], + ); + const minimizeIcon = useCallback( + (color: string) => , + [], + ); + // The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge. + const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]); + const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]); + return ( + + {fullscreen ? ( + + + + ) : null} + + + {fullscreen ? null : attachmentSlot} + + {surface} + {showFullscreenButton && !fullscreen ? ( + + ) : null} + + {fullscreen ? null : toolbar} + + + {fullscreen ? ( + + {error} + {send} + + ) : null} + {overlay} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + inline: { flexShrink: 1, position: "relative" }, + inlineTextSurface: { flexShrink: 1, position: "relative" }, + iconButton: { + width: CONTROL_SIZE, + height: CONTROL_SIZE, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + enterButton: { + position: "absolute", + top: 0, + width: CONTROL_SIZE, + height: CONTROL_SIZE, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + fullscreen: { + flex: 1, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[4], + gap: theme.spacing[3], + }, + fullscreenInputWrapper: { flex: 1, minHeight: 0 }, + fullscreenTextSurface: { flex: 1, minHeight: 0 }, + exitRow: { alignItems: "flex-end", flexShrink: 0 }, + // The send error rides the keyboard with Send; below the body it would sit behind it. + footer: { flexShrink: 0, gap: theme.spacing[3] }, + sendRow: { alignItems: "flex-end" }, +})); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 5503ea1512..6f2cdef74d 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -4,6 +4,9 @@ import { computeCanStartDictation, resolveActiveSendBehavior, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, + resolveGlyphRailOffset, + resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -316,3 +319,46 @@ describe("stopRealtimeVoice", () => { expect(calls).toEqual(["cancel agent", "stop voice"]); }); }); + +describe("composer surface text", () => { + it("measures a new nonempty replacement before it reaches the editing handle", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Short draft", replacement, "draft:1")).toBe( + replacement.text, + ); + }); + + it("keeps live typing once the replacement is applied", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Edited live draft", replacement, "draft:2")).toBe( + "Edited live draft", + ); + }); + + it("keeps the live buffer when the initial publication is already applied", () => { + const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" }; + expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe( + "Live edited draft", + ); + }); +}); + +describe("retained selection", () => { + it("keeps a caret that fits and clamps one that no longer does", () => { + expect(resolveRetainedSelection({ start: 4, end: 17 }, 40)).toEqual({ start: 4, end: 17 }); + expect(resolveRetainedSelection({ start: 4, end: 17 }, 10)).toEqual({ start: 4, end: 10 }); + expect(resolveRetainedSelection({ start: 12, end: 17 }, 10)).toEqual({ start: 10, end: 10 }); + expect(resolveRetainedSelection({ start: -1, end: 3 }, 10)).toEqual({ start: 0, end: 3 }); + }); +}); + +describe("fullscreen glyph rail", () => { + it("nudges 16px fullscreen glyphs onto the Send arrow's ink edge", () => { + expect( + resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 16, glyphIconSize: 16 }), + ).toBeCloseTo(4 / 3); + expect( + resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 20, glyphIconSize: 16 }), + ).toBeCloseTo(1 / 6); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index a63aeb02dc..698965b5ac 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,6 +1,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; -import type { MessagePayload } from "@/composer/types"; +import type { MessagePayload, TextReplacement } from "@/composer/types"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; export type SendBehavior = ActiveTurnBehavior | "queue"; @@ -192,3 +192,39 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise< await ctx.voice.stopVoice(); } + +/** + * Offset that aligns a glyph's visible ink with the Send arrow's ink when both sit in + * equal controls on the same right rail. In Lucide's 24px viewBox ArrowUp ends at + * x19 while Maximize2/Minimize2 end at x21, so equal boxes leave the fullscreen + * glyphs visibly past the arrow. + */ +export function resolveGlyphRailOffset(input: { + controlSize: number; + sendIconSize: number; + glyphIconSize: number; +}): number { + const arrowInkInset = + (input.controlSize - input.sendIconSize) / 2 + (5 * input.sendIconSize) / 24; + const glyphInkInset = + (input.controlSize - input.glyphIconSize) / 2 + (3 * input.glyphIconSize) / 24; + return arrowInkInset - glyphInkInset; +} + +export function resolveComposerSurfaceText( + liveText: string, + replacement: TextReplacement, + appliedReplacementKey: string, +): string { + return replacement.key === appliedReplacementKey ? liveText : replacement.text; +} + +/** A retained caret must fit the text the remounted editor shows. */ +export function resolveRetainedSelection( + retained: { start: number; end: number }, + textLength: number, +): { start: number; end: number } { + const end = Math.min(Math.max(retained.end, 0), textLength); + const start = Math.min(Math.max(retained.start, 0), end); + return { start, end }; +} diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 6e70fa3edf..29df20012c 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -96,6 +96,35 @@ describe("ComposerTextInput web IME composition", () => { expect(document.activeElement).toBe(mounted.textarea); }); + it("keeps its editing handle and selection across a rerender during composition", () => { + const recorder = createTextRecorder(); + const mounted = mountInput(recorder.onChangeText); + const handle = mounted.inputRef.current; + act(() => { + handle?.focus(); + dispatchComposition(mounted.textarea, "compositionstart"); + typeFromIme(mounted.textarea, "你好"); + mounted.textarea.setSelectionRange(1, 2); + mounted.root.render( + , + ); + }); + expect(mounted.inputRef.current).toBe(handle); + expect(handle?.getNativeRef()).toBe(mounted.textarea); + expect([mounted.textarea.selectionStart, mounted.textarea.selectionEnd]).toEqual([1, 2]); + expect(document.activeElement).toBe(mounted.textarea); + expect(recorder.changes).toEqual([]); + act(() => dispatchComposition(mounted.textarea, "compositionend")); + expect(recorder.changes).toEqual(["你好"]); + expect(handle?.getText()).toBe("你好"); + }); + it("keeps locally typed text when its parent rerenders with a stale value", () => { const recorder = createTextRecorder(); const mounted = mountInput(recorder.onChangeText); diff --git a/packages/app/src/composer/keyboard-scope.tsx b/packages/app/src/composer/keyboard-scope.tsx index b254c53a51..f8087bf420 100644 --- a/packages/app/src/composer/keyboard-scope.tsx +++ b/packages/app/src/composer/keyboard-scope.tsx @@ -7,18 +7,33 @@ import { } from "react"; interface ComposerKeyboardScopeValue { + /** + * The pane this composer sits in is the user's pane. Mouse-driven focus + * restoration follows it; touch navigation does not restore editor focus. + */ + isPaneFocused: boolean; + /** + * The composer takes input right now: keyboard actions, shortcuts, autocomplete, + * and the fullscreen exit action. False while a compact overlay panel covers the + * pane, which does not take pane focus. + */ isActiveComposer: boolean; } const ComposerKeyboardScopeContext = createContext({ + isPaneFocused: false, isActiveComposer: false, }); export function ComposerKeyboardScopeProvider({ + isPaneFocused, isActiveComposer, children, }: PropsWithChildren): ReactElement { - const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]); + const value = useMemo( + () => ({ isPaneFocused, isActiveComposer }), + [isPaneFocused, isActiveComposer], + ); return ( {children} diff --git a/packages/app/src/composer/submission/model.ts b/packages/app/src/composer/submission/model.ts index 26febae04f..67301bd5ff 100644 --- a/packages/app/src/composer/submission/model.ts +++ b/packages/app/src/composer/submission/model.ts @@ -17,6 +17,14 @@ export function getActiveMessageSubmissions( ); } +export function hasActiveMessageSubmission( + submissions: readonly MessageSubmissionRecord[] | null | undefined, +): boolean { + return (submissions ?? EMPTY_MESSAGE_SUBMISSIONS).some( + (submission) => !submission.providerAcknowledged, + ); +} + export function getSendingClientMessageIds( submissions: readonly MessageSubmissionRecord[] | null | undefined, ): string[] { diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index 0b74c7e1da..9ebc92ed20 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -1,7 +1,8 @@ -import React, { act, type ReactNode } from "react"; +import React, { act, useCallback, useState, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { Pressable, Text } from "react-native"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RetainedPanelActivity } from "@/components/retained-panel"; import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks"; const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }]; @@ -109,6 +110,43 @@ describe("composer track panel", () => { expect(row("row")).not.toBeNull(); }); + it("closes for good when the panel it belongs to leaves the screen", () => { + const onPress = vi.fn(); + function Harness() { + const [active, setActive] = useState(true); + const hide = useCallback(() => setActive(false), []); + const show = useCallback(() => setActive(true), []); + return ( + <> + + + + + + Subagent one + + + + + ); + } + mount(); + + openPanel(); + expect(isPanelOpen()).toBe(true); + + click(row("hide") as Element); + click(row("show") as Element); + + expect(isPanelOpen()).toBe(false); + expect(row("row")).toBeNull(); + }); + it("leaves the panel alone when a row's own action button is pressed", () => { const onPress = vi.fn(); const onAction = vi.fn(); diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index 2f03b7e89a..cb46412de0 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -9,7 +9,12 @@ export interface MessagePayload { forceSend?: boolean; } +/** + * Text the draft owner hands the editor. `hydrated` is the owner's answer to a mount: + * the draft's text as persisted or still pending from the editor that unmounted. + */ export interface TextReplacement { + kind: "initial" | "hydrated" | "replace"; key: string; text: string; } diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 721708715c..9e9a15f6b4 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -33,13 +33,18 @@ export { getIsElectronMac as getIsElectronRuntimeMac, } from "./platform"; +/** Compact layouts are the breakpoints below `md`. */ +export function isCompactBreakpoint(breakpoint: string | undefined): boolean { + return breakpoint === "xs" || breakpoint === "sm"; +} + /** * Reactive hook — re-renders the component when the breakpoint changes. * Always use this instead of reading UnistylesRuntime.breakpoint directly. */ export function useIsCompactFormFactor(): boolean { const { rt } = useUnistyles(); - return rt.breakpoint === "xs" || rt.breakpoint === "sm"; + return isCompactBreakpoint(rt.breakpoint); } // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 3e8165146c..430e4ade74 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -50,7 +50,7 @@ import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; -import { useProviderSubagentStore } from "@/subagents/provider-store"; +import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -532,9 +532,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }); - const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => { - if (message.type !== "agent.provider_subagents.update") return; - useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + const unsubProviderSubagentUpdate = feeds.subscribe({ + snapshot: () => { + void resyncProviderSubagents(client, serverId); + }, + update: (message) => { + if (message.type !== "agent.provider_subagents.update") return; + useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + }, }); const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => { diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 1fdb1c6f5d..1bd32db53d 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -8,6 +8,7 @@ export interface AcpProviderCatalogEntry { iconSvg: string | null; installLink: string; command: readonly [string, ...string[]]; + bundledProvider?: boolean; env?: Readonly>; params?: Readonly>; } @@ -238,6 +239,16 @@ const CATALOG_DATA = [ installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", command: ["hermes", "acp"], }, + { + id: "hoplite", + bundledProvider: true, + title: "Hoplite", + description: "Cloud coding agent through the local hoplite acp bridge", + version: "manual", + iconId: "hoplite", + installLink: "https://hoplite.sh/docs/cli#install", + command: ["hoplite", "acp"], + }, { id: "junie", title: "Junie", @@ -389,6 +400,7 @@ export const ACP_PROVIDER_CATALOG: AcpProviderCatalogEntry[] = CATALOG_DATA.map( version: entry.version, installLink: entry.installLink, command: entry.command, + bundledProvider: "bundledProvider" in entry ? entry.bundledProvider : undefined, env: "env" in entry ? entry.env : undefined, params: "params" in entry ? entry.params : undefined, iconSvg: entry.iconId ? (ACP_PROVIDER_ICON_SVGS[entry.iconId] ?? null) : null, diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index 01fba418a9..3bf9e99f9b 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -199,6 +199,71 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage { } describe("server data push router", () => { + it("keeps a visible diff subscribed when a hidden panel switches to the same comparison", () => { + const queryClient = new QueryClient(); + const fake = createFakeClient(); + const serverId = "server-1"; + const cwd = "/repo"; + const options = (mode: "base" | "uncommitted", enabled: boolean) => { + const queryKey = checkoutDiffQueryKey( + serverId, + cwd, + mode, + mode === "base" ? "main" : undefined, + ); + return { + queryKey, + queryFn: skipToken as typeof skipToken, + gcTime: Infinity, + meta: checkoutDiffPushRoute({ + enabled, + serverId, + cwd, + subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`, + compare: { mode, ...(mode === "base" ? { baseRef: "main" } : {}) }, + }), + }; + }; + const visible = new QueryObserver(queryClient, options("base", true)); + const hidden = new QueryObserver(queryClient, options("base", false)); + const unsubscribeVisible = visible.subscribe(() => {}); + const unsubscribeHidden = hidden.subscribe(() => {}); + const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId }); + try { + visible.setOptions(options("uncommitted", true)); + hidden.setOptions(options("uncommitted", false)); + const subscription = fake.subscribeCheckoutDiffCalls.findLast( + (call) => call.compare.mode === "uncommitted", + )!; + const files = [ + { + path: "README.md", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 0, + hunks: [], + }, + ]; + fake.emit({ + type: "checkout_diff_update", + payload: { subscriptionId: subscription.subscriptionId, cwd, files, error: null }, + }); + expect(queryClient.getQueryData(options("uncommitted", true).queryKey)).toMatchObject({ + files, + }); + expect(fake.unsubscribeCheckoutDiffCalls).not.toContain(subscription.subscriptionId); + + unsubscribeVisible(); + expect(fake.unsubscribeCheckoutDiffCalls).toContain(subscription.subscriptionId); + } finally { + unsubscribeVisible(); + unsubscribeHidden(); + unmount(); + queryClient.clear(); + } + }); + it("retains equal diff files without reconstructing changed file bodies", () => { const queryClient = new QueryClient(); const fake = createFakeClient(); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index fc6d51ce4f..5ab3069bd0 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -494,6 +494,14 @@ function getActiveServerDataRoute( if (query.getObserversCount() === 0) { return null; } + // Query metadata belongs to the last observer that updated its options. A + // retained hidden panel must not cancel demand from another visible panel. + const observerRoutes = query.observers + .map((observer) => getServerDataRoute({ meta: observer.options.meta })) + .filter((route): route is ServerDataRoute => route !== null); + if (observerRoutes.length > 0) { + return observerRoutes.find((route) => route.enabled && route.serverId === serverId) ?? null; + } const route = getServerDataRoute(query); if (route) { return route.enabled && route.serverId === serverId ? route : null; @@ -590,7 +598,7 @@ function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"]) ); } -function getServerDataRoute(query: Query): ServerDataRoute | null { +function getServerDataRoute(query: Pick): ServerDataRoute | null { const meta = query.meta; if (!isRecord(meta) || !isRecord(meta.serverData)) { return null; diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index 9209f6fcbf..982469008a 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -80,7 +80,6 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps) type: "svg", errorCorrectionLevel: "M", margin: 1, - width: 480, }), enabled: Boolean(pairingQuery.data?.url), dataShape: "value", @@ -275,7 +274,8 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) { return ( @@ -365,10 +365,6 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.border, backgroundColor: theme.colors.palette.white, }, - qrImage: { - width: "100%", - height: "100%", - }, linkRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index d396697c41..8b04590d28 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -165,4 +165,7 @@ const FRAME_STYLE = { minHeight: 0, minWidth: 0, } as const; -const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const; +// CodeMirror's scrollIntoView scrolls every overflowing ancestor and stops at a fixed or sticky +// one. Sticky (with no offsets it lays out like relative) keeps line reveals inside the editor, so +// they don't shift the host around it, such as the compact workspace sheet. +const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden", position: "sticky" } as const; diff --git a/packages/app/src/file-pane/markdown-preview/image-source.test.ts b/packages/app/src/file-pane/markdown-preview/image-source.test.ts new file mode 100644 index 0000000000..8277e57492 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source"; + +describe("resolveMarkdownFileDirectory", () => { + it("uses the parent of an absolute markdown path", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "/Users/test/project/docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs"); + }); + + it("joins a workspace-relative markdown path to the workspace root", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "docs/星谷云/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/星谷云"); + }); + + it("preserves the host home directory for home-relative markdown paths", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans"); + }); +}); + +describe("resolveMarkdownPreviewImageSource", () => { + it("passes through remote and data URIs", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "https://example.com/image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("https://example.com/image.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "data:image/png;base64,abc", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("data:image/png;base64,abc"); + }); + + it("resolves a sibling image against the markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/images/hero.png"); + }); + + it("ignores local URL query strings and fragments", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/images/hero.png"); + }); + + it("preserves encoded filename characters when stripping URL suffixes", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./hero%23draft%3Fv.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/hero#draft?v.png"); + }); + + it("preserves remote URL query strings and fragments", () => { + const source = "https://example.com/hero.png?v=2#preview"; + expect(resolveMarkdownPreviewImageSource({ source, markdownPath: "docs/guide.md" })).toBe( + source, + ); + }); + + it("resolves parent-relative images that stay inside the workspace", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../../data/cache/xinggu_docs/12-linkedin/token.png", + markdownPath: "docs/星谷云/12-LinkedIn.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png"); + }); + + it("keeps absolute and home-relative image paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "/tmp/screenshot.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/screenshot.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "~/.paseo/screenshots/output.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/screenshots/output.png"); + }); + + it("normalizes file URIs into host paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/paseo-preview.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/paseo-preview.png"); + }); + + it("resolves images beside a home-relative document without using the workspace root", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "screenshots/output.png", + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans/screenshots/output.png"); + }); + + it("decodes a file URI exactly once", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/hero%2520image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/tmp/hero%20image.png"); + }); + + it("resolves parent segments from a home-relative document", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../images/hero.png", + markdownPath: "~/docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("~/images/hero.png"); + }); + + it("resolves Windows relative images against a drive-rooted markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "..\\images\\hero.png", + markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md", + workspaceRoot: "C:\\Users\\test\\repo", + }), + ).toBe("C:/Users/test/repo/images/hero.png"); + }); +}); diff --git a/packages/app/src/file-pane/markdown-preview/image-source.ts b/packages/app/src/file-pane/markdown-preview/image-source.ts new file mode 100644 index 0000000000..5316d94279 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.ts @@ -0,0 +1,150 @@ +import { localFileSourceToPath } from "@/attachments/utils"; +import { isAbsolutePath } from "@/utils/path"; + +function trimTrailingSeparators(value: string): string { + if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) { + return value.replace(/\\/g, "/"); + } + return value.replace(/[\\/]+$/, ""); +} + +function normalizePathSegments(value: string): string { + const segments: string[] = []; + for (const segment of value.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length > 0) { + segments.pop(); + } + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + +function normalizeAbsolutePath(value: string): string | null { + const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/")); + if (isHomeRelativePath(normalizedInput)) { + return `~/${normalizePathSegments(normalizedInput.slice(2))}`; + } + if (!isAbsolutePath(normalizedInput)) { + return null; + } + + const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput); + if (drivePath) { + return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`); + } + + const prefix = normalizedInput.startsWith("//") ? "//" : "/"; + return trimTrailingSeparators( + `${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`, + ); +} + +function parentDirectory(path: string): string | null { + const normalized = trimTrailingSeparators(path.replace(/\\/g, "/")); + if (!normalized) { + return null; + } + if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) { + return normalized.endsWith("/") ? normalized : `${normalized}/`; + } + + const index = normalized.lastIndexOf("/"); + if (index < 0) { + return null; + } + if (index === 0) { + return "/"; + } + const parent = normalized.slice(0, index); + if (/^[A-Za-z]:$/.test(parent)) { + return `${parent}/`; + } + return parent; +} + +function joinAbsolute(baseDirectory: string, relativePath: string): string | null { + const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/")); + const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); + if (!directory) { + return null; + } + return normalizeAbsolutePath(`${directory}/${relative}`); +} + +function isHomeRelativePath(value: string): boolean { + return value === "~" || value.startsWith("~/") || value.startsWith("~\\"); +} + +function decodeRelativeSource(source: string): string { + try { + return decodeURIComponent(source); + } catch { + return source; + } +} + +export function resolveMarkdownFileDirectory(input: { + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const markdownPath = input.markdownPath.trim().replace(/\\/g, "/"); + if (!markdownPath) { + return null; + } + + if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) { + const absolute = normalizeAbsolutePath(markdownPath); + return absolute ? parentDirectory(absolute) : null; + } + + const workspaceRoot = input.workspaceRoot?.trim(); + if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) { + return null; + } + + const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath); + return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null; +} + +export function resolveMarkdownPreviewImageSource(input: { + source: string; + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const source = input.source.trim(); + if (!source) { + return null; + } + + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } + + const pathSource = source.replace(/[?#].*$/, ""); + const path = localFileSourceToPath( + pathSource.startsWith("file://") ? pathSource : decodeRelativeSource(pathSource), + ); + if (!path) { + return null; + } + + if (isHomeRelativePath(path) || isAbsolutePath(path)) { + return path; + } + + const directory = resolveMarkdownFileDirectory({ + markdownPath: input.markdownPath, + workspaceRoot: input.workspaceRoot, + }); + if (!directory) { + return path; + } + + return joinAbsolute(directory, path); +} diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx index e8d172c61b..a79705323f 100644 --- a/packages/app/src/file-pane/markdown-preview/index.tsx +++ b/packages/app/src/file-pane/markdown-preview/index.tsx @@ -1,11 +1,59 @@ import { useMemo } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ASTNode, RenderRules } from "react-native-markdown-display"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { MarkdownRenderer } from "@/components/markdown/renderer"; +import { + createSharedMarkdownRules, + MarkdownRenderer, + type MarkdownStyles, +} from "@/components/markdown/renderer"; import { parseMarkdownPreviewDocument } from "./document"; +import { resolveMarkdownPreviewImageSource } from "./image-source"; +import { FileMarkdownPreviewImage } from "./preview-image"; -export function FileMarkdownPreview({ source }: { source: string }) { +export function FileMarkdownPreview({ + source, + filePath, + workspaceRoot, + client, + serverId, +}: { + source: string; + filePath: string; + workspaceRoot?: string; + client?: DaemonClient | null; + serverId?: string; +}) { const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]); + const rules = useMemo( + () => ({ + ...createSharedMarkdownRules(), + image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => { + const resolved = resolveMarkdownPreviewImageSource({ + source: String(node.attributes?.src ?? ""), + markdownPath: filePath, + workspaceRoot, + }); + if (!resolved) { + return null; + } + + return ( + + ); + }, + }), + [client, filePath, serverId, workspaceRoot], + ); return ( @@ -31,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) { ))} ) : null} - + ); diff --git a/packages/app/src/file-pane/markdown-preview/preview-image.tsx b/packages/app/src/file-pane/markdown-preview/preview-image.tsx new file mode 100644 index 0000000000..ef27c14978 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/preview-image.tsx @@ -0,0 +1,151 @@ +import { useCallback, useMemo, useState } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useTranslation } from "react-i18next"; +import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import type { Theme } from "@/styles/theme"; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const PREVIEW_IMAGE_MIN_HEIGHT = 160; + +export function FileMarkdownPreviewImage({ + source, + occurrenceKey, + alt, + client, + workspaceRoot, + serverId, +}: { + source: string; + occurrenceKey: string; + alt?: string; + client?: DaemonClient | null; + workspaceRoot?: string; + serverId?: string; +}) { + const { t } = useTranslation(); + const [viewerOpen, setViewerOpen] = useState(false); + const openViewer = useCallback(() => setViewerOpen(true), []); + const closeViewer = useCallback(() => setViewerOpen(false), []); + const image = useAssistantImage({ + source, + occurrenceKey, + client, + workspaceRoot, + serverId, + }); + const binding = image.status === "failed" ? null : image.binding; + const aspectRatio = image.status === "failed" ? null : image.aspectRatio; + const imageUri = binding?.uri ?? ""; + const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]); + const imageSizeStyle = useMemo(() => { + if (aspectRatio) { + return { aspectRatio }; + } + return { height: PREVIEW_IMAGE_MIN_HEIGHT }; + }, [aspectRatio]); + const surfaceStyle = useMemo>( + () => [styles.surface, imageSizeStyle], + [imageSizeStyle], + ); + const lightboxSource = useMemo(() => { + if (!viewerOpen || !imageUri) return null; + return { + type: "uri", + uri: imageUri, + contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined, + }; + }, [aspectRatio, imageUri, viewerOpen]); + + if (image.status === "failed") { + return ( + + {image.message} + + ); + } + + if (!binding) { + return ( + + + + ); + } + + return ( + + + + + {image.status === "loading" ? ( + + + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + frame: { + width: "100%", + marginBottom: theme.spacing[4], + }, + surface: { + width: "100%", + overflow: "hidden", + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + image: { + width: "100%", + height: "100%", + }, + state: { + minHeight: PREVIEW_IMAGE_MIN_HEIGHT, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[6], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + }, + loadingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: "center", + justifyContent: "center", + }, + errorText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + textAlign: "center", + }, +})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index a5630ed82f..ea3c5b1edb 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -54,6 +54,9 @@ interface FilePreviewBodyProps { location: WorkspaceFileLocation; navigationRevision: number; imagePreviewUri: string | null; + workspaceRoot: string; + client: DaemonClient | null; + serverId: string; } type TextExplorerFile = ExplorerFile & { kind: "text" }; @@ -135,6 +138,9 @@ function FilePreviewBody({ location, navigationRevision, imagePreviewUri, + workspaceRoot, + client, + serverId, }: FilePreviewBodyProps) { const { t } = useTranslation(); const filePath = location.path; @@ -182,7 +188,13 @@ function FilePreviewBody({ style={styles.previewContent} showsVerticalScrollIndicator > - + ); @@ -297,6 +309,7 @@ export function FilePane({ return ( ); } function EditableFilePane({ + serverId, + workspaceRoot, client, cwd, path, @@ -470,6 +492,8 @@ function EditableFilePane({ location, navigationRevision, }: { + serverId: string; + workspaceRoot: string; client: DaemonClient; cwd: string; path: string; @@ -622,6 +646,9 @@ function EditableFilePane({ location={location} navigationRevision={navigationRevision} imagePreviewUri={null} + workspaceRoot={workspaceRoot} + client={client} + serverId={serverId} /> )} diff --git a/packages/app/src/git/diff-document/hit-testing.test.ts b/packages/app/src/git/diff-document/hit-testing.test.ts index a0ed9c1578..d728c2f2d7 100644 --- a/packages/app/src/git/diff-document/hit-testing.test.ts +++ b/packages/app/src/git/diff-document/hit-testing.test.ts @@ -315,6 +315,7 @@ function reviewActionsForFirstAddition(): NonNullable< commentsByTarget: new Map(), editor: { target, commentId: null, body: "" }, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts index c2b6d86c9c..ace965c1ce 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -389,6 +389,7 @@ describe("diff document model", () => { commentId: null, }, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, @@ -790,6 +791,7 @@ function reviewActionsWithEditor( commentsByTarget: new Map(), editor: target ? { target, body: "", commentId: null } : null, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 139387ccb1..6bafd3eee3 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1,3 +1,5 @@ +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { floatingActionsClearance } from "@/components/ui/floating-action-button"; import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -31,6 +33,7 @@ import { import { type ParsedDiffFile } from "@/git/use-diff-query"; import type { ChangesState } from "@/panels/changes/state"; import { defaultChangesState } from "@/panels/changes/state"; +import { ReviewCommentSheet, ReviewFeedbackAction } from "@/review"; import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document"; import { ChangedFilesTree } from "@/git/changed-files-tree"; import { JUMP_TO_FILE_CLEARANCE, JumpToFile } from "@/git/jump-to-file"; @@ -1548,7 +1551,9 @@ export function ChangesSurface({ isDiffLoading, reviewActions, reviewAttachment, + reviewFeedback, } = useWorkingDiff({ + presentation, serverId, workspaceId: workspaceId ?? undefined, cwd, @@ -1729,6 +1734,7 @@ export function ChangesSurface({ ], ); + const { bottom: bottomInset } = useSafeAreaInsets(); const hasChanges = files.length > 0; const jumpToFileInset = jumpToFileClearance({ isCompact: isMobile, @@ -1778,27 +1784,41 @@ export function ChangesSurface({ }); const diffContent: ReactElement = ( - - + + + + - + ); const bodyContent = ( + + summarizeChecks(checks), [checks]); + const { t } = useTranslation(); + const summary = useMemo(() => summarizeChecks(checks, t), [checks, t]); const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState(); return ( @@ -91,13 +93,14 @@ export function ChecksSection({ {summary.parts.length > 0 ? ( + {summary.detailLead} {summary.parts.map((part, index) => ( {index > 0 ? ", " : ""} {part.text} ))} - {` ${summary.countNoun}`} + {summary.detailTrail} ) : null} @@ -186,6 +189,7 @@ function CheckRow({ isAddingLogsToChat: boolean; onAddLogsToChat: (check: PrPaneCheck) => void; }) { + const { t } = useTranslation(); const handlePress = useCallback(() => { void openExternalUrl(check.url); }, [check.url]); @@ -217,7 +221,9 @@ function CheckRow({ onPress={handleAddLogsToChat} style={styles.addButton} > - {isAddingLogsToChat ? "Adding..." : "Add to chat"} + {isAddingLogsToChat + ? t("workspace.git.pr.actions.addingToChat") + : t("workspace.git.pr.actions.addToChat")} ) : null} {check.timing && {check.timing}} diff --git a/packages/app/src/git/pull-request-panel/checks-summary.test.ts b/packages/app/src/git/pull-request-panel/checks-summary.test.ts index f014500753..747947b907 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.test.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import type { CheckStatus } from "./check-status"; import { summarizeChecks } from "./checks-summary"; import type { PrPaneCheck } from "./data"; @@ -9,7 +10,7 @@ function check(name: string, status: CheckStatus): PrPaneCheck { describe("summarizeChecks", () => { it("reports no checks without claiming the run passed", () => { - const summary = summarizeChecks([]); + const summary = summarizeChecks([], i18n.t); expect(summary.outcome).toBe("none"); expect(summary.headline).toBe("No checks"); @@ -18,11 +19,10 @@ describe("summarizeChecks", () => { }); it("leads with failures when anything failed", () => { - const summary = summarizeChecks([ - check("build", "success"), - check("lint", "failure"), - check("e2e", "pending"), - ]); + const summary = summarizeChecks( + [check("build", "success"), check("lint", "failure"), check("e2e", "pending")], + i18n.t, + ); expect(summary.outcome).toBe("failure"); expect(summary.headline).toBe("Some checks were not successful"); @@ -31,7 +31,7 @@ describe("summarizeChecks", () => { }); it("reports an unfinished run as in progress when nothing failed", () => { - const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")]); + const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")], i18n.t); expect(summary.outcome).toBe("pending"); expect(summary.headline).toBe("Some checks haven't completed yet"); @@ -39,7 +39,10 @@ describe("summarizeChecks", () => { }); it("treats a run of only successes and skips as passed", () => { - const summary = summarizeChecks([check("build", "success"), check("deploy", "skipped")]); + const summary = summarizeChecks( + [check("build", "success"), check("deploy", "skipped")], + i18n.t, + ); expect(summary.outcome).toBe("success"); expect(summary.headline).toBe("All checks have passed"); @@ -47,11 +50,10 @@ describe("summarizeChecks", () => { }); it("keeps the checks of each group together under a counted label", () => { - const summary = summarizeChecks([ - check("build", "failure"), - check("lint", "success"), - check("test", "failure"), - ]); + const summary = summarizeChecks( + [check("build", "failure"), check("lint", "success"), check("test", "failure")], + i18n.t, + ); const [failing, successful] = summary.groups; expect(failing?.label).toBe("2 failing checks"); @@ -60,9 +62,34 @@ describe("summarizeChecks", () => { }); it("says check rather than checks for a run of one", () => { - const summary = summarizeChecks([check("build", "failure")]); + const summary = summarizeChecks([check("build", "failure")], i18n.t); - expect(summary.countNoun).toBe("check"); expect(summary.detail).toBe("1 failing check"); }); + + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + const summary = summarizeChecks( + [ + check("build", "success"), + check("deploy", "skipped"), + check("lint", "failure"), + check("test", "failure"), + ], + i18n.t, + ); + + expect(summary.headline).toBe("Certaines vérifications ont échoué"); + expect(summary.detail).toBe("Vérifications : 2 en échec, 1 réussie(s), 1 ignorée(s)"); + expect(summary.groups.map((group) => group.label)).toEqual([ + "2 vérifications en échec", + "1 vérification réussie", + "1 vérification ignorée", + ]); + expect(summarizeChecks([], i18n.t).headline).toBe("Aucune vérification"); + } finally { + await i18n.changeLanguage("en"); + } + }); }); diff --git a/packages/app/src/git/pull-request-panel/checks-summary.ts b/packages/app/src/git/pull-request-panel/checks-summary.ts index f2f9b30a38..977706a9af 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.ts @@ -1,4 +1,5 @@ import { classifyCheck, type CheckPresentation } from "@/git/check-presentation"; +import type { TFunction } from "i18next"; import type { PrPaneCheck } from "./data"; /** @@ -16,27 +17,10 @@ const PRESENTATION_ORDER = [ "ignored", ] as const satisfies readonly CheckPresentation[]; -/** How each status reads inside a count phrase: "2 failing", "4 in progress". */ -const PRESENTATION_NOUN: Record = { - actionRequired: "needs action", - warning: "warning", - failure: "failing", - pending: "in progress", - manual: "manual", - success: "successful", - ignored: "skipped", -}; - /** The worst thing happening in the run, which is what the headline and the ring report. */ export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none"; -const OUTCOME_HEADLINE: Record = { - actionRequired: "Some checks need your attention", - failure: "Some checks were not successful", - pending: "Some checks haven't completed yet", - success: "All checks have passed", - none: "No checks", -}; +const COPY = "workspace.git.pr.checksOverview"; export interface ChecksCountPart { status: CheckPresentation; @@ -58,8 +42,12 @@ export interface ChecksSummary { headline: string; /** The count phrases behind `detail`, kept apart so the header can label each one. */ parts: readonly ChecksCountPart[]; - /** The noun closing the detail line — "checks", or "check" for a run of one. */ - countNoun: string; + /** + * The words around the count phrases in `detail`: "" and " checks" in English, where the noun + * closes the line, while other languages lead with it. + */ + detailLead: string; + detailTrail: string; /** The whole line: "2 failing, 4 in progress, 1 successful checks". Empty with no checks. */ detail: string; total: number; @@ -72,7 +60,7 @@ export interface ChecksSummary { * header, the ring, and the grouped list all read from one derivation instead of each * filtering the array again with its own idea of what counts. */ -export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { +export function summarizeChecks(checks: readonly PrPaneCheck[], t: TFunction): ChecksSummary { const groups: ChecksGroup[] = []; const parts: ChecksCountPart[] = []; @@ -83,24 +71,27 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { } groups.push({ status, - label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`, + label: t(`${COPY}.${matching.length === 1 ? "groupOne" : "groupMany"}.${status}`, { + count: matching.length, + }), checks: matching, }); parts.push({ status, count: matching.length, - text: `${matching.length} ${PRESENTATION_NOUN[status]}`, + text: t(`${COPY}.count.${status}`, { count: matching.length }), }); } const outcome = selectOutcome(checks); - const noun = countNoun(checks.length); + const { lead, trail } = detailFrame(checks.length, t); return { outcome, - headline: OUTCOME_HEADLINE[outcome], + headline: t(`${COPY}.headline.${outcome}`), parts, - countNoun: noun, - detail: parts.length === 0 ? "" : `${parts.map((part) => part.text).join(", ")} ${noun}`, + detailLead: lead, + detailTrail: trail, + detail: parts.length === 0 ? "" : `${lead}${parts.map((part) => part.text).join(", ")}${trail}`, total: checks.length, groups, }; @@ -131,6 +122,13 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome { return "success"; } -function countNoun(count: number): string { - return count === 1 ? "check" : "checks"; +/** + * The header renders each count phrase on its own, so the translated line is split around + * where the phrases go. + */ +function detailFrame(total: number, t: TFunction): { lead: string; trail: string } { + const marker = "\u0000"; + const line = t(`${COPY}.${total === 1 ? "detailOne" : "detailMany"}`, { parts: marker }); + const [lead = "", trail = ""] = line.split(marker); + return { lead, trail }; } diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index 21b145bedc..837433417e 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -9,12 +9,13 @@ import { isPipelineActiveStatus, mapPipelineStatus, } from "@/git/forges/gitlab"; +import { i18n } from "@/i18n/i18next"; import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors"; import { deriveAvatarColor, formatAge, - getActivityVerb, - getStateLabel, + getActivityVerbKey, + getStateLabelKey, mapPrPaneData, } from "./data"; @@ -779,37 +780,34 @@ describe("formatAge", () => { }); }); -describe("getStateLabel", () => { +const english = i18n.getFixedT("en"); +const french = i18n.getFixedT("fr"); + +describe("getStateLabelKey", () => { it.each([ - ["open", "Open"], - ["draft", "Draft"], - ["merged", "Merged"], - ["closed", "Closed"], - ] as const)("maps %s → %s", (state, expected) => { - expect(getStateLabel(state)).toBe(expected); + ["open", "Open", "Ouverte"], + ["draft", "Draft", "Brouillon"], + ["merged", "Merged", "Fusionnée"], + ["closed", "Closed", "Fermée"], + ] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => { + expect(english(getStateLabelKey(state))).toBe(englishLabel); + expect(french(getStateLabelKey(state))).toBe(frenchLabel); }); }); -describe("getActivityVerb", () => { - it("returns Commented for comment kind", () => { - expect(getActivityVerb({ kind: "comment" })).toBe("Commented"); - }); - - it("returns Approved for approved review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved"); - }); - - it("returns Requested changes for changes_requested review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe( +describe("getActivityVerbKey", () => { + it.each([ + [{ kind: "comment" }, "Commented", "A commenté"], + [{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"], + [ + { kind: "review", reviewState: "changes_requested" }, "Requested changes", - ); - }); - - it("returns Reviewed for a commented review with body (generic case)", () => { - expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed"); - }); - - it("returns Reviewed when reviewState is undefined", () => { - expect(getActivityVerb({ kind: "review" })).toBe("Reviewed"); + "A demandé des modifications", + ], + [{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"], + [{ kind: "review" }, "Reviewed", "A relu"], + ] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => { + expect(english(getActivityVerbKey(item))).toBe(englishVerb); + expect(french(getActivityVerbKey(item))).toBe(frenchVerb); }); }); diff --git a/packages/app/src/git/pull-request-panel/data.ts b/packages/app/src/git/pull-request-panel/data.ts index 0f1e2014e9..d1275f2f1e 100644 --- a/packages/app/src/git/pull-request-panel/data.ts +++ b/packages/app/src/git/pull-request-panel/data.ts @@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null { } } -export function getStateLabel(state: PrState): string { - if (state === "draft") return "Draft"; - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return "Open"; +export function getStateLabelKey(state: PrState) { + if (state === "draft") return "workspace.git.pr.states.draft"; + if (state === "merged") return "workspace.git.pr.states.merged"; + if (state === "closed") return "workspace.git.pr.states.closed"; + return "workspace.git.pr.states.open"; } -export function getActivityVerb(item: Pick): string { - if (item.kind === "comment") return "Commented"; - if (item.reviewState === "approved") return "Approved"; - if (item.reviewState === "changes_requested") return "Requested changes"; - return "Reviewed"; +export function getActivityVerbKey(item: Pick) { + if (item.kind === "comment") return "workspace.git.pr.activity.commented"; + if (item.reviewState === "approved") return "workspace.git.pr.activity.approved"; + if (item.reviewState === "changes_requested") { + return "workspace.git.pr.activity.requestedChanges"; + } + return "workspace.git.pr.activity.reviewed"; } diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index b7c0890c3a..429dd2be0e 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -63,7 +63,7 @@ import { canAddPullRequestActivityToChat, } from "./context-attachment"; import { ChecksSection, getCheckIdentity } from "./checks-section"; -import { getActivityVerb, getStateLabel } from "./data"; +import { getActivityVerbKey, getStateLabelKey } from "./data"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { CheckPresentationIcon } from "@/git/check-presentation.view"; @@ -538,7 +538,7 @@ export function PullRequestPane({ - {getStateLabel(data.state)} + {t(getStateLabelKey(data.state))} {nativeHeaderMeta} {repoIdentity ? ( @@ -568,7 +568,7 @@ export function PullRequestPane({
- Add all to chat + {t("workspace.git.pr.actions.addAllToChat")} ) : null} {activityLoading ? : null} {!activityLoading && visibleEntries.length === 0 ? ( - No activity yet + {t("workspace.git.pr.empty.noActivity")} ) : null} {!activityLoading ? visibleEntries.map(({ entry, collapsed }) => ( @@ -702,14 +702,14 @@ function ActivityKebab({ {renderKebabTriggerIcon} {attachEnabled && canAddPullRequestActivityToChat(activity) ? ( - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {activity.body.trim() !== "" ? ( @@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu } function ActivityVerb({ activity }: { activity: PrPaneActivity }) { - const verb = getActivityVerb(activity).toLowerCase(); + const { t } = useTranslation(); + const verb = t(getActivityVerbKey(activity)).toLowerCase(); if (activity.kind === "review" && activity.reviewState === "approved") { return ( @@ -815,6 +816,7 @@ function SingleActivityCard({ entry: Extract; collapsed: boolean; }) { + const { t } = useTranslation(); const { activity } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -884,7 +886,7 @@ function SingleActivityCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -935,6 +937,7 @@ function ReviewCard({ collapsed: boolean; collapsedEntryIds: ReadonlySet; }) { + const { t } = useTranslation(); const { review, threads } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -961,7 +964,7 @@ function ReviewCard({ onPress={handleAddToChat} style={styles.checkAddButton} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {collapsed ? ( @@ -995,7 +998,7 @@ function ReviewCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -1063,8 +1066,12 @@ function ThreadBlock({ ? formatPullRequestThreadPath(thread.location) : t("workspace.git.pr.thread.discussion")} - {thread.isResolved ? : null} - {thread.location?.isOutdated ? : null} + {thread.isResolved ? ( + + ) : null} + {thread.location?.isOutdated ? ( + + ) : null} {collapsed ? ( @@ -1080,7 +1087,7 @@ function ThreadBlock({ {renderKebabTriggerIcon} @@ -1124,7 +1131,7 @@ function ThreadBlock({ leftIcon={MessageSquarePlus} onPress={handleAddThreadToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index 341cc4946d..daeda1479d 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -7,6 +7,7 @@ import { buildReviewDraftKey, useInlineReviewController, useReviewAttachmentSnapshot, + useReviewFeedback, } from "@/review"; import { useCheckoutDiffQuery } from "@/git/use-diff-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; @@ -19,6 +20,7 @@ interface UseWorkingDiffOptions { ignoreWhitespace: boolean; enabled: boolean; queryScope?: string; + presentation?: string; } export function useWorkingDiff({ @@ -28,6 +30,7 @@ export function useWorkingDiff({ ignoreWhitespace, enabled, queryScope, + presentation, }: UseWorkingDiffOptions) { const { status, @@ -81,7 +84,7 @@ export function useWorkingDiff({ }), [baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId], ); - const reviewActions = useInlineReviewController({ reviewDraftKey }); + const reviewActions = useInlineReviewController({ reviewDraftKey, presentation }); const reviewAttachment = useReviewAttachmentSnapshot({ key: reviewDraftKey, diffFiles: files, @@ -90,6 +93,13 @@ export function useWorkingDiff({ baseRef, }); + const reviewFeedback = useReviewFeedback({ + serverId, + workspaceId, + reviewDraftKey, + attachment: reviewAttachment, + }); + return { status, isStatusLoading, @@ -107,6 +117,7 @@ export function useWorkingDiff({ isDiffLoading, reviewActions, reviewAttachment, + reviewFeedback, }; } diff --git a/packages/app/src/hooks/sidebar-status-view-model.test.ts b/packages/app/src/hooks/sidebar-status-view-model.test.ts index 62dc7142a8..c5bd8dcd11 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model"; import { buildStatusGroups, buildStatusShortcutIndex, - STATUS_BUCKET_LABELS, + getStatusBucketLabel, STATUS_BUCKET_ORDER, type StatusGroup, } from "./sidebar-status-view-model"; @@ -55,7 +56,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]); expect(groups[0]?.label).toBe("Needs input"); @@ -69,7 +70,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:b", statusBucket: "running" }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]); }); @@ -93,7 +94,7 @@ describe("buildStatusGroups", () => { }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]); }); @@ -109,7 +110,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([ "srv:ts", @@ -145,13 +146,13 @@ describe("buildStatusGroups", () => { }), ]; - const groups = buildStatusGroups(workspaces, projectNames); + const groups = buildStatusGroups(workspaces, projectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]); }); it("returns empty array for no workspaces", () => { - const groups = buildStatusGroups([], emptyProjectNames); + const groups = buildStatusGroups([], emptyProjectNames, i18n.t); expect(groups).toEqual([]); }); @@ -180,11 +181,11 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER); expect(groups.map((g) => g.label)).toEqual( - STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]), + STATUS_BUCKET_ORDER.map((b) => getStatusBucketLabel(b, i18n.t)), ); // Each group has exactly one row with the matching bucket for (const group of groups) { @@ -230,3 +231,20 @@ describe("buildStatusShortcutIndex", () => { expect(index.size).toBe(0); }); }); + +describe("getStatusBucketLabel", () => { + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + expect(STATUS_BUCKET_ORDER.map((bucket) => getStatusBucketLabel(bucket, i18n.t))).toEqual([ + "Attend une réponse", + "Échec", + "À relire", + "En cours", + "Terminé", + ]); + } finally { + await i18n.changeLanguage("en"); + } + }); +}); diff --git a/packages/app/src/hooks/sidebar-status-view-model.ts b/packages/app/src/hooks/sidebar-status-view-model.ts index 7bcbe3d345..5736c7f391 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model"; @@ -5,13 +6,17 @@ export type StatusBucket = SidebarWorkspaceEntry["statusBucket"]; export { STATUS_BUCKET_ORDER }; -export const STATUS_BUCKET_LABELS: Record = { - needs_input: "Needs input", - failed: "Failed", - attention: "Ready to review", - running: "Working", - done: "Done", -}; +const STATUS_BUCKET_LABEL_KEYS = { + needs_input: "sidebar.statusBucket.needsInput", + failed: "sidebar.statusBucket.failed", + attention: "sidebar.statusBucket.readyToReview", + running: "sidebar.statusBucket.working", + done: "sidebar.statusBucket.done", +} as const satisfies Record; + +export function getStatusBucketLabel(bucket: StatusBucket, t: TFunction): string { + return t(STATUS_BUCKET_LABEL_KEYS[bucket]); +} export interface StatusGroup { bucket: StatusBucket; @@ -22,6 +27,7 @@ export interface StatusGroup { export function buildStatusGroups( workspaces: SidebarWorkspaceEntry[], projectNamesByViewKey: Map, + t: TFunction, ): StatusGroup[] { const bucketRows = new Map(); @@ -42,7 +48,7 @@ export function buildStatusGroups( if (!rows || rows.length === 0) continue; rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey)); - groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows }); + groups.push({ bucket, label: getStatusBucketLabel(bucket, t), rows }); } return groups; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 842844cdc2..24ecc34461 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -27,6 +27,7 @@ export interface SidebarWorkspacePlacement { workspaceDirectory?: string; projectKind: WorkspaceStructureProject["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; + background?: boolean; name: string; } @@ -164,6 +165,7 @@ export function createSidebarWorkspaceEntry(input: { input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory), projectKind: input.workspace.projectKind, workspaceKind: input.workspace.workspaceKind, + background: input.workspace.background, name: input.workspace.name, title: input.workspace.title ?? null, pinnedAt: input.workspace.pinnedAt, diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index 63e360aba0..201c17c022 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -44,6 +44,7 @@ describe("ACP provider catalog", () => { expect(findProvider("codewhale").command).toEqual(["codewhale", "serve", "--acp"]); expect(findProvider("devin").command).toEqual(["devin", "acp"]); expect(findProvider("goose").command).toEqual(["goose", "acp"]); + expect(findProvider("hoplite").command).toEqual(["hoplite", "acp"]); expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]); expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]); expect(findProvider("poolside").command).toEqual(["pool", "acp"]); @@ -59,6 +60,32 @@ describe("ACP provider catalog", () => { expect(findProvider("minimax-code").iconSvg).toContain(" { + expect(findProvider("hoplite")).toMatchObject({ + title: "Hoplite", + command: ["hoplite", "acp"], + installLink: "https://hoplite.sh/docs/cli#install", + }); + expect(buildAcpProviderConfigPatch(findProvider("hoplite"), true)).toEqual({ + providers: { + hoplite: { enabled: true }, + }, + }); + const ids = getAcpProviderCatalog().map((entry) => entry.id); + expect(ids.indexOf("hermes")).toBeLessThan(ids.indexOf("hoplite")); + expect(ids.indexOf("hoplite")).toBeLessThan(ids.indexOf("junie")); + expect(findProvider("hoplite").iconSvg).toContain('fill="currentColor"'); + }); + + it.each([undefined, false])( + "rejects unsupported bundled Hoplite before producing a patch (%s)", + (supported) => { + expect(() => buildAcpProviderConfigPatch(findProvider("hoplite"), supported)).toThrow( + "Update the host to add Hoplite.", + ); + }, + ); + it("maps a catalog entry to the daemon provider config patch", () => { expect(buildAcpProviderConfigPatch(findProvider("amp-acp"))).toEqual({ providers: { diff --git a/packages/app/src/hooks/use-acp-provider-catalog.ts b/packages/app/src/hooks/use-acp-provider-catalog.ts index 92f9ca07e2..f20c31caf2 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.ts @@ -10,7 +10,13 @@ export function getAcpProviderCatalog(): AcpProviderCatalogItem[] { export function buildAcpProviderConfigPatch( entry: AcpProviderCatalogItem, + supportsHopliteProvider = false, ): MutableDaemonConfigPatch { + if (entry.bundledProvider) { + // COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter. + if (!supportsHopliteProvider) throw new Error("Update the host to add Hoplite."); + return { providers: { [entry.id]: { enabled: true } } }; + } return { providers: { [entry.id]: { diff --git a/packages/app/src/hooks/use-file-download.ts b/packages/app/src/hooks/use-file-download.ts index 91a88170b8..683b7a2381 100644 --- a/packages/app/src/hooks/use-file-download.ts +++ b/packages/app/src/hooks/use-file-download.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useHosts } from "@/runtime/host-runtime"; +import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { useDownloadStore } from "@/stores/download-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; @@ -12,8 +12,9 @@ interface UseFileDownloadParams { /** * Returns a stable callback that downloads a single workspace file by its * workspace-relative path. Shared by the file explorer tree and the git diff - * pane so both surfaces download through the same host token + download-store - * pipeline instead of duplicating the plumbing. + * pane so both surfaces download through the same download-store pipeline instead + * of duplicating the plumbing: the HTTP token download over a direct TCP connection, + * the session socket over anything else. */ export function useFileDownload({ serverId, @@ -30,7 +31,7 @@ export function useFileDownload({ () => workspaceId?.trim() || normalizedWorkspaceRoot, [normalizedWorkspaceRoot, workspaceId], ); - const { requestFileDownloadToken } = useFileExplorerActions({ + const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({ serverId, workspaceId, workspaceRoot: normalizedWorkspaceRoot, @@ -48,9 +49,18 @@ export function useFileDownload({ fileName, path, daemonProfile, + activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null, requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath), + readFile: requestFileBytes, }); }, - [daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId], + [ + daemonProfile, + requestFileBytes, + requestFileDownloadToken, + serverId, + startDownload, + workspaceScopeId, + ], ); } diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 53714e37b3..c29feb5241 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -253,6 +253,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor [client, normalizedWorkspaceRoot, t], ); + const requestFileBytes = useCallback( + async (path: string, maxBytes: number) => { + if (!normalizedWorkspaceRoot) { + throw new Error(t("workspace.fileExplorer.states.unavailable")); + } + if (!client) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + // No request timeout: a large file over a slow relay legitimately takes longer than the + // 60 s default, and a dead connection still rejects on disconnect. + return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0); + }, + [client, normalizedWorkspaceRoot, t], + ); + const createEntry = useCallback( async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => { if (!client || !normalizedWorkspaceRoot) { @@ -342,6 +357,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor requestDirectoryListing, requestFilePreview, requestFileDownloadToken, + requestFileBytes, createEntry, renameEntry, duplicateEntry, diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index d723b5be04..6f332b0a98 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -40,7 +40,7 @@ import { navigateToLastWorkspace, useActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; -import { dispatchTopWebOverlayKeyDown } from "@/lib/overlay-root"; +import { dispatchTopWebOverlayKeyDown, keyBelongsToDomPopover } from "@/lib/overlay-root"; export function useKeyboardShortcuts({ enabled, @@ -321,6 +321,10 @@ export function useKeyboardShortcuts({ return; } + if (keyBelongsToDomPopover(event)) { + return; + } + // During IME composition, Enter confirms the candidate selection and must // not route through global shortcuts like message send. if (isImeComposingKeyboardEvent(event)) { diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index d396f26d9a..6d9c8d4c7c 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -15,6 +15,7 @@ import { parseClampedFontSize, parseTerminalScrollbackLines, resolveContentMaxWidth, + sanitizeFontFamily, saveAppSettings, type SettingsDeps, } from "./storage"; @@ -1094,3 +1095,34 @@ describe("content max width", () => { expect((await load("wide")).contentMaxWidth).toBeNull(); }); }); + +describe("font family CSS safety", () => { + it.each(["'foo", '"foo', "Menlo, 'foo", "foo\\", "foo/*", "foo(", "foo["])( + "rejects unfinished CSS syntax: %s", + (value) => expect(sanitizeFontFamily(value)).toBeNull(), + ); + + it.each([ + "Menlo", + "'Fira Code', monospace", + '"Fira Code", monospace', + "'Font\\'s name'", + "Font\\ Name", + "'Font (Mono)'", + "'Font [Mono]'", + ])("preserves complete font families: %s", (value) => + expect(sanitizeFontFamily(value)).toBe(value), + ); + + it.each(["uiFontFamily", "monoFontFamily"])( + "recovers a persisted unmatched quote in %s", + async (field) => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ [field]: "'foo" }), + }), + }); + expect(await loadAppSettingsFromStorage(deps)).toMatchObject({ [field]: "" }); + }, + ); +}); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 6c44546668..54ee6b10bc 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -522,7 +522,24 @@ export function sanitizeFontFamily(value: unknown): string | null { if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) { return null; // control chars would corrupt the font-family string } - return trimmed; // quotes/commas are legit in stacks + // Unistyles serializes theme values into one shared stylesheet. An unfinished + // string, escape, comment, or block consumes the declarations that follow. + let quote: string | null = null; + let escaped = false; + for (const char of trimmed) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (quote !== null) { + if (char === quote) quote = null; + } else if (char === "'" || char === '"') { + quote = char; + } else if (/[()[\]/*]/.test(char)) { + return null; + } + } + return quote === null && !escaped ? trimmed : null; } async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{ diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 2b83055774..bf42a3b61a 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -74,6 +74,45 @@ const untranslatedLocalFallbacks = [ "Unable to save desktop settings.", ] as const; +const pullRequestPanelSources = [ + "git/pull-request-panel/pane.tsx", + "git/pull-request-panel/data.ts", + "git/pull-request-panel/checks-section.tsx", + "components/sidebar/sidebar-status-list.tsx", +] as const; +const untranslatedPullRequestPanelLabels = [ + "Activity", + "No activity yet", + "Add all to chat", + "Add to chat", + "Adding...", + "Comment actions", + "Thread actions", + "Commented", + "Approved", + "Requested changes", + "Reviewed", + "Draft", + "Merged", + "Closed", + "Open", + "Resolved", + "Outdated", +] as const; + +function findUntranslatedPullRequestPanelLabels(): string[] { + return pullRequestPanelSources.flatMap((source) => { + const contents = readFileSync(join(appSourceRoot, source), "utf8"); + const matches: string[] = untranslatedPullRequestPanelLabels.filter( + (text) => contents.includes(`"${text}"`) || new RegExp(`>\\s*${text}\\s*<`).test(contents), + ); + if (contents.includes("} group`")) { + matches.push("group"); + } + return matches.length === 0 ? [] : [`${source}: ${matches.join(", ")}`]; + }); +} + function collectSourceFiles(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); @@ -204,6 +243,32 @@ describe("translation resources", () => { expect(en.workspace.fileActions.addToChat).toBe("Add to chat"); }); + it("keeps pull request panel and sidebar status group labels translated", () => { + expect(findUntranslatedPullRequestPanelLabels()).toEqual([]); + for (const resource of [ar, es, fr, ja, ko, ptBR, ru, zhCN]) { + const pr = resource.workspace.git.pr; + const englishPr = en.workspace.git.pr; + expect(pr.sections.activity).not.toBe(englishPr.sections.activity); + expect(pr.empty.noActivity).not.toBe(englishPr.empty.noActivity); + expect(pr.actions.addToChat).not.toBe(englishPr.actions.addToChat); + expect(pr.actions.addAllToChat).not.toBe(englishPr.actions.addAllToChat); + expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat); + expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions); + expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions); + for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) { + expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]); + } + for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) { + expect(pr.states[state]).not.toBe(englishPr.states[state]); + } + expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved); + expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated); + expect(resource.sidebar.statusGroupAccessibility).not.toBe( + en.sidebar.statusGroupAccessibility, + ); + } + }); + it("keeps local connection fallback errors translated", () => { expect(findUntranslatedConnectionErrors()).toEqual([]); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f001a177ef..5e09ac5957 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -30,6 +30,7 @@ export const ar: TranslationResources = { back: "خلف", loading: "تحميل...", actions: { + save: "حفظ", back: "خلف", cancel: "يلغي", close: "يغلق", @@ -123,6 +124,8 @@ export const ar: TranslationResources = { input: { accessibilityLabel: "وكيل الرسائل...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "ملء الشاشة", + exitFullscreen: "الخروج من ملء الشاشة", focusHint: "{{shortcut}}للتركيز", addAttachment: "إضافة مرفق", interruptAgent: "عامل المقاطعة", @@ -382,6 +385,9 @@ export const ar: TranslationResources = { completed: "اكتملت", }, }, + turnFooter: { + workedFor: "عمل لمدة {{duration}}", + }, compaction: { loading: "الضغط...", auto: "يتم ضغط السياق تلقائيًا", @@ -607,6 +613,9 @@ export const ar: TranslationResources = { updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", }, + sheet: { + openAsTab: "فتح كعلامة تبويب", + }, tabs: { loading: "تحميل...", modified: "تغييرات غير محفوظة", @@ -622,7 +631,7 @@ export const ar: TranslationResources = { }, switcher: { trigger: "تبديل علامات التبويب (فتح{{count}})", - title: "علامة التبويب التبديل", + title: "علامات تبويب الـ Workspace", searchPlaceholder: "علامات تبويب البحث", }, menu: { @@ -720,6 +729,7 @@ export const ar: TranslationResources = { header: { actions: { workspaceActions: "إجراءات Workspace", + workspaceActionsUnhealthy: "إجراءات Workspace، توجد خدمة غير سليمة", newAgent: "وكيل جديد", newTerminal: "محطة جديدة", newBrowser: "علامة تبويب متصفح جديدة", @@ -767,6 +777,8 @@ export const ar: TranslationResources = { exitCode: "الخروج من{{code}}", startFailed: "فشل بدء تشغيل{{scriptName}}", stopFailed: "فشل إيقاف{{scriptName}}", + running: "قيد التشغيل", + runningCount: "{{count}} قيد التشغيل", }, }, tree: { @@ -970,6 +982,47 @@ export const ar: TranslationResources = { actions: { viewPullRequest: "عرض", openOn: "فتح على {{brand}}", + addToChat: "إضافة إلى الدردشة", + addAllToChat: "إضافة الكل إلى الدردشة", + addingToChat: "جارٍ الإضافة...", + }, + checksOverview: { + headline: { + actionRequired: "بعض الفحوصات تحتاج إلى انتباهك", + failure: "بعض الفحوصات لم تنجح", + pending: "بعض الفحوصات لم تكتمل بعد", + success: "نجحت جميع الفحوصات", + none: "لا توجد فحوصات", + }, + count: { + actionRequired: "{{count}} تحتاج إجراءً", + warning: "{{count}} مع تحذير", + failure: "{{count}} فاشلة", + pending: "{{count}} قيد التشغيل", + manual: "{{count}} يدوية", + success: "{{count}} ناجحة", + ignored: "{{count}} متخطاة", + }, + detailOne: "الفحص: {{parts}}", + detailMany: "الفحوصات: {{parts}}", + groupOne: { + actionRequired: "{{count}} فحص يحتاج إجراءً", + warning: "{{count}} فحص مع تحذير", + failure: "{{count}} فحص فاشل", + pending: "{{count}} فحص قيد التشغيل", + manual: "{{count}} فحص يدوي", + success: "{{count}} فحص ناجح", + ignored: "{{count}} فحص متخطى", + }, + groupMany: { + actionRequired: "{{count}} فحوصات تحتاج إجراءً", + warning: "{{count}} فحوصات مع تحذير", + failure: "{{count}} فحوصات فاشلة", + pending: "{{count}} فحوصات قيد التشغيل", + manual: "{{count}} فحوصات يدوية", + success: "{{count}} فحوصات ناجحة", + ignored: "{{count}} فحوصات متخطاة", + }, }, checksSummary: { passedLabel: "نجح", @@ -983,17 +1036,21 @@ export const ar: TranslationResources = { checks: "الشيكات", pipeline: "خط المعالجة", reviews: "التعليقات", + activity: "النشاط", }, empty: { noJobs: "لا توجد مهام", loadingPipeline: "جارٍ تحميل خط المعالجة...", pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة", allowedToFail: "مسموح بالفشل", + noActivity: "لا يوجد نشاط بعد", }, approvals: "{{given}} من {{required}} موافقات", accessibility: { pullRequest: "سحب الطلب #{{number}}", pullRequest_mr: "طلب دمج !{{number}}", + commentActions: "إجراءات التعليق", + threadActions: "إجراءات سلسلة النقاش", checkStatus: { passed: "ناجح", failed: "فاشل", @@ -1022,6 +1079,8 @@ export const ar: TranslationResources = { }, thread: { discussion: "سلسلة المناقشة", + resolved: "تم الحل", + outdated: "قديم", }, errors: { statusLoadFailed: "غير قادر على تحميل حالة طلب السحب", @@ -1088,7 +1147,16 @@ export const ar: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "مجموعة {{label}}", + statusBucket: { + needsInput: "تحتاج إدخالاً", + failed: "فشل", + readyToReview: "جاهزة للمراجعة", + working: "قيد العمل", + done: "تم", + }, display: { + showBackground: "إظهار مساحات العمل في الخلفية", trigger: "تفضيلات العرض", heading: "العرض", grouping: { @@ -1592,14 +1660,19 @@ export const ar: TranslationResources = { title: "إضافة مزود", search: "مقدمي البحث", noProviders: "لم يتم العثور على مقدمي الخدمات", + clearSearch: "مسح البحث", actions: { add: "يضيف", + addProvider: "إضافة {{provider}}", adding: "إضافة", installed: "تم التثبيت", cancel: "يلغي", installInstructions: "تعليمات التثبيت", installInstructionsFor: "تعليمات تثبيت{{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "غير قادر على تثبيت الموفر", }, @@ -1946,6 +2019,17 @@ export const ar: TranslationResources = { accessibility: "تم استخدام نافذة السياق{{percentage}}%", }, review: { + feedback: { + send: "إرسال الملاحظات ({{count}})", + sending: "جارٍ إرسال الملاحظات ({{count}})", + chooseAgent: "اختر وكيلاً", + sent: "تم إرسال الملاحظات إلى {{recipient}}", + "no-agents": "افتح علامة تبويب وكيل في مساحة العمل هذه لإرسال الملاحظات.", + disconnected: "اتصل بالمضيف لإرسال الملاحظات.", + "no-context": "لم تعد التعليقات المحفوظة تتطابق مع هذا الفرق.", + failed: "تعذر إرسال الملاحظات. حاول مرة أخرى.", + prompt: "يرجى معالجة مراجعة الكود هذه.", + }, comment: { add: "إضافة تعليق المراجعة", edit: "تحرير تعليق المراجعة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1bf5598dfd..8c6a870580 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -26,6 +26,7 @@ export const en = { back: "Back", loading: "Loading...", actions: { + save: "Save", back: "Back", cancel: "Cancel", close: "Close", @@ -119,6 +120,8 @@ export const en = { input: { accessibilityLabel: "Message agent...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", focusHint: "{{shortcut}} to focus", addAttachment: "Add attachment", interruptAgent: "Interrupt agent", @@ -379,6 +382,9 @@ export const en = { completed: "Completed", }, }, + turnFooter: { + workedFor: "Worked for {{duration}}", + }, compaction: { loading: "Compacting...", auto: "Context automatically compacted", @@ -604,6 +610,9 @@ export const en = { updateHost: "Update the host to use the native terminal renderer.", unableToSubscribe: "Unable to subscribe to terminal", }, + sheet: { + openAsTab: "Open as tab", + }, tabs: { loading: "Loading...", modified: "Unsaved changes", @@ -619,7 +628,7 @@ export const en = { }, switcher: { trigger: "Switch tabs ({{count}} open)", - title: "Switch tab", + title: "Workspace tabs", searchPlaceholder: "Search tabs", }, menu: { @@ -715,6 +724,7 @@ export const en = { header: { actions: { workspaceActions: "Workspace actions", + workspaceActionsUnhealthy: "Workspace actions, a service is unhealthy", newAgent: "New agent", newTerminal: "New terminal", newBrowser: "New browser tab", @@ -762,6 +772,8 @@ export const en = { exitCode: "exit {{code}}", startFailed: "Failed to start {{scriptName}}", stopFailed: "Failed to stop {{scriptName}}", + running: "Running", + runningCount: "{{count}} running", }, }, tree: { @@ -978,6 +990,47 @@ export const en = { actions: { viewPullRequest: "View", openOn: "Open on {{brand}}", + addToChat: "Add to chat", + addAllToChat: "Add all to chat", + addingToChat: "Adding...", + }, + checksOverview: { + headline: { + actionRequired: "Some checks need your attention", + failure: "Some checks were not successful", + pending: "Some checks haven't completed yet", + success: "All checks have passed", + none: "No checks", + }, + count: { + actionRequired: "{{count}} needs action", + warning: "{{count}} warning", + failure: "{{count}} failing", + pending: "{{count}} in progress", + manual: "{{count}} manual", + success: "{{count}} successful", + ignored: "{{count}} skipped", + }, + detailOne: "{{parts}} check", + detailMany: "{{parts}} checks", + groupOne: { + actionRequired: "{{count}} needs action check", + warning: "{{count}} warning check", + failure: "{{count}} failing check", + pending: "{{count}} in progress check", + manual: "{{count}} manual check", + success: "{{count}} successful check", + ignored: "{{count}} skipped check", + }, + groupMany: { + actionRequired: "{{count}} needs action checks", + warning: "{{count}} warning checks", + failure: "{{count}} failing checks", + pending: "{{count}} in progress checks", + manual: "{{count}} manual checks", + success: "{{count}} successful checks", + ignored: "{{count}} skipped checks", + }, }, checksSummary: { passedLabel: "passed", @@ -991,17 +1044,21 @@ export const en = { checks: "Checks", pipeline: "Pipeline", reviews: "Reviews", + activity: "Activity", }, empty: { noJobs: "No jobs", loadingPipeline: "Loading pipeline…", pipelineJobsLoadFailed: "Could not load pipeline jobs", allowedToFail: "allowed to fail", + noActivity: "No activity yet", }, approvals: "{{given}} of {{required}} approvals", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Comment actions", + threadActions: "Thread actions", checkStatus: { passed: "Passed", failed: "Failed", @@ -1030,6 +1087,8 @@ export const en = { }, thread: { discussion: "Discussion thread", + resolved: "Resolved", + outdated: "Outdated", }, errors: { statusLoadFailed: "Unable to load pull request status", @@ -1096,7 +1155,16 @@ export const en = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} group", + statusBucket: { + needsInput: "Needs input", + failed: "Failed", + readyToReview: "Ready to review", + working: "Working", + done: "Done", + }, display: { + showBackground: "Show background", trigger: "Display preferences", heading: "Display", grouping: { @@ -1617,14 +1685,19 @@ export const en = { title: "Add provider", search: "Search providers", noProviders: "No providers found", + clearSearch: "Clear search", actions: { add: "Add", + addProvider: "Add {{provider}}", adding: "Adding", installed: "Installed", cancel: "Cancel", installInstructions: "Install instructions", installInstructionsFor: "{{provider}} install instructions", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Unable to install provider", }, @@ -1971,6 +2044,17 @@ export const en = { accessibility: "Context window {{percentage}}% used", }, review: { + feedback: { + send: "Send feedback ({{count}})", + sending: "Sending feedback ({{count}})", + chooseAgent: "Choose an agent", + sent: "Feedback sent to {{recipient}}", + "no-agents": "Open an agent tab in this workspace to send feedback.", + disconnected: "Connect to the host to send feedback.", + "no-context": "Saved comments no longer match this diff.", + failed: "Failed to send feedback. Try again.", + prompt: "Please address this code review.", + }, comment: { add: "Add review comment", edit: "Edit review comment", @@ -2104,7 +2188,7 @@ export const en = { globalTitle: "Enable plugins", globalHint: "Global switch for every configured plugin", sourceLabel: "Plugin source", - sourcePlaceholder: "Directory, Git URL, or npm package", + sourcePlaceholder: "owner/slug, directory, Git URL, or npm package", docs: "Docs", install: "Install plugin", installing: "Installing…", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index f3e0fc9e17..62667ba9f9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -31,6 +31,7 @@ export const es: TranslationResources = { back: "Atrás", loading: "Cargando...", actions: { + save: "Guardar", back: "Atrás", cancel: "Cancelar", close: "Cerrar", @@ -124,6 +125,8 @@ export const es: TranslationResources = { input: { accessibilityLabel: "Agente de mensajes...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Pantalla completa", + exitFullscreen: "Salir de pantalla completa", focusHint: "{{shortcut}}para enfocar", addAttachment: "Agregar archivo adjunto", interruptAgent: "agente de interrupción", @@ -387,6 +390,9 @@ export const es: TranslationResources = { completed: "Completada", }, }, + turnFooter: { + workedFor: "Trabajó durante {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automáticamente", @@ -613,6 +619,9 @@ export const es: TranslationResources = { updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", unableToSubscribe: "No se puede suscribir al terminal", }, + sheet: { + openAsTab: "Abrir como pestaña", + }, tabs: { loading: "Cargando...", modified: "Cambios sin guardar", @@ -628,7 +637,7 @@ export const es: TranslationResources = { }, switcher: { trigger: "Cambiar pestañas ({{count}}abierto)", - title: "Cambiar pestaña", + title: "Pestañas del workspace", searchPlaceholder: "Pestañas de búsqueda", }, menu: { @@ -727,6 +736,7 @@ export const es: TranslationResources = { header: { actions: { workspaceActions: "AccionesWorkspace", + workspaceActionsUnhealthy: "Acciones del workspace, hay un servicio con problemas", newAgent: "Nuevo agente", newTerminal: "Nueva terminal", newBrowser: "Nueva pestaña del navegador", @@ -775,6 +785,8 @@ export const es: TranslationResources = { exitCode: "salir de{{code}}", startFailed: "No se pudo iniciar{{scriptName}}", stopFailed: "No se pudo detener{{scriptName}}", + running: "En ejecución", + runningCount: "{{count}} en ejecución", }, }, tree: { @@ -1003,6 +1015,47 @@ export const es: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir en {{brand}}", + addToChat: "Añadir al chat", + addAllToChat: "Añadir todo al chat", + addingToChat: "Añadiendo...", + }, + checksOverview: { + headline: { + actionRequired: "Algunas comprobaciones requieren tu atención", + failure: "Algunas comprobaciones no se superaron", + pending: "Algunas comprobaciones aún no han terminado", + success: "Todas las comprobaciones se superaron", + none: "Sin comprobaciones", + }, + count: { + actionRequired: "{{count}} con acción pendiente", + warning: "{{count}} con advertencia", + failure: "{{count}} con error", + pending: "{{count}} en curso", + manual: "{{count}} manual(es)", + success: "{{count}} superada(s)", + ignored: "{{count}} omitida(s)", + }, + detailOne: "Comprobación: {{parts}}", + detailMany: "Comprobaciones: {{parts}}", + groupOne: { + actionRequired: "{{count}} comprobación con acción pendiente", + warning: "{{count}} comprobación con advertencia", + failure: "{{count}} comprobación con error", + pending: "{{count}} comprobación en curso", + manual: "{{count}} comprobación manual", + success: "{{count}} comprobación superada", + ignored: "{{count}} comprobación omitida", + }, + groupMany: { + actionRequired: "{{count}} comprobaciones con acción pendiente", + warning: "{{count}} comprobaciones con advertencia", + failure: "{{count}} comprobaciones con error", + pending: "{{count}} comprobaciones en curso", + manual: "{{count}} comprobaciones manuales", + success: "{{count}} comprobaciones superadas", + ignored: "{{count}} comprobaciones omitidas", + }, }, checksSummary: { passedLabel: "pasó", @@ -1016,17 +1069,21 @@ export const es: TranslationResources = { checks: "cheques", pipeline: "Pipeline", reviews: "Reseñas", + activity: "Actividad", }, empty: { noJobs: "Sin trabajos", loadingPipeline: "Cargando pipeline...", pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline", allowedToFail: "permitido fallar", + noActivity: "Aún no hay actividad", }, approvals: "{{given}} de {{required}} aprobaciones", accessibility: { pullRequest: "Solicitud de extracción n.°{{number}}", pullRequest_mr: "Solicitud de fusión !{{number}}", + commentActions: "Acciones del comentario", + threadActions: "Acciones del hilo", checkStatus: { passed: "Superado", failed: "Fallido", @@ -1045,16 +1102,18 @@ export const es: TranslationResources = { open: "Abierto", }, activity: { - commented: "Comentado", - approved: "Aprobado", - requestedChanges: "Cambios solicitados", - reviewed: "Revisado", + commented: "Comentó", + approved: "Aprobó", + requestedChanges: "Solicitó cambios", + reviewed: "Revisó", }, time: { justNow: "En este momento", }, thread: { discussion: "Hilo de discusión", + resolved: "Resuelto", + outdated: "Desactualizado", }, errors: { statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción", @@ -1124,7 +1183,16 @@ export const es: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", + statusBucket: { + needsInput: "Necesita datos", + failed: "Con error", + readyToReview: "Para revisar", + working: "En ejecución", + done: "Terminado", + }, display: { + showBackground: "Mostrar espacios en segundo plano", trigger: "Preferencias de visualización", heading: "Visualización", grouping: { @@ -1638,14 +1706,19 @@ export const es: TranslationResources = { title: "Agregar proveedor", search: "Proveedores de búsqueda", noProviders: "No se encontraron proveedores", + clearSearch: "Borrar búsqueda", actions: { add: "Agregar", + addProvider: "Añadir {{provider}}", adding: "Añadiendo", installed: "Instalado", cancel: "Cancelar", installInstructions: "Instrucciones de instalación", installInstructionsFor: "Instrucciones de instalación de{{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "No se puede instalar el proveedor", }, @@ -1994,6 +2067,17 @@ export const es: TranslationResources = { accessibility: "Ventana de contexto{{percentage}}% utilizada", }, review: { + feedback: { + send: "Enviar comentarios ({{count}})", + sending: "Enviando comentarios ({{count}})", + chooseAgent: "Elegir un agente", + sent: "Comentarios enviados a {{recipient}}", + "no-agents": "Abre una pestaña de agente en este espacio de trabajo para enviar comentarios.", + disconnected: "Conéctate al host para enviar comentarios.", + "no-context": "Los comentarios guardados ya no coinciden con este diff.", + failed: "No se pudieron enviar los comentarios. Inténtalo de nuevo.", + prompt: "Por favor, atiende esta revisión de código.", + }, comment: { add: "Agregar comentario de revisión", edit: "Editar comentario de revisión", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 19deced196..8e70ece6da 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -31,6 +31,7 @@ export const fr: TranslationResources = { back: "Retour", loading: "Chargement…", actions: { + save: "Enregistrer", back: "Retour", cancel: "Annuler", close: "Fermer", @@ -125,6 +126,8 @@ export const fr: TranslationResources = { input: { accessibilityLabel: "Écrire à l’agent…", terminalAccessibilityLabel: "Prompt du terminal", + fullscreen: "Plein écran", + exitFullscreen: "Quitter le plein écran", focusHint: "{{shortcut}} pour saisir", addAttachment: "Ajouter une pièce jointe", interruptAgent: "Interrompre l’agent", @@ -387,6 +390,9 @@ export const fr: TranslationResources = { completed: "Terminée", }, }, + turnFooter: { + workedFor: "A travaillé pendant {{duration}}", + }, compaction: { loading: "Compaction…", auto: "Contexte compacté automatiquement", @@ -612,6 +618,9 @@ export const fr: TranslationResources = { updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", unableToSubscribe: "Impossible de s’abonner au terminal", }, + sheet: { + openAsTab: "Ouvrir dans un onglet", + }, tabs: { loading: "Chargement…", modified: "Modifications non enregistrées", @@ -627,7 +636,7 @@ export const fr: TranslationResources = { }, switcher: { trigger: "Changer d’onglet ({{count}} ouverts)", - title: "Changer d’onglet", + title: "Onglets de l’espace de travail", searchPlaceholder: "Rechercher des onglets", }, menu: { @@ -726,6 +735,8 @@ export const fr: TranslationResources = { header: { actions: { workspaceActions: "Actions de l’espace de travail", + workspaceActionsUnhealthy: + "Actions de l’espace de travail, un service est en mauvaise santé", newAgent: "Nouvel agent", newTerminal: "Nouveau terminal", newBrowser: "Nouvel onglet de navigateur", @@ -774,6 +785,8 @@ export const fr: TranslationResources = { exitCode: "sortie {{code}}", startFailed: "Impossible de démarrer {{scriptName}}", stopFailed: "Impossible d’arrêter {{scriptName}}", + running: "En cours", + runningCount: "{{count}} en cours", }, }, tree: { @@ -994,6 +1007,47 @@ export const fr: TranslationResources = { actions: { viewPullRequest: "Voir", openOn: "Ouvrir sur {{brand}}", + addToChat: "Ajouter à la conversation", + addAllToChat: "Tout ajouter à la conversation", + addingToChat: "Ajout…", + }, + checksOverview: { + headline: { + actionRequired: "Certaines vérifications demandent votre attention", + failure: "Certaines vérifications ont échoué", + pending: "Certaines vérifications ne sont pas terminées", + success: "Toutes les vérifications ont réussi", + none: "Aucune vérification", + }, + count: { + actionRequired: "{{count}} à traiter", + warning: "{{count}} avec avertissement", + failure: "{{count}} en échec", + pending: "{{count}} en cours", + manual: "{{count}} manuelle(s)", + success: "{{count}} réussie(s)", + ignored: "{{count}} ignorée(s)", + }, + detailOne: "Vérification : {{parts}}", + detailMany: "Vérifications : {{parts}}", + groupOne: { + actionRequired: "{{count}} vérification à traiter", + warning: "{{count}} vérification avec avertissement", + failure: "{{count}} vérification en échec", + pending: "{{count}} vérification en cours", + manual: "{{count}} vérification manuelle", + success: "{{count}} vérification réussie", + ignored: "{{count}} vérification ignorée", + }, + groupMany: { + actionRequired: "{{count}} vérifications à traiter", + warning: "{{count}} vérifications avec avertissement", + failure: "{{count}} vérifications en échec", + pending: "{{count}} vérifications en cours", + manual: "{{count}} vérifications manuelles", + success: "{{count}} vérifications réussies", + ignored: "{{count}} vérifications ignorées", + }, }, checksSummary: { passedLabel: "réussies", @@ -1007,17 +1061,21 @@ export const fr: TranslationResources = { checks: "Vérifications", pipeline: "Pipeline", reviews: "Revues", + activity: "Activité", }, empty: { noJobs: "Aucun job", loadingPipeline: "Chargement du pipeline…", pipelineJobsLoadFailed: "Impossible de charger les jobs du pipeline", allowedToFail: "échec autorisé", + noActivity: "Aucune activité pour le moment", }, approvals: "{{given}} sur {{required}} approbations", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Actions du commentaire", + threadActions: "Actions du fil", checkStatus: { passed: "Réussie", failed: "Échec", @@ -1046,6 +1104,8 @@ export const fr: TranslationResources = { }, thread: { discussion: "Fil de discussion", + resolved: "Résolu", + outdated: "Obsolète", }, errors: { statusLoadFailed: "Impossible de charger le statut de la pull request", @@ -1115,7 +1175,16 @@ export const fr: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Groupe {{label}}", + statusBucket: { + needsInput: "Attend une réponse", + failed: "Échec", + readyToReview: "À relire", + working: "En cours", + done: "Terminé", + }, display: { + showBackground: "Afficher les espaces en arrière-plan", trigger: "Préférences d’affichage", heading: "Affichage", grouping: { @@ -1634,14 +1703,19 @@ export const fr: TranslationResources = { title: "Ajouter un fournisseur", search: "Rechercher des fournisseurs", noProviders: "Aucun fournisseur trouvé", + clearSearch: "Effacer la recherche", actions: { add: "Ajouter", + addProvider: "Ajouter {{provider}}", adding: "Ajout…", installed: "Installé", cancel: "Annuler", installInstructions: "Instructions d’installation", installInstructionsFor: "Instructions d’installation de {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Impossible d’installer le fournisseur", }, @@ -1991,6 +2065,18 @@ export const fr: TranslationResources = { accessibility: "Fenêtre de contexte : {{percentage}} % utilisés", }, review: { + feedback: { + send: "Envoyer les commentaires ({{count}})", + sending: "Envoi des commentaires ({{count}})", + chooseAgent: "Choisir un agent", + sent: "Commentaires envoyés à {{recipient}}", + "no-agents": + "Ouvrez un onglet d’agent dans cet espace de travail pour envoyer les commentaires.", + disconnected: "Connectez-vous à l’hôte pour envoyer les commentaires.", + "no-context": "Les commentaires enregistrés ne correspondent plus à ce diff.", + failed: "Échec de l’envoi des commentaires. Réessayez.", + prompt: "Veuillez traiter cette revue de code.", + }, comment: { add: "Ajouter un commentaire de revue", edit: "Modifier le commentaire de revue", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 59ac1cc600..d7b08a9c84 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -31,6 +31,7 @@ export const ja: TranslationResources = { back: "戻る", loading: "読み込み中...", actions: { + save: "保存", back: "戻る", cancel: "キャンセル", close: "閉じる", @@ -124,6 +125,8 @@ export const ja: TranslationResources = { input: { accessibilityLabel: "エージェントにメッセージ...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全画面表示", + exitFullscreen: "全画面表示を終了", focusHint: "{{shortcut}}でフォーカス", addAttachment: "添付ファイルを追加", interruptAgent: "エージェントを中断", @@ -387,6 +390,9 @@ export const ja: TranslationResources = { completed: "完了", }, }, + turnFooter: { + workedFor: "作業時間 {{duration}}", + }, compaction: { loading: "コンテキストを圧縮中...", auto: "コンテキストが自動的に圧縮されました", @@ -613,6 +619,9 @@ export const ja: TranslationResources = { updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", }, + sheet: { + openAsTab: "タブで開く", + }, tabs: { loading: "読み込み中...", modified: "未保存の変更", @@ -628,7 +637,7 @@ export const ja: TranslationResources = { }, switcher: { trigger: "タブを切り替え({{count}}件開いています)", - title: "タブを切り替え", + title: "ワークスペースのタブ", searchPlaceholder: "タブを検索", }, menu: { @@ -724,6 +733,7 @@ export const ja: TranslationResources = { header: { actions: { workspaceActions: "ワークスペースアクション", + workspaceActionsUnhealthy: "ワークスペースアクション、異常なサービスがあります", newAgent: "新しいエージェント", newTerminal: "新しいターミナル", newBrowser: "新しいブラウザタブ", @@ -771,6 +781,8 @@ export const ja: TranslationResources = { exitCode: "終了コード: {{code}}", startFailed: "{{scriptName}}の起動に失敗しました", stopFailed: "{{scriptName}}の停止に失敗しました", + running: "実行中", + runningCount: "{{count}} 件実行中", }, }, tree: { @@ -983,6 +995,47 @@ export const ja: TranslationResources = { actions: { viewPullRequest: "表示", openOn: "{{brand}}で開く", + addToChat: "チャットに追加", + addAllToChat: "すべてチャットに追加", + addingToChat: "追加中...", + }, + checksOverview: { + headline: { + actionRequired: "対応が必要なチェックがあります", + failure: "失敗したチェックがあります", + pending: "完了していないチェックがあります", + success: "すべてのチェックに合格しました", + none: "チェックなし", + }, + count: { + actionRequired: "{{count}} 件要対応", + warning: "{{count}} 件警告", + failure: "{{count}} 件失敗", + pending: "{{count}} 件実行中", + manual: "{{count}} 件手動", + success: "{{count}} 件成功", + ignored: "{{count}} 件スキップ", + }, + detailOne: "チェック: {{parts}}", + detailMany: "チェック: {{parts}}", + groupOne: { + actionRequired: "要対応のチェック {{count}} 件", + warning: "警告のチェック {{count}} 件", + failure: "失敗したチェック {{count}} 件", + pending: "実行中のチェック {{count}} 件", + manual: "手動のチェック {{count}} 件", + success: "成功したチェック {{count}} 件", + ignored: "スキップされたチェック {{count}} 件", + }, + groupMany: { + actionRequired: "要対応のチェック {{count}} 件", + warning: "警告のチェック {{count}} 件", + failure: "失敗したチェック {{count}} 件", + pending: "実行中のチェック {{count}} 件", + manual: "手動のチェック {{count}} 件", + success: "成功したチェック {{count}} 件", + ignored: "スキップされたチェック {{count}} 件", + }, }, checksSummary: { passedLabel: "成功", @@ -996,17 +1049,21 @@ export const ja: TranslationResources = { checks: "チェック", pipeline: "パイプライン", reviews: "レビュー", + activity: "アクティビティ", }, empty: { noJobs: "ジョブなし", loadingPipeline: "パイプラインを読み込み中...", pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした", allowedToFail: "失敗を許可", + noActivity: "まだアクティビティはありません", }, approvals: "{{given}} / {{required}} 承認", accessibility: { pullRequest: "プルリクエスト#{{number}}", pullRequest_mr: "マージリクエスト !{{number}}", + commentActions: "コメントの操作", + threadActions: "スレッドの操作", checkStatus: { passed: "成功", failed: "失敗", @@ -1035,6 +1092,8 @@ export const ja: TranslationResources = { }, thread: { discussion: "ディスカッションスレッド", + resolved: "解決済み", + outdated: "古い", }, errors: { statusLoadFailed: "プルリクエストのステータスを読み込めません", @@ -1101,7 +1160,16 @@ export const ja: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} グループ", + statusBucket: { + needsInput: "入力待ち", + failed: "失敗", + readyToReview: "レビュー待ち", + working: "実行中", + done: "完了", + }, display: { + showBackground: "バックグラウンドを表示", trigger: "表示設定", heading: "表示", grouping: { @@ -1609,14 +1677,19 @@ export const ja: TranslationResources = { title: "プロバイダーを追加", search: "プロバイダーを検索", noProviders: "プロバイダーが見つかりません", + clearSearch: "検索をクリア", actions: { add: "追加", + addProvider: "{{provider}} を追加", adding: "追加中", installed: "インストール済み", cancel: "キャンセル", installInstructions: "インストール手順", installInstructionsFor: "{{provider}}のインストール手順", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "プロバイダーをインストールできません", }, @@ -1966,6 +2039,18 @@ export const ja: TranslationResources = { accessibility: "コンテキストウィンドウ{{percentage}}%使用", }, review: { + feedback: { + send: "フィードバックを送信 ({{count}})", + sending: "フィードバックを送信中 ({{count}})", + chooseAgent: "エージェントを選択", + sent: "{{recipient}} にフィードバックを送信しました", + "no-agents": + "フィードバックを送信するには、このワークスペースでエージェントタブを開いてください。", + disconnected: "フィードバックを送信するにはホストに接続してください。", + "no-context": "保存したコメントはこの差分と一致しなくなりました。", + failed: "フィードバックを送信できませんでした。もう一度お試しください。", + prompt: "このコードレビューに対応してください。", + }, comment: { add: "レビューコメントを追加", edit: "レビューコメントを編集", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 7528bd26c6..c82240f0c4 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -30,6 +30,7 @@ export const ko: TranslationResources = { back: "뒤로", loading: "불러오는 중...", actions: { + save: "저장", back: "뒤로", cancel: "취소", close: "닫기", @@ -123,6 +124,8 @@ export const ko: TranslationResources = { input: { accessibilityLabel: "에이전트에게 메시지...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "전체 화면", + exitFullscreen: "전체 화면 종료", focusHint: "{{shortcut}}로 포커스", addAttachment: "첨부 추가", interruptAgent: "에이전트 중단", @@ -383,6 +386,9 @@ export const ko: TranslationResources = { completed: "완료됨", }, }, + turnFooter: { + workedFor: "작업 시간 {{duration}}", + }, compaction: { loading: "압축하는 중...", auto: "컨텍스트가 자동으로 압축되었습니다", @@ -608,6 +614,9 @@ export const ko: TranslationResources = { updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", unableToSubscribe: "터미널을 구독할 수 없습니다", }, + sheet: { + openAsTab: "탭으로 열기", + }, tabs: { loading: "불러오는 중...", modified: "저장되지 않은 변경사항", @@ -623,7 +632,7 @@ export const ko: TranslationResources = { }, switcher: { trigger: "탭 전환 ({{count}}개 열림)", - title: "탭 전환", + title: "워크스페이스 탭", searchPlaceholder: "탭 검색", }, menu: { @@ -720,6 +729,7 @@ export const ko: TranslationResources = { header: { actions: { workspaceActions: "워크스페이스 작업", + workspaceActionsUnhealthy: "워크스페이스 작업, 비정상 서비스가 있습니다", newAgent: "새 에이전트", newTerminal: "새 터미널", newBrowser: "새 브라우저 탭", @@ -767,6 +777,8 @@ export const ko: TranslationResources = { exitCode: "종료 {{code}}", startFailed: "{{scriptName}}을(를) 시작하지 못했습니다", stopFailed: "{{scriptName}}를 중지하지 못했습니다.", + running: "실행 중", + runningCount: "{{count}}개 실행 중", }, }, tree: { @@ -977,6 +989,47 @@ export const ko: TranslationResources = { actions: { viewPullRequest: "보기", openOn: "{{brand}}에서 열기", + addToChat: "채팅에 추가", + addAllToChat: "모두 채팅에 추가", + addingToChat: "추가 중...", + }, + checksOverview: { + headline: { + actionRequired: "확인이 필요한 검사가 있습니다", + failure: "실패한 검사가 있습니다", + pending: "아직 완료되지 않은 검사가 있습니다", + success: "모든 검사를 통과했습니다", + none: "검사 없음", + }, + count: { + actionRequired: "{{count}}개 조치 필요", + warning: "{{count}}개 경고", + failure: "{{count}}개 실패", + pending: "{{count}}개 진행 중", + manual: "{{count}}개 수동", + success: "{{count}}개 성공", + ignored: "{{count}}개 건너뜀", + }, + detailOne: "검사: {{parts}}", + detailMany: "검사: {{parts}}", + groupOne: { + actionRequired: "조치 필요 검사 {{count}}개", + warning: "경고 검사 {{count}}개", + failure: "실패한 검사 {{count}}개", + pending: "진행 중인 검사 {{count}}개", + manual: "수동 검사 {{count}}개", + success: "성공한 검사 {{count}}개", + ignored: "건너뛴 검사 {{count}}개", + }, + groupMany: { + actionRequired: "조치 필요 검사 {{count}}개", + warning: "경고 검사 {{count}}개", + failure: "실패한 검사 {{count}}개", + pending: "진행 중인 검사 {{count}}개", + manual: "수동 검사 {{count}}개", + success: "성공한 검사 {{count}}개", + ignored: "건너뛴 검사 {{count}}개", + }, }, checksSummary: { passedLabel: "통과", @@ -990,17 +1043,21 @@ export const ko: TranslationResources = { checks: "검사", pipeline: "파이프라인", reviews: "리뷰", + activity: "활동", }, empty: { noJobs: "작업 없음", loadingPipeline: "파이프라인 로드 중…", pipelineJobsLoadFailed: "파이프라인 작업을 로드할 수 없습니다.", allowedToFail: "실패가 허용됨", + noActivity: "아직 활동이 없습니다", }, approvals: "{{required}} 중 {{given}} 승인", accessibility: { pullRequest: "풀 리퀘스트 #{{number}}", pullRequest_mr: "병합 요청 !{{number}}", + commentActions: "댓글 작업", + threadActions: "스레드 작업", checkStatus: { passed: "통과", failed: "실패", @@ -1029,6 +1086,8 @@ export const ko: TranslationResources = { }, thread: { discussion: "토론 스레드", + resolved: "해결됨", + outdated: "오래됨", }, errors: { statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다", @@ -1095,7 +1154,16 @@ export const ko: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} 그룹", + statusBucket: { + needsInput: "입력 필요", + failed: "실패", + readyToReview: "검토 대기", + working: "실행 중", + done: "완료", + }, display: { + showBackground: "백그라운드 표시", trigger: "표시 설정", heading: "표시", grouping: { @@ -1602,14 +1670,19 @@ export const ko: TranslationResources = { title: "프로바이더 추가", search: "프로바이더 검색", noProviders: "프로바이더를 찾을 수 없습니다", + clearSearch: "검색 지우기", actions: { add: "추가", + addProvider: "{{provider}} 추가", adding: "추가 중", installed: "설치됨", cancel: "취소", installInstructions: "설치 안내", installInstructionsFor: "{{provider}} 설치 안내", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "프로바이더를 설치할 수 없습니다", }, @@ -1956,6 +2029,17 @@ export const ko: TranslationResources = { accessibility: "컨텍스트 윈도우 {{percentage}}% 사용됨", }, review: { + feedback: { + send: "피드백 보내기 ({{count}})", + sending: "피드백 전송 중 ({{count}})", + chooseAgent: "에이전트 선택", + sent: "{{recipient}}에게 피드백을 보냈습니다", + "no-agents": "피드백을 보내려면 이 작업 공간에서 에이전트 탭을 여세요.", + disconnected: "피드백을 보내려면 호스트에 연결하세요.", + "no-context": "저장된 댓글이 더 이상 이 변경 사항과 일치하지 않습니다.", + failed: "피드백 전송에 실패했습니다. 다시 시도하세요.", + prompt: "이 코드 리뷰를 반영해 주세요.", + }, comment: { add: "리뷰 댓글 추가", edit: "리뷰 댓글 편집", diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index b3f0621df7..8146ad9747 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -27,7 +27,7 @@ export const pluginSettings = { globalTitle: "تفعيل الإضافات", globalHint: "مفتاح عام لكل الإضافات المضبوطة", sourceLabel: "مصدر الإضافة", - sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm", + sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm", docs: "الوثائق", install: "تثبيت الإضافة", installing: "جارٍ التثبيت…", @@ -82,7 +82,7 @@ export const pluginSettings = { globalTitle: "Activar plugins", globalHint: "Interruptor global para todos los plugins configurados", sourceLabel: "Fuente del plugin", - sourcePlaceholder: "Directorio, URL de Git o paquete npm", + sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm", docs: "Documentación", install: "Instalar plugin", installing: "Instalando…", @@ -137,7 +137,7 @@ export const pluginSettings = { globalTitle: "Activer les plugins", globalHint: "Interrupteur global pour tous les plugins configurés", sourceLabel: "Source du plugin", - sourcePlaceholder: "Dossier, URL Git ou paquet npm", + sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm", docs: "Documentation", install: "Installer le plugin", installing: "Installation…", @@ -192,7 +192,7 @@ export const pluginSettings = { globalTitle: "プラグインを有効化", globalHint: "設定済みプラグインすべての共通スイッチ", sourceLabel: "プラグインソース", - sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ", + sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ", docs: "ドキュメント", install: "プラグインをインストール", installing: "インストール中…", @@ -246,7 +246,7 @@ export const pluginSettings = { globalTitle: "플러그인 활성화", globalHint: "설정된 모든 플러그인의 전역 스위치", sourceLabel: "플러그인 소스", - sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지", + sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지", docs: "문서", install: "플러그인 설치", installing: "설치 중…", @@ -301,7 +301,7 @@ export const pluginSettings = { globalTitle: "Ativar plugins", globalHint: "Controle global para todos os plugins configurados", sourceLabel: "Fonte do plugin", - sourcePlaceholder: "Diretório, URL Git ou pacote npm", + sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm", docs: "Documentação", install: "Instalar plugin", installing: "Instalando…", @@ -356,7 +356,7 @@ export const pluginSettings = { globalTitle: "Включить плагины", globalHint: "Общий переключатель для всех настроенных плагинов", sourceLabel: "Источник плагина", - sourcePlaceholder: "Каталог, URL Git или пакет npm", + sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm", docs: "Документация", install: "Установить плагин", installing: "Установка…", @@ -410,7 +410,7 @@ export const pluginSettings = { globalTitle: "启用插件", globalHint: "所有已配置插件的全局开关", sourceLabel: "插件来源", - sourcePlaceholder: "目录、Git URL 或 npm 包", + sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包", docs: "文档", install: "安装插件", installing: "正在安装…", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5726d75c8b..622c13e798 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -31,6 +31,7 @@ export const ptBR: TranslationResources = { back: "Voltar", loading: "Carregando...", actions: { + save: "Salvar", back: "Voltar", cancel: "Cancelar", close: "Fechar", @@ -124,6 +125,8 @@ export const ptBR: TranslationResources = { input: { accessibilityLabel: "Enviar mensagem ao agente...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Tela cheia", + exitFullscreen: "Sair da tela cheia", focusHint: "{{shortcut}} para focar", addAttachment: "Adicionar anexo", interruptAgent: "Interromper agente", @@ -386,6 +389,9 @@ export const ptBR: TranslationResources = { completed: "Concluída", }, }, + turnFooter: { + workedFor: "Trabalhou por {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automaticamente", @@ -612,6 +618,9 @@ export const ptBR: TranslationResources = { updateHost: "Atualize o host para usar o renderizador de terminal nativo.", unableToSubscribe: "Não foi possível assinar o terminal", }, + sheet: { + openAsTab: "Abrir como aba", + }, tabs: { loading: "Carregando...", modified: "Alterações não salvas", @@ -627,7 +636,7 @@ export const ptBR: TranslationResources = { }, switcher: { trigger: "Alternar abas ({{count}} abertas)", - title: "Alternar aba", + title: "Abas do workspace", searchPlaceholder: "Buscar abas", }, menu: { @@ -724,6 +733,7 @@ export const ptBR: TranslationResources = { header: { actions: { workspaceActions: "Ações do workspace", + workspaceActionsUnhealthy: "Ações do workspace, há um serviço com problema", newAgent: "Novo agente", newTerminal: "Novo terminal", newBrowser: "Nova aba de navegador", @@ -771,6 +781,8 @@ export const ptBR: TranslationResources = { exitCode: "saída {{code}}", startFailed: "Falha ao iniciar {{scriptName}}", stopFailed: "Falha ao parar {{scriptName}}", + running: "Em execução", + runningCount: "{{count}} em execução", }, }, tree: { @@ -993,6 +1005,47 @@ export const ptBR: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir no {{brand}}", + addToChat: "Adicionar ao chat", + addAllToChat: "Adicionar tudo ao chat", + addingToChat: "Adicionando...", + }, + checksOverview: { + headline: { + actionRequired: "Algumas verificações precisam da sua atenção", + failure: "Algumas verificações não foram bem-sucedidas", + pending: "Algumas verificações ainda não terminaram", + success: "Todas as verificações foram aprovadas", + none: "Nenhuma verificação", + }, + count: { + actionRequired: "{{count}} com ação pendente", + warning: "{{count}} com aviso", + failure: "{{count}} com falha", + pending: "{{count}} em andamento", + manual: "{{count}} manual(is)", + success: "{{count}} aprovada(s)", + ignored: "{{count}} ignorada(s)", + }, + detailOne: "Verificação: {{parts}}", + detailMany: "Verificações: {{parts}}", + groupOne: { + actionRequired: "{{count}} verificação com ação pendente", + warning: "{{count}} verificação com aviso", + failure: "{{count}} verificação com falha", + pending: "{{count}} verificação em andamento", + manual: "{{count}} verificação manual", + success: "{{count}} verificação aprovada", + ignored: "{{count}} verificação ignorada", + }, + groupMany: { + actionRequired: "{{count}} verificações com ação pendente", + warning: "{{count}} verificações com aviso", + failure: "{{count}} verificações com falha", + pending: "{{count}} verificações em andamento", + manual: "{{count}} verificações manuais", + success: "{{count}} verificações aprovadas", + ignored: "{{count}} verificações ignoradas", + }, }, checksSummary: { passedLabel: "passou", @@ -1006,17 +1059,21 @@ export const ptBR: TranslationResources = { checks: "Verificações", pipeline: "Pipeline", reviews: "Revisões", + activity: "Atividade", }, empty: { noJobs: "Sem jobs", loadingPipeline: "Carregando pipeline...", pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline", allowedToFail: "permitido falhar", + noActivity: "Nenhuma atividade ainda", }, approvals: "{{given}} de {{required}} aprovações", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Ações do comentário", + threadActions: "Ações da conversa", checkStatus: { passed: "Aprovado", failed: "Falhou", @@ -1036,15 +1093,17 @@ export const ptBR: TranslationResources = { }, activity: { commented: "Comentou", - approved: "Aprovado", + approved: "Aprovou", requestedChanges: "Solicitou alterações", - reviewed: "Revisado", + reviewed: "Revisou", }, time: { justNow: "agora mesmo", }, thread: { discussion: "Tópico de discussão", + resolved: "Resolvido", + outdated: "Desatualizado", }, errors: { statusLoadFailed: "Não foi possível carregar o status da pull request", @@ -1114,7 +1173,16 @@ export const ptBR: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", + statusBucket: { + needsInput: "Precisa de resposta", + failed: "Com falha", + readyToReview: "Para revisar", + working: "Em execução", + done: "Concluído", + }, display: { + showBackground: "Mostrar espaços em segundo plano", trigger: "Preferências de exibição", heading: "Exibição", grouping: { @@ -1623,14 +1691,19 @@ export const ptBR: TranslationResources = { title: "Adicionar provedor", search: "Buscar provedores", noProviders: "Nenhum provedor encontrado", + clearSearch: "Limpar pesquisa", actions: { add: "Adicionar", + addProvider: "Adicionar {{provider}}", adding: "Adicionando", installed: "Instalado", cancel: "Cancelar", installInstructions: "Instruções de instalação", installInstructionsFor: "Instruções de instalação de {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Não foi possível instalar provedor", }, @@ -1979,6 +2052,17 @@ export const ptBR: TranslationResources = { accessibility: "Janela de contexto {{percentage}}% usada", }, review: { + feedback: { + send: "Enviar comentários ({{count}})", + sending: "Enviando comentários ({{count}})", + chooseAgent: "Escolher um agente", + sent: "Comentários enviados para {{recipient}}", + "no-agents": "Abra uma aba de agente neste espaço de trabalho para enviar comentários.", + disconnected: "Conecte-se ao host para enviar comentários.", + "no-context": "Os comentários salvos não correspondem mais a este diff.", + failed: "Falha ao enviar comentários. Tente novamente.", + prompt: "Por favor, atenda a esta revisão de código.", + }, comment: { add: "Adicionar comentário de revisão", edit: "Editar comentário de revisão", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4f558ca79f..733596de39 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -31,6 +31,7 @@ export const ru: TranslationResources = { back: "Назад", loading: "Загрузка...", actions: { + save: "Сохранить", back: "Назад", cancel: "Отмена", close: "Закрыть", @@ -124,6 +125,8 @@ export const ru: TranslationResources = { input: { accessibilityLabel: "Написать агенту...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Полный экран", + exitFullscreen: "Выйти из полноэкранного режима", focusHint: "{{shortcut}}, для фокуса", addAttachment: "Добавить вложение", interruptAgent: "Прервать агента", @@ -386,6 +389,9 @@ export const ru: TranslationResources = { completed: "Завершена", }, }, + turnFooter: { + workedFor: "Время работы: {{duration}}", + }, compaction: { loading: "Сжатие контекста...", auto: "Контекст сжат автоматически", @@ -614,6 +620,9 @@ export const ru: TranslationResources = { updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", unableToSubscribe: "Не удалось подключиться к терминалу", }, + sheet: { + openAsTab: "Открыть во вкладке", + }, tabs: { loading: "Загрузка...", modified: "Несохранённые изменения", @@ -629,7 +638,7 @@ export const ru: TranslationResources = { }, switcher: { trigger: "Переключить вкладки (открыто: {{count}})", - title: "Переключить вкладку", + title: "Вкладки рабочего пространства", searchPlaceholder: "Поиск вкладок", }, menu: { @@ -727,6 +736,7 @@ export const ru: TranslationResources = { header: { actions: { workspaceActions: "Действия с рабочим пространством", + workspaceActionsUnhealthy: "Действия с рабочим пространством, есть неисправный сервис", newAgent: "Новый агент", newTerminal: "Новый терминал", newBrowser: "Новая вкладка браузера", @@ -775,6 +785,8 @@ export const ru: TranslationResources = { exitCode: "Код выхода: {{code}}", startFailed: "Не удалось запустить скрипт {{scriptName}}", stopFailed: "Не удалось остановить скрипт {{scriptName}}", + running: "Запущен", + runningCount: "Запущено: {{count}}", }, }, tree: { @@ -987,6 +999,47 @@ export const ru: TranslationResources = { actions: { viewPullRequest: "Просмотреть", openOn: "Открыть на {{brand}}", + addToChat: "Добавить в чат", + addAllToChat: "Добавить всё в чат", + addingToChat: "Добавление...", + }, + checksOverview: { + headline: { + actionRequired: "Некоторые проверки требуют внимания", + failure: "Некоторые проверки не пройдены", + pending: "Некоторые проверки ещё не завершены", + success: "Все проверки пройдены", + none: "Нет проверок", + }, + count: { + actionRequired: "требует действий: {{count}}", + warning: "с предупреждением: {{count}}", + failure: "не пройдено: {{count}}", + pending: "выполняется: {{count}}", + manual: "вручную: {{count}}", + success: "пройдено: {{count}}", + ignored: "пропущено: {{count}}", + }, + detailOne: "Проверка ({{parts}})", + detailMany: "Проверки ({{parts}})", + groupOne: { + actionRequired: "Требует действий: {{count}}", + warning: "С предупреждением: {{count}}", + failure: "Не пройдена: {{count}}", + pending: "Выполняется: {{count}}", + manual: "Ручная: {{count}}", + success: "Пройдена: {{count}}", + ignored: "Пропущена: {{count}}", + }, + groupMany: { + actionRequired: "Требуют действий: {{count}}", + warning: "С предупреждением: {{count}}", + failure: "Не пройдены: {{count}}", + pending: "Выполняются: {{count}}", + manual: "Ручные: {{count}}", + success: "Пройдены: {{count}}", + ignored: "Пропущены: {{count}}", + }, }, checksSummary: { passedLabel: "успешно", @@ -1000,17 +1053,21 @@ export const ru: TranslationResources = { checks: "Проверки", pipeline: "Пайплайн", reviews: "Ревью", + activity: "Активность", }, empty: { noJobs: "Нет заданий", loadingPipeline: "Загрузка пайплайна...", pipelineJobsLoadFailed: "Не удалось загрузить задания пайплайна", allowedToFail: "допускается сбой", + noActivity: "Активности пока нет", }, approvals: "Одобрено: {{given}} из {{required}}", accessibility: { pullRequest: "PR #{{number}}", pullRequest_mr: "MR !{{number}}", + commentActions: "Действия с комментарием", + threadActions: "Действия с обсуждением", checkStatus: { passed: "Успешно", failed: "Ошибка", @@ -1039,6 +1096,8 @@ export const ru: TranslationResources = { }, thread: { discussion: "Ветка обсуждения", + resolved: "Решено", + outdated: "Устарело", }, errors: { statusLoadFailed: "Не удалось загрузить статус PR", @@ -1105,7 +1164,16 @@ export const ru: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Группа «{{label}}»", + statusBucket: { + needsInput: "Ожидает ввода", + failed: "Ошибка", + readyToReview: "На проверку", + working: "В работе", + done: "Готово", + }, display: { + showBackground: "Показать фоновые пространства", trigger: "Настройки отображения", heading: "Отображение", grouping: { @@ -1621,14 +1689,19 @@ export const ru: TranslationResources = { title: "Добавить провайдера", search: "Поиск провайдеров", noProviders: "Провайдеры не найдены", + clearSearch: "Очистить поиск", actions: { add: "Добавить", + addProvider: "Добавить {{provider}}", adding: "Добавление", installed: "Установлено", cancel: "Отмена", installInstructions: "Инструкции по установке", installInstructionsFor: "Инструкции по установке {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Не удалось установить провайдера", }, @@ -1979,6 +2052,17 @@ export const ru: TranslationResources = { accessibility: "Использовано {{percentage}}% контекстного окна", }, review: { + feedback: { + send: "Отправить отзыв ({{count}})", + sending: "Отправка отзыва ({{count}})", + chooseAgent: "Выберите агента", + sent: "Отзыв отправлен: {{recipient}}", + "no-agents": "Откройте вкладку агента в этой рабочей области, чтобы отправить отзыв.", + disconnected: "Подключитесь к хосту, чтобы отправить отзыв.", + "no-context": "Сохранённые комментарии больше не соответствуют этому diff.", + failed: "Не удалось отправить отзыв. Повторите попытку.", + prompt: "Пожалуйста, учтите эту проверку кода.", + }, comment: { add: "Добавить комментарий к ревью", edit: "Изменить комментарий к ревью", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d1b5511dd6..c8627ca769 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -30,6 +30,7 @@ export const zhCN: TranslationResources = { back: "返回", loading: "加载中...", actions: { + save: "保存", back: "返回", cancel: "取消", close: "关闭", @@ -123,6 +124,8 @@ export const zhCN: TranslationResources = { input: { accessibilityLabel: "给 Agent 发消息...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全屏", + exitFullscreen: "退出全屏", focusHint: "{{shortcut}} 聚焦", addAttachment: "添加附件", interruptAgent: "中断 Agent", @@ -382,6 +385,9 @@ export const zhCN: TranslationResources = { completed: "已完成", }, }, + turnFooter: { + workedFor: "工作了 {{duration}}", + }, compaction: { loading: "正在压缩...", auto: "上下文已自动压缩", @@ -607,6 +613,9 @@ export const zhCN: TranslationResources = { updateHost: "请更新主机以使用原生终端渲染器。", unableToSubscribe: "无法订阅 Terminal", }, + sheet: { + openAsTab: "作为标签页打开", + }, tabs: { loading: "正在加载...", modified: "未保存的更改", @@ -622,7 +631,7 @@ export const zhCN: TranslationResources = { }, switcher: { trigger: "切换标签(已打开 {{count}} 个)", - title: "切换标签", + title: "Workspace 标签", searchPlaceholder: "搜索标签", }, menu: { @@ -716,6 +725,7 @@ export const zhCN: TranslationResources = { header: { actions: { workspaceActions: "Workspace 操作", + workspaceActionsUnhealthy: "Workspace 操作,有服务异常", newAgent: "新建 Agent", newTerminal: "新建 Terminal", newBrowser: "新建浏览器标签", @@ -763,6 +773,8 @@ export const zhCN: TranslationResources = { exitCode: "exit {{code}}", startFailed: "启动 {{scriptName}} 失败", stopFailed: "停止 {{scriptName}} 失败", + running: "运行中", + runningCount: "{{count}} 个运行中", }, }, tree: { @@ -962,6 +974,47 @@ export const zhCN: TranslationResources = { actions: { viewPullRequest: "查看", openOn: "在 {{brand}} 上打开", + addToChat: "添加到聊天", + addAllToChat: "全部添加到聊天", + addingToChat: "正在添加...", + }, + checksOverview: { + headline: { + actionRequired: "部分检查需要你处理", + failure: "部分检查未通过", + pending: "部分检查尚未完成", + success: "所有检查均已通过", + none: "没有检查", + }, + count: { + actionRequired: "{{count}} 个需处理", + warning: "{{count}} 个警告", + failure: "{{count}} 个失败", + pending: "{{count}} 个进行中", + manual: "{{count}} 个手动", + success: "{{count}} 个成功", + ignored: "{{count}} 个已跳过", + }, + detailOne: "检查:{{parts}}", + detailMany: "检查:{{parts}}", + groupOne: { + actionRequired: "{{count}} 个需处理的检查", + warning: "{{count}} 个有警告的检查", + failure: "{{count}} 个失败的检查", + pending: "{{count}} 个进行中的检查", + manual: "{{count}} 个手动检查", + success: "{{count}} 个成功的检查", + ignored: "{{count}} 个已跳过的检查", + }, + groupMany: { + actionRequired: "{{count}} 个需处理的检查", + warning: "{{count}} 个有警告的检查", + failure: "{{count}} 个失败的检查", + pending: "{{count}} 个进行中的检查", + manual: "{{count}} 个手动检查", + success: "{{count}} 个成功的检查", + ignored: "{{count}} 个已跳过的检查", + }, }, checksSummary: { passedLabel: "通过", @@ -975,17 +1028,21 @@ export const zhCN: TranslationResources = { checks: "Checks", pipeline: "流水线", reviews: "Reviews", + activity: "动态", }, empty: { noJobs: "无作业", loadingPipeline: "正在加载流水线...", pipelineJobsLoadFailed: "无法加载流水线作业", allowedToFail: "允许失败", + noActivity: "暂无动态", }, approvals: "{{given}} / {{required}} 批准", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "合并请求 !{{number}}", + commentActions: "评论操作", + threadActions: "讨论串操作", checkStatus: { passed: "成功", failed: "失败", @@ -998,22 +1055,24 @@ export const zhCN: TranslationResources = { }, }, states: { - draft: "Draft", - merged: "已 merge", + draft: "草稿", + merged: "已合并", closed: "已关闭", - open: "Open", + open: "开放", }, activity: { commented: "已评论", approved: "已批准", requestedChanges: "请求修改", - reviewed: "已 review", + reviewed: "已审查", }, time: { justNow: "刚刚", }, thread: { discussion: "讨论主题", + resolved: "已解决", + outdated: "已过时", }, errors: { statusLoadFailed: "无法加载 Pull Request 状态", @@ -1080,7 +1139,16 @@ export const zhCN: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} 分组", + statusBucket: { + needsInput: "需要输入", + failed: "失败", + readyToReview: "待查看", + working: "运行中", + done: "已完成", + }, display: { + showBackground: "显示后台工作区", trigger: "显示偏好", heading: "显示", grouping: { @@ -1575,14 +1643,19 @@ export const zhCN: TranslationResources = { title: "添加 provider", search: "搜索 providers", noProviders: "未找到 providers", + clearSearch: "清除搜索", actions: { add: "添加", + addProvider: "添加 {{provider}}", adding: "正在添加", installed: "已安装", cancel: "取消", installInstructions: "安装说明", installInstructionsFor: "{{provider}} 安装说明", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "无法安装 provider", }, @@ -1924,6 +1997,17 @@ export const zhCN: TranslationResources = { accessibility: "上下文窗口已使用 {{percentage}}%", }, review: { + feedback: { + send: "发送反馈 ({{count}})", + sending: "正在发送反馈 ({{count}})", + chooseAgent: "选择智能体", + sent: "已向 {{recipient}} 发送反馈", + "no-agents": "请在此工作区打开智能体标签页以发送反馈。", + disconnected: "请连接到主机以发送反馈。", + "no-context": "保存的评论不再匹配此差异。", + failed: "发送反馈失败。请重试。", + prompt: "请处理此代码审查。", + }, comment: { add: "添加 review 评论", edit: "编辑 review 评论", diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index d68e731e56..9e56ef66cd 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { formatShortcut } from "@/utils/format-shortcut"; +import { keyboardEventToComboString } from "./shortcut-string"; import { buildKeyboardShortcutHelpSections, buildEffectiveBindings, + normalizeCapturedShortcutCombo, getBindingIdForAction, getDefaultKeysForAction, getWorkspaceIndexJumpModifierKey, @@ -822,9 +824,9 @@ describe("keyboard-shortcut help sections", () => { expect(rowChord({}, "show-shortcuts")).toEqual([["?"]]); }); - it("replaces the default-only wildcard when the index jump is rebound", () => { + it("keeps the digit range readable when the index jump is rebound", () => { expect(rowChord({ [MAC_INDEX_BINDING]: "Ctrl+Digit" }, "workspace-jump-index")).toEqual([ - ["ctrl", "Digit"], + ["ctrl", "1-9"], ]); }); @@ -1026,6 +1028,90 @@ describe("unassigned shortcuts", () => { }); describe("matching", () => { + it("dispatches a captured Russian letter shortcut on the same key", () => { + const event = keyboardInput({ key: "и", code: "KeyB", ctrlKey: true }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Ctrl+B"); + const bindings = buildEffectiveBindings({ + "sidebar-toggle-left-ctrl-period-non-mac": combo!, + }); + + expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe( + "sidebar.toggle.left", + ); + }); + + it("dispatches a recorded dotted-uppercase letter with Shift held", () => { + const event = keyboardInput({ key: "İ", code: "KeyS", ctrlKey: true, shiftKey: true }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Ctrl+Shift+S"); + const bindings = buildEffectiveBindings({ + "sidebar-toggle-left-ctrl-period-non-mac": combo!, + }); + expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe( + "sidebar.toggle.left", + ); + }); + + it.each(["и", "И", "β", "ش", "ב", "é"])( + "matches default and recorded letter bindings for unsupported letter %s", + (key) => { + const event = keyboardInput({ key, code: "KeyB", ctrlKey: true }); + expectShortcutResolution({ + event, + context: desktopNonMac, + action: "sidebar.toggle.left", + }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Ctrl+B"); + expect( + resolveShortcut({ + event, + context: desktopNonMac, + bindings: buildEffectiveBindings({ + "sidebar-toggle-left-ctrl-period-non-mac": combo!, + }), + }).match?.action, + ).toBe("sidebar.toggle.left"); + }, + ); + + it.each(["v", "†", "Dead", "Unidentified", ""])( + "does not match an unrelated character or named key %s by physical letter", + (key) => { + expectNoShortcutResolution({ + event: { key, code: "KeyB", ctrlKey: true }, + context: desktopNonMac, + }); + }, + ); + + it("does not match non-letter bindings by physical key for unsupported letters", () => { + expectNoShortcutResolution({ + event: { key: "ю", code: "Period", ctrlKey: true }, + context: desktopNonMac, + }); + }); + + it("dispatches a captured Dvorak binding on the same logical key", () => { + const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Cmd+B"); + const bindings = buildEffectiveBindings({ "sidebar-toggle-left-mac-cmd-b": combo! }); + + expect( + resolveShortcut({ event, context: { isMac: true, isDesktop: true }, bindings }).match + ?.action, + ).toBe("sidebar.toggle.left"); + expect( + resolveShortcut({ + event: { key: "x", code: "KeyB", metaKey: true }, + context: { isMac: true, isDesktop: true }, + bindings, + }).match, + ).toBeNull(); + }); + it("stops matching a shortcut the user unassigned", () => { const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO }); @@ -1262,3 +1348,45 @@ describe("direct new-tab target shortcuts", () => { ).toEqual([["ctrl", "shift", "H"]]); }); }); + +describe("number-group shortcut capture", () => { + const tabBindingId = "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop"; + it.each([ + { id: tabBindingId, action: "workspace.tab.navigate.index", row: "workspace-tab-jump-index" }, + { + id: "workspace-navigate-index-cmd-digit-mac", + action: "workspace.navigate.index", + row: "workspace-jump-index", + }, + ])("captures Ctrl+1 as the entire number group for $row", ({ id, action, row }) => { + const combo = normalizeCapturedShortcutCombo(id, "Ctrl+1"); + expect(combo).toBe("Ctrl+Digit"); + const overrides = { [id]: combo }; + const bindings = buildEffectiveBindings(overrides); + for (let index = 1; index <= 9; index++) { + const result = resolveShortcut({ + event: { key: String(index), code: `Digit${index}`, ctrlKey: true }, + context: { isMac: true, isDesktop: true, focusScope: "message-input" }, + bindings, + }); + expect(result.match?.action).toBe(action); + expect(result.match?.payload).toEqual({ index }); + } + expect(resolveShortcutKeysForAction(row, overrides, { isMac: true, isDesktop: true })).toEqual([ + ["ctrl", "1-9"], + ]); + expect( + resolveShortcut({ + event: { key: "0", code: "Digit0", ctrlKey: true }, + context: { isMac: true, isDesktop: true }, + bindings, + }).match, + ).toBeNull(); + }); + it("preserves literal numbers for non-index actions and existing nonnumeric combos", () => { + expect(normalizeCapturedShortcutCombo("show-shortcuts", "Ctrl+1")).toBe("Ctrl+1"); + expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+K")).toBe("Ctrl+K"); + expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+0")).toBe("Ctrl+0"); + expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+9")).toBe("Ctrl+Digit"); + }); +}); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 4a843124f2..a009008b85 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -1210,6 +1210,13 @@ export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] = export type ShortcutOverrides = Record; +/** Keep number-group actions bound to all nine indices when recording a new modifier. */ +export function normalizeCapturedShortcutCombo(bindingId: string, combo: string): string { + const binding = DEFAULT_BINDINGS.find((candidate) => candidate.id === bindingId); + if (binding?.payload?.type !== "index") return combo; + return combo.replace(/(^|\+)[1-9]$/, "$1Digit"); +} + export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShortcutBinding[] { return DEFAULT_BINDINGS.map(function (binding) { const override = overrides[binding.id]; @@ -1285,6 +1292,12 @@ function matchesKeyOrCode(combo: KeyCombo, event: KeyboardShortcutInput): boolea if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) { return true; } + // Recording an unsupported letter (for example Cyrillic и on KeyB) + // stores its physical A-Z key. Dispatch must accept that same key without + // giving Latin layouts such as Dvorak a second physical-key binding. + if (/^[a-z]$/.test(combo.key) && /^\p{Letter}$/u.test(event.key) && !/^[a-z]$/.test(eventKey)) { + return event.code === combo.code; + } // macOS rewrites event.key when Option is held (Option+T -> "†", // Option+[ -> "“"), so Alt-bound letter / bracket bindings can only // match by event.code. Stay key-first for non-Alt bindings so Dvorak diff --git a/packages/app/src/keyboard/shortcut-string.test.ts b/packages/app/src/keyboard/shortcut-string.test.ts index e7cffee597..8d48d5d986 100644 --- a/packages/app/src/keyboard/shortcut-string.test.ts +++ b/packages/app/src/keyboard/shortcut-string.test.ts @@ -22,6 +22,45 @@ function keyboardEvent(overrides: Partial): KeyboardEvent { } describe("keyboardEventToComboString", () => { + it("captures the Dvorak character rather than its QWERTY position", () => { + expect( + keyboardEventToComboString(keyboardEvent({ key: "b", code: "KeyN", metaKey: true })), + ).toBe("Cmd+B"); + }); + + it("captures shifted Dvorak letters and punctuation at other physical positions", () => { + expect( + keyboardEventToComboString( + keyboardEvent({ key: "T", code: "KeyK", metaKey: true, shiftKey: true }), + ), + ).toBe("Shift+Cmd+T"); + expect( + keyboardEventToComboString(keyboardEvent({ key: ";", code: "KeyZ", ctrlKey: true })), + ).toBe("Ctrl+;"); + expect( + keyboardEventToComboString( + keyboardEvent({ key: "?", code: "BracketLeft", ctrlKey: true, shiftKey: true }), + ), + ).toBe("Ctrl+Shift+/"); + }); + + it("keeps named keys and rewritten Option characters capturable", () => { + expect(keyboardEventToComboString(keyboardEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe( + "ArrowLeft", + ); + expect( + keyboardEventToComboString(keyboardEvent({ key: "†", code: "KeyT", altKey: true })), + ).toBe("Alt+T"); + }); + + it("does not record a named key that dispatch does not support", () => { + expect( + keyboardEventToComboString( + keyboardEvent({ key: "Enter", code: "NumpadEnter", ctrlKey: true }), + ), + ).toBeNull(); + }); + it("captures the minus key (regression: previously returned null)", () => { expect(keyboardEventToComboString(keyboardEvent({ key: "-", code: "Minus" }))).toBe("-"); }); diff --git a/packages/app/src/keyboard/shortcut-string.ts b/packages/app/src/keyboard/shortcut-string.ts index 339d623954..6b80009633 100644 --- a/packages/app/src/keyboard/shortcut-string.ts +++ b/packages/app/src/keyboard/shortcut-string.ts @@ -192,6 +192,7 @@ const MODIFIER_CODES = new Set([ * override on a named key renders as a raw code — `ARROWLEFT`, not `←`. */ const DISPLAY_KEY_ALIASES: Record = { + Digit: "1-9", ArrowLeft: "Left", ArrowRight: "Right", ArrowUp: "Up", @@ -266,7 +267,16 @@ export function keyboardEventToComboString(event: KeyboardEvent): string | null if (event.shiftKey) parts.push("Shift"); if (event.metaKey) parts.push("Cmd"); - const humanKey = CODE_TO_KEY[event.code]; + // Capture the same logical character that dispatch matches. Keep the code + // fallback for named keys and Option combinations whose key is rewritten. + const eventKey = event.key.toLowerCase(); + const logicalKey = Object.entries(KEY_MAP).find( + ([, mapping]) => + mapping.key?.length === 1 && + (mapping.key.toLowerCase() === eventKey || + (event.shiftKey && mapping.shiftedKey === event.key)), + )?.[0]; + const humanKey = logicalKey ?? CODE_TO_KEY[event.code]; if (humanKey) { parts.push(humanKey); } else { diff --git a/packages/app/src/lib/overlay-root.browser.test.tsx b/packages/app/src/lib/overlay-root.browser.test.tsx index a21403dd8d..bd20d2aeeb 100644 --- a/packages/app/src/lib/overlay-root.browser.test.tsx +++ b/packages/app/src/lib/overlay-root.browser.test.tsx @@ -2,7 +2,11 @@ import React, { type RefCallback } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { useWebOverlayRegistration } from "./overlay-root"; +import { + hasOpenDomPopover, + keyBelongsToDomPopover, + useWebOverlayRegistration, +} from "./overlay-root"; function nextFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(() => resolve())); @@ -68,3 +72,35 @@ describe("useWebOverlayRegistration in the browser", () => { expect(openerFocusCalls).toBe(1); }); }); + +describe("hasOpenDomPopover", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("is true only while a popover is shown", () => { + const panel = document.createElement("div"); + panel.setAttribute("popover", "manual"); + document.body.append(panel); + expect(hasOpenDomPopover()).toBe(false); + panel.showPopover(); + expect(hasOpenDomPopover()).toBe(true); + panel.hidePopover(); + expect(hasOpenDomPopover()).toBe(false); + }); +}); + +describe("Escape with a plugin popover open", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("leaves other keys to the shortcuts while a popover is open", () => { + const panel = document.createElement("div"); + panel.setAttribute("popover", "manual"); + document.body.append(panel); + panel.showPopover(); + expect(keyBelongsToDomPopover({ key: "Enter" })).toBe(false); + expect(keyBelongsToDomPopover({ key: "Escape" })).toBe(true); + }); +}); diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 60df676d24..35afbb3c11 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -128,6 +128,31 @@ function notifyWebOverlayChanges(): void { for (const listener of webOverlayChangeListeners) listener(); } +/** + * True while an element shown with the Popover API is open. Such a layer + * floats above the app like a registered overlay but lives outside the + * registry: plugins draw their panels this way, since they cannot register. + */ +export function hasOpenDomPopover(): boolean { + if (typeof document === "undefined") return false; + try { + return document.querySelector(":popover-open") !== null; + } catch { + // An engine without the Popover API rejects the selector, and has none open. + return false; + } +} + +/** + * Whether a key belongs to an open popover rather than to the app's shortcuts. + * An open popover (a plugin's panel) is the top layer and closes on its own + * Escape listener, which runs after the shortcut engine's capture listener: + * Escape must not also interrupt the running agent. + */ +export function keyBelongsToDomPopover(event: Pick): boolean { + return event.key === "Escape" && hasOpenDomPopover(); +} + function getFocusableElements(scope: HTMLElement): HTMLElement[] { const selector = [ "a[href]", diff --git a/packages/app/src/panels/agent-facts-line.test.ts b/packages/app/src/panels/agent-facts-line.test.ts new file mode 100644 index 0000000000..182a5ff683 --- /dev/null +++ b/packages/app/src/panels/agent-facts-line.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentFactsLine, type AgentFactsInput } from "./agent-facts-line"; + +const models = [ + { + provider: "claude", + id: "claude-fable-5-1", + label: "Fable 5.1", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, +]; + +const modes = [ + { id: "default", label: "Default" }, + { id: "bypassPermissions", label: "Bypass permissions" }, +]; + +const base: AgentFactsInput = { + models, + runtimeModelId: "claude-fable-5-1", + configuredModelId: null, + runtimeThinkingOptionId: "high", + explicitThinkingOptionId: null, + availableModes: modes, + currentModeId: "bypassPermissions", + contextWindowUsedTokens: 56_200, + contextWindowMaxTokens: 200_000, +}; + +describe("buildAgentFactsLine", () => { + it("joins model, thinking, mode, and context usage as the controls show them", () => { + expect(buildAgentFactsLine(base)).toBe( + "Fable 5.1 · High · Bypass permissions · 56k / 200k tokens (28%)", + ); + }); + + it("shows the raw model id before the catalog has loaded", () => { + expect( + buildAgentFactsLine({ + ...base, + models: null, + runtimeModelId: null, + configuredModelId: "gpt-6.1-sol", + runtimeThinkingOptionId: null, + explicitThinkingOptionId: "medium", + availableModes: [], + contextWindowUsedTokens: null, + contextWindowMaxTokens: null, + }), + ).toBe("gpt-6.1-sol · Medium"); + }); + + it("falls back to the provider's first mode until the agent reports one", () => { + expect( + buildAgentFactsLine({ + ...base, + currentModeId: null, + contextWindowUsedTokens: undefined, + contextWindowMaxTokens: undefined, + }), + ).toBe("Fable 5.1 · High · Default"); + }); + + it("leaves thinking and context out when the agent has none", () => { + expect( + buildAgentFactsLine({ + ...base, + models: [{ provider: "mock", id: "m", label: "Model M" }], + runtimeModelId: "m", + runtimeThinkingOptionId: null, + availableModes: [], + contextWindowUsedTokens: 10, + contextWindowMaxTokens: 0, + }), + ).toBe("Model M"); + }); + + it("is empty until the agent has a model", () => { + expect( + buildAgentFactsLine({ ...base, runtimeModelId: null, configuredModelId: " " }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/panels/agent-facts-line.ts b/packages/app/src/panels/agent-facts-line.ts new file mode 100644 index 0000000000..7d3cd80332 --- /dev/null +++ b/packages/app/src/panels/agent-facts-line.ts @@ -0,0 +1,59 @@ +import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import { formatAgentModeLabel } from "@/agent-controls/labels"; +import { formatTokenCount } from "@/components/context-window-meter.utils"; +import { resolveAgentModelSelection } from "@/composer/agent-controls/utils"; + +export interface AgentFactsInput { + models: AgentModelDefinition[] | null; + runtimeModelId: string | null | undefined; + configuredModelId: string | null | undefined; + runtimeThinkingOptionId: string | null | undefined; + explicitThinkingOptionId: string | null | undefined; + availableModes: AgentMode[]; + currentModeId: string | null | undefined; + contextWindowUsedTokens: number | null | undefined; + contextWindowMaxTokens: number | null | undefined; +} + +/** + * What the composer's controls and context meter show about a Paseo agent, as one line: model, + * thinking, mode, and context usage, each resolved exactly as its control resolves it. Null until + * the agent has a model at all. + */ +export function buildAgentFactsLine(input: AgentFactsInput): string | null { + const hasModel = Boolean(input.runtimeModelId?.trim() || input.configuredModelId?.trim()); + if (!hasModel) return null; + const selection = resolveAgentModelSelection(input); + const parts = [selection.displayModel]; + if (selection.selectedThinkingId) parts.push(selection.displayThinking); + const mode = resolveModeLabel(input.availableModes, input.currentModeId); + if (mode) parts.push(mode); + const context = formatContextUsage(input.contextWindowUsedTokens, input.contextWindowMaxTokens); + if (context) parts.push(context); + return parts.join(" · "); +} + +/** The mode control shows the current mode, or the provider's first one until the agent reports. */ +function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) { + const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0]; + return mode ? formatAgentModeLabel(mode) : null; +} + +/** The context meter's numbers: used of max, with the share it draws as the ring. */ +function formatContextUsage( + usedTokens: number | null | undefined, + maxTokens: number | null | undefined, +): string | null { + if ( + typeof usedTokens !== "number" || + typeof maxTokens !== "number" || + !Number.isFinite(usedTokens) || + !Number.isFinite(maxTokens) || + usedTokens < 0 || + maxTokens <= 0 + ) { + return null; + } + const percentage = Math.round(Math.min(100, (usedTokens / maxTokens) * 100)); + return `${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens (${percentage}%)`; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2d8a605c3f..a9e11df570 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -27,6 +27,11 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { RetainedChatContent } from "./retained-chat-content"; import { Composer } from "@/composer"; +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "@/composer/editing-session"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { resolveComposerTrackControlClearance, @@ -55,6 +60,9 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; import { TimelineSyncStatus } from "@/timeline/sync-status"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; +import { buildAgentFactsLine } from "@/panels/agent-facts-line"; +import { PaneSubtitle } from "@/panels/pane-subtitle"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; import { useHasPluginComposerPills } from "@/plugins"; @@ -1153,6 +1161,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); + // The workspace sheet is for looking; Open as tab brings the agent back with its composer. + const showsComposer = usePaneContext().host !== "sheet"; + // The dock lays out fullscreen for the same session the composer edits in. + const composerEditingSession = useComposerEditingSession("chat"); + const isComposerFullscreen = useComposerFullscreen(composerEditingSession); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const tasks = useSessionStore((state): TodoEntry[] | undefined => state.sessions[serverId]?.agentTasks.get(agentId), @@ -1216,7 +1229,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ composerState, ], ); - const composerSection = ( + const composerSection = showsComposer ? ( - ); + ) : null; const streamContent = ( + {showsComposer ? null : } + {dockContent} {composerSection} {dockOverlay} @@ -1321,13 +1336,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ function ChatSurface({ children, disabled, + fullscreen, }: { children: [ReactNode, ReactNode, ReactNode]; disabled: boolean; + fullscreen: boolean; }) { return ( - {children} + {children} ); } @@ -1362,6 +1379,36 @@ function TimelineSyncErrorCallout({ ); } +/** + * What the composer's controls and context meter would show, as one line, for an agent shown + * without its composer. + */ +function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) { + const agent = useSessionStore( + useShallow((state) => { + const current = state.sessions[serverId]?.agents.get(agentId); + return current + ? { + provider: current.provider, + cwd: current.cwd, + runtimeModelId: current.runtimeInfo?.model ?? null, + configuredModelId: current.model ?? null, + runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null, + explicitThinkingOptionId: current.thinkingOptionId ?? null, + availableModes: current.availableModes, + currentModeId: current.currentModeId, + contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null, + contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null, + } + : null; + }), + ); + const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); + const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null; + const text = agent ? buildAgentFactsLine({ ...agent, models }) : null; + return text ? : null; +} + const AgentStreamSection = memo(function AgentStreamSection({ streamViewRef, serverId, @@ -1471,6 +1518,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd, isSubmitLoading, agentInputDraft, + editingSession, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, @@ -1484,6 +1532,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; + editingSession: ComposerEditingSession; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; @@ -1507,6 +1556,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd={cwd} isSubmitLoading={isSubmitLoading} agentInputDraft={agentInputDraft} + editingSession={editingSession} onAttentionInputFocus={onAttentionInputFocus} onAttentionPromptSend={onAttentionPromptSend} onComposerHeightChange={onComposerHeightChange} @@ -1522,6 +1572,7 @@ function ActiveAgentComposer({ cwd, isSubmitLoading, agentInputDraft, + editingSession, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, @@ -1533,6 +1584,7 @@ function ActiveAgentComposer({ cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; + editingSession: ComposerEditingSession; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; @@ -1629,6 +1681,7 @@ function ActiveAgentComposer({ onChangeAttachments={agentInputDraft.setAttachments} cwd={cwd} clearDraft={agentInputDraft.clear} + editingSession={editingSession} autoFocus autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)} isSubmitLoading={isSubmitLoading} @@ -1721,6 +1774,8 @@ const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", flexShrink: 1, + // Content-sized inline; fills the dock's fullscreen slot. + flexGrow: 1, }, }); diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 962e095b89..86faad329c 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -3,7 +3,7 @@ import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { AgentTaskList } from "@/composer/task-list"; import { ComposerTrackBar } from "@/composer/tracks"; -import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePaneContext } from "@/panels/pane-context"; import { useSettings } from "@/hooks/use-settings"; import { PluginComposerPills } from "@/plugins"; @@ -12,12 +12,13 @@ import { type ArchiveFinishedStatus, useArchiveSubagent, useDetachSubagent, + useSubagentOpensInSheet, type SubagentRow, } from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; import type { TodoEntry } from "@/types/stream"; import { navigateToAgent } from "@/utils/navigate-to-agent"; -import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model"; import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside"; import { openComposerChanges } from "@/workspace-tabs/open-supporting-view"; @@ -49,10 +50,9 @@ export const AgentTracks = memo(function AgentTracks({ onArchiveFinished: () => void; hasPluginComposerPills: boolean; }): ReactElement | null { - const { tabId, openTab } = usePaneContext(); + const { tabId } = usePaneContext(); const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId); const isCompact = useIsCompactFormFactor(); - const canSplit = supportsDesktopPaneSplits() && !isCompact; const openInSidePane = useSettings((settings) => settings.openInSidePane); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const canDetachSubagents = useSessionStore( @@ -60,46 +60,38 @@ export const AgentTracks = memo(function AgentTracks({ ); const archiveSubagent = useArchiveSubagent({ serverId }); const detachSubagent = useDetachSubagent({ serverId }); + const openSubagentTarget = useCallback( + (target: WorkspaceTabTarget) => { + openPreferredWorkspaceTarget({ + workspaceKey, + target, + source: "subagents", + preferences: openInSidePane, + parentTabId: tabId, + }); + }, + [openInSidePane, tabId, workspaceKey], + ); const handleOpenSubagent = useCallback( (subagentId: string) => { const session = useSessionStore.getState().sessions[serverId]; const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId); + // A subagent in another workspace lives in that workspace's tabs. if (agent?.workspaceId && agent.workspaceId !== workspaceId) { navigateToAgent({ serverId, agentId: subagentId }); return; } - if (canSplit && workspaceKey) { - openPreferredWorkspaceTarget({ - isCompact, - workspaceKey, - target: { kind: "agent", agentId: subagentId }, - source: "subagents", - preferences: openInSidePane, - parentTabId: tabId, - }); - return; - } - navigateToAgent({ serverId, agentId: subagentId }); + openSubagentTarget({ kind: "agent", agentId: subagentId }); }, - [canSplit, isCompact, openInSidePane, serverId, tabId, workspaceId, workspaceKey], + [openSubagentTarget, serverId, workspaceId], ); const handleOpenProviderSubagent = useCallback( (parentAgentId: string, subagentId: string) => { - if (canSplit && workspaceKey) { - openPreferredWorkspaceTarget({ - isCompact, - workspaceKey, - target: { kind: "provider_subagent", parentAgentId, subagentId }, - source: "subagents", - preferences: openInSidePane, - parentTabId: tabId, - }); - return; - } - openTab({ kind: "provider_subagent", parentAgentId, subagentId }); + openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId }); }, - [canSplit, isCompact, openInSidePane, openTab, tabId, workspaceKey], + [openSubagentTarget], ); + const opensInSheet = useSubagentOpensInSheet(workspaceKey); const handleOpenChanges = useCallback(() => { if (!workspaceKey) { return; @@ -132,6 +124,7 @@ export const AgentTracks = memo(function AgentTracks({ rows={subagentRows} onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} + opensInSheet={opensInSheet} onArchiveSubagent={archiveSubagent} onArchiveFinished={onArchiveFinished} archiveFinishedStatus={archiveFinishedStatus} diff --git a/packages/app/src/panels/pane-subtitle.tsx b/packages/app/src/panels/pane-subtitle.tsx new file mode 100644 index 0000000000..eb5292d67b --- /dev/null +++ b/packages/app/src/panels/pane-subtitle.tsx @@ -0,0 +1,30 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * One muted line of facts about a pane's agent, under the pane's header and above its stream: + * model, thinking, tokens. Provider subagents get it from the daemon; a Paseo agent shown without + * its composer gets it from the same facts the composer's controls show. + */ +export function PaneSubtitle({ text, testID }: { text: string; testID?: string }) { + return ( + + + {text} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + text: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/panels/panel-instance-attributes.ts b/packages/app/src/panels/panel-instance-attributes.ts index 6ef49b0e4e..6fb414943c 100644 --- a/packages/app/src/panels/panel-instance-attributes.ts +++ b/packages/app/src/panels/panel-instance-attributes.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { usePaneContext } from "@/panels/pane-context"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; export interface PanelInstanceIdentity { serverId: string; @@ -18,8 +19,20 @@ const listenersByPanel = new Map void>>(); const allListeners = new Set<() => void>(); let attributesRevision = 0; +function buildWorkspacePanelKey(workspaceKey: string, tabId: string): string { + return `${workspaceKey}:${tabId}`; +} + export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string { - return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`; + return buildWorkspacePanelKey(buildWorkspaceTabPersistenceKey(identity) ?? "", identity.tabId); +} + +/** Looks a panel up by the workspace key the layout store uses. */ +export function getWorkspacePanelAttributes( + workspaceKey: string, + tabId: string, +): PanelInstanceAttributes { + return attributesByPanel.get(buildWorkspacePanelKey(workspaceKey, tabId)) ?? DEFAULT_ATTRIBUTES; } export function getPanelInstanceAttributes( diff --git a/packages/app/src/panels/panel-manifest.test.ts b/packages/app/src/panels/panel-manifest.test.ts index c06a87864d..0766003f84 100644 --- a/packages/app/src/panels/panel-manifest.test.ts +++ b/packages/app/src/panels/panel-manifest.test.ts @@ -18,6 +18,14 @@ describe("panel manifest", () => { expect(panelSupportsHost("setup", "explorer")).toBe(false); }); + it("supports the sheet exactly for kinds with a compact sheet rule", () => { + expect(panelSupportsHost("file", "sheet")).toBe(true); + expect(panelSupportsHost("agent", "sheet")).toBe(true); + expect(panelSupportsHost("provider_subagent", "sheet")).toBe(true); + expect(panelSupportsHost("terminal", "sheet")).toBe(false); + expect(panelSupportsHost("plugin", "sheet")).toBe(false); + }); + it("lets navigation tabs hide their close button without losing their close action", () => { expect(getPanelManifest("files").showCloseButton).toBe(false); expect(getPanelManifest("changes_tree").showCloseButton).toBe(false); diff --git a/packages/app/src/panels/panel-manifest.ts b/packages/app/src/panels/panel-manifest.ts index 4122da80f1..90fdf1931e 100644 --- a/packages/app/src/panels/panel-manifest.ts +++ b/packages/app/src/panels/panel-manifest.ts @@ -1,12 +1,38 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; -export type PaneHost = "main" | "explorer"; +/** Where a tab renders: a split pane, the Explorer dock, or the compact workspace sheet. */ +export type PaneHost = "main" | "explorer" | "sheet"; + +/** How a target behaves while it is shown in the compact workspace sheet. */ +export interface CompactSheetPolicy { + /** The sheet offers Open as tab, which moves the same tab into the focused pane. */ + openAsTab: boolean; +} + +/** Facts about a target that live outside the layout, resolved lazily by the workspace. */ +export interface PanelTargetFacts { + isWorkspaceRootAgent(agentId: string): boolean; +} + +type CompactSheetRule = + | CompactSheetPolicy + | null + | (( + target: Extract, + facts: PanelTargetFacts, + ) => CompactSheetPolicy | null); export interface PanelManifest { kind: K; - supportedHosts: readonly PaneHost[]; + /** The sheet is never listed: a kind renders there exactly when it has a `compactSheet` rule. */ + supportedHosts: readonly Exclude[]; showCloseButton: boolean; singleton: boolean; + /** + * On compact layouts, revealing this target opens it in the workspace sheet over the current + * tab instead of adding a tab. `null` keeps it in the tab strip. + */ + compactSheet: CompactSheetRule; resourceKey(target: Extract): string; } @@ -20,6 +46,7 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: null, resourceKey: () => "new_tab", }, draft: { @@ -27,6 +54,7 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: null, resourceKey: (target) => target.draftId, }, agent: { @@ -34,6 +62,8 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: (target, facts) => + facts.isWorkspaceRootAgent(target.agentId) ? null : { openAsTab: true }, resourceKey: (target) => target.agentId, }, provider_subagent: { @@ -41,6 +71,7 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: { openAsTab: false }, resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`, }, terminal: { @@ -48,6 +79,7 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: null, resourceKey: (target) => target.terminalId, }, browser: { @@ -55,6 +87,7 @@ const manifests = { supportedHosts: ["main"], showCloseButton: true, singleton: false, + compactSheet: null, resourceKey: (target) => target.browserId, }, changes_tree: { @@ -62,6 +95,7 @@ const manifests = { supportedHosts: ["explorer"], showCloseButton: false, singleton: true, + compactSheet: null, resourceKey: () => "changes_tree", }, files: { @@ -69,6 +103,7 @@ const manifests = { supportedHosts: ["explorer"], showCloseButton: false, singleton: true, + compactSheet: null, resourceKey: () => "files", }, pull_request: { @@ -76,6 +111,7 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: null, resourceKey: () => "pull_request", }, file: { @@ -83,6 +119,7 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: { openAsTab: false }, resourceKey: (target) => target.path, }, working_diff: { @@ -90,6 +127,7 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: null, resourceKey: () => "working_diff", }, plugin: { @@ -98,6 +136,7 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: null, resourceKey: (target) => target.context === "agent" ? `${target.pluginId}:${target.panelId}:agent:${target.agentId}` @@ -108,6 +147,7 @@ const manifests = { supportedHosts: ["main"], showCloseButton: true, singleton: false, + compactSheet: null, resourceKey: (target) => target.workspaceId, }, commit_diff: { @@ -115,6 +155,7 @@ const manifests = { supportedHosts: ["main", "explorer"], showCloseButton: true, singleton: false, + compactSheet: null, resourceKey: (target) => target.sha, }, } satisfies PanelManifestByKind; @@ -124,7 +165,17 @@ export function getPanelManifest(kind: K): } export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHost): boolean { - return getPanelManifest(kind).supportedHosts.includes(host); + const manifest = getPanelManifest(kind); + if (host === "sheet") return manifest.compactSheet !== null; + return manifest.supportedHosts.includes(host); +} + +export function panelCompactSheetPolicy( + target: WorkspaceTabTarget, + facts: PanelTargetFacts, +): CompactSheetPolicy | null { + const rule = getPanelManifest(target.kind).compactSheet as CompactSheetRule; + return typeof rule === "function" ? rule(target as never, facts) : rule; } export function panelCanLaunchInPane( diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 6c2d482403..ecd4a082bf 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -13,9 +13,11 @@ import { ComposerTrackBar } from "@/composer/tracks"; import { useIsCompactFormFactor } from "@/constants/layout"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; import { usePaneContext } from "@/panels/pane-context"; +import { PaneSubtitle } from "@/panels/pane-subtitle"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; -import { useSubagentsForParent } from "@/subagents/select"; +import { type SubagentRow, useSubagentsForParent } from "@/subagents/select"; +import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet"; import { SubagentsTrack } from "@/subagents/track"; import { providerSubagentKey, @@ -30,6 +32,7 @@ import type { StreamItem } from "@/types/stream"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import type { TurnPresentation } from "@/timeline/turn-liveness"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; const EMPTY_PERMISSIONS = new Map(); const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -47,10 +50,12 @@ function ProviderSubagentChildTrack({ serverId, rows, onOpenProviderSubagent, + opensInSheet, }: { serverId: string; rows: ReturnType; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + opensInSheet: (row: SubagentRow) => boolean; }) { if (rows.length === 0) return null; return ( @@ -60,6 +65,7 @@ function ProviderSubagentChildTrack({ rows={rows} onOpenSubagent={NOOP_SUBAGENT} onOpenProviderSubagent={onOpenProviderSubagent} + opensInSheet={opensInSheet} onArchiveSubagent={NOOP_SUBAGENT} /> @@ -109,7 +115,7 @@ function useProviderSubagentDescriptor( function ProviderSubagentPanel() { const { t } = useTranslation(); - const { serverId, target, openFileInWorkspace, openTab } = usePaneContext(); + const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext(); invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId); const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`; @@ -137,6 +143,9 @@ function ProviderSubagentPanel() { providerParentSubagentId: target.subagentId, }); const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact); + const opensInSheet = useSubagentOpensInSheet( + buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + ); const openProviderChild = useCallback( (parentAgentId: string, subagentId: string) => { openTab({ kind: "provider_subagent", parentAgentId, subagentId }); @@ -243,17 +252,7 @@ function ProviderSubagentPanel() { return ( - {subtitle ? ( - - - {subtitle} - - - ) : null} + {subtitle ? : null} ); @@ -280,16 +280,6 @@ function ProviderSubagentPanel() { const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0 }, - subtitleHeader: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - subtitleText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, })); diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index f2f9997749..378965cd8f 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -7,9 +7,11 @@ import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { CompactHeader } from "@/components/headers/compact-header"; import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenTitle } from "@/components/headers/screen-title"; import { HostFilter } from "@/components/hosts/host-filter"; +import type { HostPickerHost } from "@/components/hosts/host-picker"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; @@ -87,19 +89,14 @@ function PluginHostFilter({ pluginId, identity, params, - serverIds, + hosts, }: { serverId: string; pluginId: string; identity: PluginSurfaceContributionIdentity; params: PluginScreenParams; - serverIds: string[]; + hosts: HostPickerHost[]; }) { - const allHosts = useHosts(); - const hosts = useMemo( - () => allHosts.filter((host) => serverIds.includes(host.serverId)), - [allHosts, serverIds], - ); const selectHost = useCallback( (nextServerId: string) => { // A legacy row remembers its own host; a screen's host carries to all the plugin's items. @@ -113,9 +110,6 @@ function PluginHostFilter({ }, [identity, params, pluginId], ); - const show = serverIds.length > 1 && hosts.length > 1; - if (!show) return null; - return ( host.serverId === serverId)?.label ?? serverId; - const contributionServerIds = useMemo( - () => - identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [], - [identity, installations, pluginId], - ); + // The hosts this contribution is installed on; the filter shows when there is a choice. + const filterHosts = useMemo(() => { + if (!identity) return []; + const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity); + return hosts.filter((candidate) => serverIds.includes(candidate.serverId)); + }, [hosts, identity, installations, pluginId]); const title = useMemo( () => surface @@ -187,18 +182,23 @@ export function PluginSurfaceScreen() { ), [Icon, title], ); + const hostFilter = useMemo( + () => + identity && filterHosts.length > 1 ? ( + + ) : null, + [filterHosts, identity, params, pluginId, serverId], + ); const headerRight = useMemo( () => ( <> - {identity ? ( - - ) : null} + {hostFilter} ), - [close, contributionServerIds, identity, params, pluginId, serverId], + [close, hostFilter], ); return ( - + {compact ? ( + + ) : ( + + )} {plugin && surface && client ? ( { + const entry = ts.createSourceFile( + "index.ts", + readFileSync(new URL("../../index.ts", import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + ); + const firstImport = entry.statements.find(ts.isImportDeclaration); + expect(firstImport?.moduleSpecifier.getText(entry)).toBe('"./src/polyfills/install-early"'); + const early = ts.createSourceFile( + "install-early.ts", + readFileSync(new URL("./install-early.ts", import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + ); + expect( + early.statements.some( + (statement) => + ts.isExpressionStatement(statement) && + ts.isCallExpression(statement.expression) && + statement.expression.expression.getText(early) === "polyfillNavigator", + ), + ).toBe(true); +}); + +it("initializes the native navigator before evaluating the real terminal dependency", () => { + const require = createRequire(import.meta.url); + const terminalSource = readFileSync(require.resolve("@xterm/headless"), "utf8"); + const navigatorSource = ts.transpileModule( + readFileSync(new URL("./navigator.ts", import.meta.url), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }, + ).outputText; + // Each VM owns its globals, so the native navigator shape never replaces + // globals in the test runner or needs a native-module mock. + const uninitialized = createContext({ + navigator: {}, + window: {}, + module: { exports: {} }, + exports: {}, + }); + expect(() => runInContext(terminalSource, uninitialized)).toThrow(/includes/); + + const initialized = createContext({ + navigator: {}, + window: {}, + module: { exports: {} }, + exports: {}, + }); + runInContext(navigatorSource, initialized); + runInContext("exports.polyfillNavigator()", initialized); + expect(() => runInContext(terminalSource, initialized)).not.toThrow(); +}); diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 690bd439af..6c13ad3d83 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -9,6 +9,7 @@ import { buildSelectedTriggerLabel, filterAndRankModelRows, matchesModelSearch, + resolveEffectiveComposerModeId, resolveSelectedModelLabel, resolveSubmissionReadiness, } from "./provider-selection"; @@ -441,3 +442,56 @@ function getAllModelLabels(providers: ReturnType { + const selection = { + provider: "pi", + modelId: "", + thinkingOptionId: "", + availableModels: [], + }; + + it("drops a remembered mode when the provider offers no modes", () => { + expect( + resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, false), + ).toBe(""); + }); + + it("submits the mode the picker shows when the remembered mode is not offered", () => { + expect( + resolveEffectiveComposerModeId( + { + ...selection, + modeId: "plan", + modeOptions: [ + { id: "build", label: "Build" }, + { id: "ask", label: "Ask" }, + ], + }, + false, + ), + ).toBe("build"); + }); + + it("keeps a selected mode the provider offers", () => { + expect( + resolveEffectiveComposerModeId( + { + ...selection, + modeId: "ask", + modeOptions: [ + { id: "build", label: "Build" }, + { id: "ask", label: "Ask" }, + ], + }, + false, + ), + ).toBe("ask"); + }); + + it("keeps the selected mode while the provider's modes are still loading", () => { + expect( + resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, true), + ).toBe("build"); + }); +}); diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index ae00dd9528..f66a1b1476 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -272,6 +272,23 @@ export function resolveEffectiveComposerModelId(selection: ProviderSelectionStat ); } +// The mode picker shows the first offered mode when the selected one isn't +// offered (e.g. a remembered mode the provider no longer advertises), so the +// submitted mode must match that display. A provider with no modes gets none. +// While the provider is still loading its modes aren't known yet, so the +// selected mode is kept. +export function resolveEffectiveComposerModeId( + selection: ProviderSelectionState, + isModelLoading: boolean, +): string { + const selectedModeId = selection.modeId.trim(); + if (isModelLoading) { + return selectedModeId; + } + const modeIds = selection.modeOptions.map((mode) => mode.id); + return modeIds.includes(selectedModeId) ? selectedModeId : (modeIds[0] ?? ""); +} + export function resolveEffectiveComposerThinkingOptionId( selection: ProviderSelectionState, effectiveModelId: string, diff --git a/packages/app/src/review/comment-sheet.tsx b/packages/app/src/review/comment-sheet.tsx new file mode 100644 index 0000000000..d54ec0ec96 --- /dev/null +++ b/packages/app/src/review/comment-sheet.tsx @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { FormTextInput } from "@/components/ui/form-field"; +import { Button } from "@/components/ui/button"; +import type { InlineReviewActions, InlineReviewEditorState } from "./geometry"; + +export function ReviewCommentSheet({ + editor, + reviewActions, + onChangeBody, + enabled, +}: { + enabled: boolean | undefined; + editor: InlineReviewEditorState | null; + reviewActions: InlineReviewActions; + onChangeBody: (body: string) => void; +}) { + if (enabled === false || !editor) return null; + return ( + + ); +} + +function OpenReviewCommentSheet({ + editor, + reviewActions, + onChangeBody, +}: { + editor: InlineReviewEditorState; + reviewActions: InlineReviewActions; + onChangeBody: (body: string) => void; +}) { + const { t } = useTranslation(); + const context = `${editor.target.filePath} · ${editor.target.side === "old" ? "−" : "+"}${editor.target.lineNumber}`; + const header = useMemo( + () => ({ + title: t("review.comment.label"), + subtitle: {context}, + }), + [context, t], + ); + const { onSaveEditor, onCancelEditor } = reviewActions; + const handleSave = useCallback(() => onSaveEditor(editor.body), [onSaveEditor, editor.body]); + const canSave = editor.body.trim().length > 0; + const footer = useMemo( + () => ( + + + + + ), + [canSave, handleSave, onCancelEditor, t], + ); + return ( + + + + ); +} + +const COMMENT_SNAP_POINTS = ["50%", "90%"]; + +const styles = StyleSheet.create((theme) => ({ + context: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, + input: { flex: 1, minHeight: 0, textAlignVertical: "top" }, + actions: { flex: 1, flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2] }, +})); diff --git a/packages/app/src/review/editor/index.test.ts b/packages/app/src/review/editor/index.test.ts new file mode 100644 index 0000000000..6735adef34 --- /dev/null +++ b/packages/app/src/review/editor/index.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { createReviewEditor, createReviewEditorScope, type ReviewCommentWriter } from "./index"; +import type { ReviewDraftComment } from "../state"; +import type { ReviewableDiffTarget } from "@/utils/diff-layout"; + +const target: ReviewableDiffTarget = { + key: "src/example.ts:new:2", + filePath: "src/example.ts", + side: "new", + lineNumber: 2, + hunkHeader: "@@ -1,2 +1,2 @@", + hunkIndex: 0, + lineIndex: 2, + oldLineNumber: null, + newLineNumber: 2, + lineType: "add", + content: "const value = next;", +}; + +function memoryWriter() { + const drafts = new Map(); + let sequence = 0; + const writer: ReviewCommentWriter = { + saveComment: ({ key, id, comment }) => { + const current = drafts.get(key) ?? []; + if (id && current.some((saved) => saved.id === id)) { + drafts.set( + key, + current.map((saved) => (saved.id === id ? { ...saved, ...comment } : saved)), + ); + } else { + const saved = { + ...comment, + id: String(++sequence), + createdAt: "2026-10-09T00:00:00Z", + updatedAt: "2026-10-09T00:00:00Z", + }; + drafts.set(key, [...current, saved]); + } + }, + deleteComment: ({ key, id }) => { + drafts.set( + key, + (drafts.get(key) ?? []).filter((comment) => comment.id !== id), + ); + }, + }; + return { writer, comments: (key: string) => drafts.get(key) ?? [] }; +} + +describe("review editor", () => { + it("saves trimmed comments, edits the same record, and cancels without altering saved comments", () => { + const storage = memoryWriter(); + const editor = createReviewEditor("first", storage.writer); + editor.start(target); + editor.setBody(" First comment "); + editor.save(editor.getState()!.body); + expect(editor.getState()).toBeNull(); + const saved = storage.comments("first")[0]!; + expect(saved).toMatchObject({ + filePath: target.filePath, + side: "new", + lineNumber: 2, + body: "First comment", + }); + editor.edit(target, saved); + editor.setBody("Discarded edit"); + editor.cancel(); + expect(storage.comments("first")).toEqual([saved]); + editor.edit(target, saved); + editor.save(" Updated comment "); + expect(storage.comments("first")).toEqual([{ ...saved, body: "Updated comment" }]); + editor.start(target); + editor.setBody("Discarded new comment"); + editor.cancel(); + expect(storage.comments("first")).toHaveLength(1); + }); + + it("keeps an open edit saveable after its submitted record is acknowledged", () => { + const storage = memoryWriter(); + const editor = createReviewEditor("first", storage.writer); + editor.start(target); + editor.save("Submitted body"); + const saved = storage.comments("first")[0]!; + editor.edit(target, saved); + editor.setBody("Typed before acknowledgement"); + storage.writer.deleteComment({ key: "first", id: saved.id }); + expect(editor.getState()!.body).toBe("Typed before acknowledgement"); + editor.save(editor.getState()!.body); + expect(storage.comments("first")).toMatchObject([ + { + body: "Typed before acknowledgement", + filePath: target.filePath, + lineNumber: target.lineNumber, + }, + ]); + }); + + it("rejects empty saves and repeated saves after the editor closes", () => { + const storage = memoryWriter(); + const editor = createReviewEditor("first", storage.writer); + editor.start(target); + editor.save(" \n "); + expect(editor.getState()).toEqual({ target, commentId: null, body: "" }); + expect(storage.comments("first")).toEqual([]); + editor.save("Saved once"); + editor.save("Duplicate"); + expect(storage.comments("first").map((comment) => comment.body)).toEqual(["Saved once"]); + }); + + it("isolates draft ownership and closes an editor when its saved comment is deleted", () => { + const storage = memoryWriter(); + const first = createReviewEditor("first", storage.writer); + const second = createReviewEditor("second", storage.writer); + first.start(target); + first.save("First workspace"); + const saved = storage.comments("first")[0]!; + first.edit(target, saved); + expect(second.getState()).toBeNull(); + second.start(target); + second.save("Second workspace"); + first.delete(saved.id); + expect(first.getState()).toBeNull(); + expect(storage.comments("first")).toEqual([]); + expect(storage.comments("second").map((comment) => comment.body)).toEqual(["Second workspace"]); + }); +}); + +it("retains an open editor through remounts without exposing it on another surface or draft", async () => { + const storage = memoryWriter(); + const scope = createReviewEditorScope(storage.writer); + const editor = scope.forDraft("first", "diff"); + const unsubscribe = editor.subscribe(() => {}); + editor.start(target); + editor.setBody("Unsaved wide body"); + unsubscribe(); + await Promise.resolve(); + const compact = scope.forDraft("first", "diff"); + expect(compact.getState()!.body).toBe("Unsaved wide body"); + expect(scope.forDraft("first", "combined").getState()).toBeNull(); + expect(scope.forDraft("second", "diff").getState()).toBeNull(); + compact.save(compact.getState()!.body); + expect(storage.comments("first")[0]!.body).toBe("Unsaved wide body"); + expect(scope.forDraft("first", "diff").getState()).toBeNull(); +}); diff --git a/packages/app/src/review/editor/index.ts b/packages/app/src/review/editor/index.ts new file mode 100644 index 0000000000..6a7741eceb --- /dev/null +++ b/packages/app/src/review/editor/index.ts @@ -0,0 +1,84 @@ +import type { ReviewableDiffTarget } from "@/utils/diff-layout"; +import type { InlineReviewEditorState } from "../geometry"; +import type { ReviewDraftComment } from "../state"; + +export interface ReviewCommentWriter { + saveComment: (input: { + key: string; + id: string | null; + comment: Pick; + }) => void; + deleteComment: (input: { key: string; id: string }) => void; +} + +/** One editor belongs to one review draft; replacing the draft creates a fresh editor. */ +export function createReviewEditor(key: string, writer: ReviewCommentWriter) { + return buildReviewEditor(key, writer, () => {}); +} + +function buildReviewEditor(key: string, writer: ReviewCommentWriter, onUnused: () => void) { + let editor: InlineReviewEditorState | null = null; + const listeners = new Set<() => void>(); + function publish(next: InlineReviewEditorState | null) { + editor = next; + listeners.forEach((listener) => listener()); + if (!editor && listeners.size === 0) onUnused(); + } + return { + getState: () => editor, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + queueMicrotask(() => { + if (!editor && listeners.size === 0) onUnused(); + }); + }; + }, + start: (target: ReviewableDiffTarget) => publish({ target, commentId: null, body: "" }), + edit: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => + publish({ target, commentId: comment.id, body: comment.body }), + setBody: (body: string) => { + if (editor) publish({ ...editor, body }); + }, + cancel: () => publish(null), + save: (body: string) => { + const trimmedBody = body.trim(); + if (!editor || !trimmedBody) return; + writer.saveComment({ + key, + id: editor.commentId, + comment: { + filePath: editor.target.filePath, + side: editor.target.side, + lineNumber: editor.target.lineNumber, + body: trimmedBody, + }, + }); + publish(null); + }, + delete: (id: string) => { + writer.deleteComment({ key, id }); + if (editor?.commentId === id) publish(null); + }, + }; +} + +/** An open draft survives presentation remounts; each presentation owns its editor for that draft. */ +export function createReviewEditorScope(writer: ReviewCommentWriter) { + const editors = new Map>(); + return { + forDraft(key: string, presentation: string) { + const owner = JSON.stringify([key, presentation]); + let model = editors.get(owner); + if (!model) { + const created = buildReviewEditor(key, writer, () => { + if (editors.get(owner) === created) editors.delete(owner); + }); + model = created; + editors.set(owner, model); + } + return model; + }, + }; +} diff --git a/packages/app/src/review/feedback/index.test.ts b/packages/app/src/review/feedback/index.test.ts new file mode 100644 index 0000000000..b423855b75 --- /dev/null +++ b/packages/app/src/review/feedback/index.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "vitest"; +import { + createReviewFeedback, + createReviewFeedbackScope, + type ReviewFeedbackPorts, + type ReviewFeedbackSnapshot, + type FeedbackAttachment, + type FeedbackRecipient, +} from "./index"; +import type { ReviewDraftComment } from "../state"; + +function makeComment(id: string, body = id): ReviewDraftComment { + return { + id, + body, + filePath: "sample.ts", + side: "new", + lineNumber: 2, + createdAt: "2026-10-09T00:00:00Z", + updatedAt: "2026-10-09T00:00:00Z", + }; +} +function makeRecipient(agentId: string): FeedbackRecipient { + return { + agentId, + tab: { + key: `agent_${agentId}`, + tabId: `agent_${agentId}`, + kind: "agent", + target: { kind: "agent", agentId }, + }, + }; +} +function makeAttachment(comments: readonly ReviewDraftComment[]): FeedbackAttachment | null { + if (!comments.length) return null; + const line = { + type: "add" as const, + content: "const value = 1", + oldLineNumber: null, + newLineNumber: 2, + }; + return { + kind: "review", + reviewDraftKey: "draft", + commentCount: comments.length, + attachment: { + type: "review", + mimeType: "application/paseo-review", + cwd: "/workspace", + mode: "uncommitted", + baseRef: null, + comments: comments.map((comment) => ({ + filePath: comment.filePath, + side: comment.side, + lineNumber: comment.lineNumber, + body: comment.body, + context: { hunkHeader: "@@ -1 +1 @@", targetLine: line, lines: [line] }, + })), + }, + }; +} +function setup(agentIds: string[], unavailableIds: string[] = [], create = createReviewFeedback) { + let comments = [makeComment("first")]; + let recipients = agentIds.map(makeRecipient); + let connected = true; + let rejectSend: ((error: Error) => void) | null = null; + let resolveSend: (() => void) | null = null; + const sends: Array<{ recipient: FeedbackRecipient; attachment: FeedbackAttachment }> = []; + const read = (): ReviewFeedbackSnapshot => ({ + comments, + recipients, + connected, + attachment: makeAttachment(comments.filter((comment) => !unavailableIds.includes(comment.id))), + }); + const ports: ReviewFeedbackPorts = { + read, + send: (recipient, attachment) => { + sends.push({ recipient, attachment }); + return new Promise((resolve, reject) => { + resolveSend = resolve; + rejectSend = reject; + }); + }, + clearSent: (sent) => { + comments = comments.filter( + (current) => + !sent.some( + (prior) => + prior.id === current.id && + prior.updatedAt === current.updatedAt && + prior.body === current.body, + ), + ); + }, + failedMessage: () => "Failed to send", + }; + const model = create(ports); + return { + model, + ports, + sends, + comments: () => comments, + setComments: (next: ReviewDraftComment[]) => { + comments = next; + model.refresh(); + }, + setRecipients: (ids: string[]) => { + recipients = ids.map(makeRecipient); + model.refresh(); + }, + disconnect: () => { + connected = false; + model.refresh(); + }, + succeed: () => resolveSend!(), + fail: () => rejectSend!(new Error("Host rejected feedback")), + }; +} + +describe("review feedback", () => { + it("counts only attached comments, freezes the sending count, and retains orphans", async () => { + const feedback = setup(["one"], ["orphan"]); + feedback.setComments([makeComment("first"), makeComment("orphan")]); + expect(feedback.model.getState()).toMatchObject({ commentCount: 2, sendableCommentCount: 1 }); + const pending = feedback.model.press(); + feedback.setComments([makeComment("first"), makeComment("orphan"), makeComment("added")]); + expect(feedback.model.getState()).toMatchObject({ + phase: { kind: "sending", commentCount: 1 }, + sendableCommentCount: 2, + }); + expect(feedback.sends[0]!.attachment.commentCount).toBe(1); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([makeComment("orphan"), makeComment("added")]); + }); + it("retains an entirely orphaned draft without claiming any comments are sendable", async () => { + const feedback = setup(["one"], ["first"]); + expect(feedback.model.getState()).toMatchObject({ + commentCount: 1, + sendableCommentCount: 0, + disabledReason: "no-context", + canSend: false, + }); + await feedback.model.press(); + expect(feedback.sends).toEqual([]); + expect(feedback.comments()).toEqual([makeComment("first")]); + }); + it("retains comments and explains why no workspace agent can receive them", async () => { + const feedback = setup([]); + await feedback.model.press(); + expect(feedback.model.getState()).toMatchObject({ + canSend: false, + disabledReason: "no-agents", + commentCount: 1, + }); + expect(feedback.sends).toEqual([]); + expect(feedback.comments()).toEqual([makeComment("first")]); + }); + it("sends once to the only recipient and clears only sent versions", async () => { + const feedback = setup(["one"]); + const pending = feedback.model.press(); + await feedback.model.press(); + await feedback.model.send("one"); + expect(feedback.sends).toEqual([ + { recipient: makeRecipient("one"), attachment: makeAttachment([makeComment("first")]) }, + ]); + feedback.setComments([makeComment("first", "Edited during send"), makeComment("added")]); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([ + makeComment("first", "Edited during send"), + makeComment("added"), + ]); + expect(feedback.model.getState().phase).toEqual({ + kind: "sent", + recipient: makeRecipient("one"), + }); + }); + it("clears unchanged sent comments while preserving additions", async () => { + const feedback = setup(["one"]); + const pending = feedback.model.press(); + feedback.setComments([makeComment("first"), makeComment("added")]); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([makeComment("added")]); + }); + it("asks for a recipient when several tabs exist and validates the selection again", async () => { + const feedback = setup(["one", "two"]); + await feedback.model.press(); + expect(feedback.model.getState().phase).toEqual({ kind: "choosing" }); + expect(feedback.sends).toEqual([]); + feedback.setRecipients(["one"]); + await feedback.model.send("two"); + expect(feedback.sends).toEqual([]); + const pending = feedback.model.send("one"); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([]); + }); + it("retains comments on failure and retries using the latest saved version", async () => { + const feedback = setup(["one"]); + const failed = feedback.model.press(); + feedback.fail(); + await failed; + expect(feedback.model.getState().phase).toEqual({ + kind: "failed", + message: "Host rejected feedback", + }); + expect(feedback.comments()).toEqual([makeComment("first")]); + feedback.setComments([makeComment("first", "Corrected before retry")]); + const retried = feedback.model.press(); + feedback.succeed(); + await retried; + expect(feedback.sends).toHaveLength(2); + expect(feedback.sends[1]!.attachment.attachment.comments[0]!.body).toBe( + "Corrected before retry", + ); + expect(feedback.comments()).toEqual([]); + }); + it("keeps workspace drafts independent and prevents disconnected sends", async () => { + const first = setup(["one"]); + const second = setup(["other"]); + first.disconnect(); + await first.model.press(); + expect(first.model.getState().disabledReason).toBe("disconnected"); + const pending = second.model.press(); + second.succeed(); + await pending; + expect(first.comments()).toEqual([makeComment("first")]); + expect(first.sends).toEqual([]); + expect(second.comments()).toEqual([]); + }); +}); + +describe("shared review submission ownership", () => { + it("blocks another surface and reconnects to the pending draft while other drafts remain usable", async () => { + const scope = createReviewFeedbackScope(); + const first = setup(["one"], [], (ports) => scope.connect("workspace-one", ports)); + const second = scope.connect("workspace-one", first.ports); + const other = setup(["other"], [], (ports) => scope.connect("workspace-other", ports)); + const unsubscribe = first.model.subscribe(() => {}); + const pending = first.model.press(); + unsubscribe(); + await Promise.resolve(); + const reconnected = scope.connect("workspace-one", first.ports); + expect(second.getState()).toMatchObject({ canSend: false, phase: { kind: "sending" } }); + await second.press(); + await reconnected.send("one"); + expect(first.sends).toHaveLength(1); + const otherPending = other.model.press(); + expect(other.sends).toHaveLength(1); + first.fail(); + await pending; + expect(second.getState().phase).toEqual({ kind: "failed", message: "Host rejected feedback" }); + const retry = second.press(); + first.succeed(); + await retry; + expect(first.sends).toHaveLength(2); + other.succeed(); + await otherPending; + }); + it("opens only the active surface's recipient chooser", async () => { + const scope = createReviewFeedbackScope(); + const first = setup(["one", "two"], [], (ports) => scope.connect("same", ports)); + const second = scope.connect("same", first.ports); + await first.model.press(); + expect(first.model.getState().phase).toEqual({ kind: "choosing" }); + expect(second.getState().phase).toEqual({ kind: "idle" }); + first.model.closeMenu(); + expect(first.model.getState().phase).toEqual({ kind: "idle" }); + }); +}); diff --git a/packages/app/src/review/feedback/index.ts b/packages/app/src/review/feedback/index.ts new file mode 100644 index 0000000000..69e0592fe8 --- /dev/null +++ b/packages/app/src/review/feedback/index.ts @@ -0,0 +1,177 @@ +import type { ComposerAttachment } from "@/attachments/types"; +import type { ReviewDraftComment } from "../state"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +export interface FeedbackRecipient { + agentId: string; + tab: WorkspaceTabDescriptor; +} +export type FeedbackAttachment = Extract; +export interface ReviewFeedbackSnapshot { + comments: readonly ReviewDraftComment[]; + attachment: FeedbackAttachment | null; + recipients: readonly FeedbackRecipient[]; + connected: boolean; +} +export interface ReviewFeedbackPorts { + read: () => ReviewFeedbackSnapshot; + send: (recipient: FeedbackRecipient, attachment: FeedbackAttachment) => Promise; + clearSent: (comments: readonly ReviewDraftComment[]) => void; + failedMessage: () => string; +} +export type FeedbackPhase = + | { kind: "idle" } + | { kind: "choosing" } + | { kind: "sending"; recipient: FeedbackRecipient; commentCount: number } + | { kind: "failed"; message: string } + | { kind: "sent"; recipient: FeedbackRecipient }; +export interface ReviewFeedbackState { + phase: FeedbackPhase; + commentCount: number; + sendableCommentCount: number; + recipients: readonly FeedbackRecipient[]; + disabledReason: "no-agents" | "disconnected" | "no-context" | null; + canSend: boolean; +} + +export function createReviewFeedback(ports: ReviewFeedbackPorts) { + return createFeedbackView(ports, createFeedbackWorkflow()); +} + +/** Surfaces share submission ownership while keeping their chooser and live inputs local. */ +export function createReviewFeedbackScope() { + const workflows = new Map>(); + return { + connect(key: string, ports: ReviewFeedbackPorts) { + let workflow = workflows.get(key); + if (!workflow) { + const created = createFeedbackWorkflow(() => { + if (workflows.get(key) === created) workflows.delete(key); + }); + workflow = created; + workflows.set(key, workflow); + } + return createFeedbackView(ports, workflow); + }, + }; +} + +function createFeedbackWorkflow(onUnused: () => void = () => {}) { + let phase: FeedbackPhase = { kind: "idle" }; + const observers = new Set<() => void>(); + function releaseIfUnused() { + queueMicrotask(() => { + if (observers.size === 0 && phase.kind !== "sending") onUnused(); + }); + } + return { + getPhase: () => phase, + changePhase(next: FeedbackPhase) { + phase = next; + observers.forEach((observer) => observer()); + releaseIfUnused(); + }, + subscribe(observer: () => void) { + observers.add(observer); + return () => { + observers.delete(observer); + releaseIfUnused(); + }; + }, + }; +} + +function createFeedbackView( + ports: ReviewFeedbackPorts, + workflow: ReturnType, +) { + let choosing = false; + const choosingPhase: FeedbackPhase = { kind: "choosing" }; + const getPhase = () => (choosing ? choosingPhase : workflow.getPhase()); + let state = deriveState(ports.read(), getPhase()); + const listeners = new Set<() => void>(); + function publish() { + state = deriveState(ports.read(), getPhase()); + listeners.forEach((listener) => listener()); + } + async function send(agentId: string) { + const snapshot = ports.read(); + const current = deriveState(snapshot, workflow.getPhase()); + const recipient = snapshot.recipients.find((candidate) => candidate.agentId === agentId); + if (!current.canSend || !recipient || !snapshot.attachment) return; + const attachment = snapshot.attachment; + const sentComments = snapshot.comments.filter((comment) => + attachment.attachment.comments.some( + (sent) => + sent.filePath === comment.filePath && + sent.side === comment.side && + sent.lineNumber === comment.lineNumber && + sent.body === comment.body, + ), + ); + choosing = false; + workflow.changePhase({ kind: "sending", recipient, commentCount: attachment.commentCount }); + publish(); + try { + await ports.send(recipient, attachment); + ports.clearSent(sentComments); + workflow.changePhase({ kind: "sent", recipient }); + } catch (error) { + workflow.changePhase({ + kind: "failed", + message: error instanceof Error ? error.message : ports.failedMessage(), + }); + } + publish(); + } + return { + getState: () => { + if (state.phase !== getPhase()) state = deriveState(ports.read(), getPhase()); + return state; + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + const unsubscribe = workflow.subscribe(() => { + choosing = false; + publish(); + }); + return () => { + listeners.delete(listener); + unsubscribe(); + }; + }, + refresh: publish, + press: async () => { + const snapshot = ports.read(); + if (!deriveState(snapshot, workflow.getPhase()).canSend) return; + if (snapshot.recipients.length === 1) { + await send(snapshot.recipients[0]!.agentId); + } else { + choosing = true; + publish(); + } + }, + closeMenu: () => { + if (choosing) { + choosing = false; + publish(); + } + }, + send, + }; +} + +function deriveState(snapshot: ReviewFeedbackSnapshot, phase: FeedbackPhase): ReviewFeedbackState { + let disabledReason: ReviewFeedbackState["disabledReason"] = null; + if (snapshot.recipients.length === 0) disabledReason = "no-agents"; + else if (!snapshot.connected) disabledReason = "disconnected"; + else if (!snapshot.attachment) disabledReason = "no-context"; + return { + phase, + commentCount: snapshot.comments.length, + sendableCommentCount: snapshot.attachment?.commentCount ?? 0, + recipients: snapshot.recipients, + disabledReason, + canSend: snapshot.comments.length > 0 && disabledReason === null && phase.kind !== "sending", + }; +} diff --git a/packages/app/src/review/feedback/surface.tsx b/packages/app/src/review/feedback/surface.tsx new file mode 100644 index 0000000000..7f87f46d88 --- /dev/null +++ b/packages/app/src/review/feedback/surface.tsx @@ -0,0 +1,284 @@ +import { useCallback, useMemo, type ReactNode } from "react"; +import { Text, View } from "react-native"; +import { Send } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native-unistyles"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { useMenuContext } from "@/components/ui/menu"; +import { Button } from "@/components/ui/button"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { SPACING } from "@/styles/theme"; +import { FloatingActionButton } from "@/components/ui/floating-action-button"; +import { + WorkspaceTabPresentationResolver, + WorkspaceTabIcon, + type WorkspaceTabPresentation, +} from "@/screens/workspace/workspace-tab-presentation"; +import type { FeedbackRecipient, ReviewFeedbackState, createReviewFeedback } from "./index"; + +type FeedbackModel = ReturnType; +interface ReviewFeedbackActionProps { + bottomOffset: number; + model: FeedbackModel; + state: ReviewFeedbackState; + serverId: string; + workspaceId: string; +} + +export function ReviewFeedbackAction({ + enabled, + hasDiff, + ...props +}: Omit & { + workspaceId?: string | null; + enabled: boolean | undefined; + hasDiff: boolean; +}) { + if (enabled === false || !hasDiff) return null; + return ; +} + +function OpenReviewFeedbackAction({ + model, + state, + serverId, + workspaceId, + bottomOffset, +}: ReviewFeedbackActionProps) { + const { t } = useTranslation(); + const { bottom } = useSafeAreaInsets(); + const isCompact = useIsCompactFormFactor(); + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) model.closeMenu(); + }, + [model], + ); + const noteStyle = useMemo( + () => [ + styles.note, + !isCompact && styles.centered, + { + bottom: + bottom + + bottomOffset + + (isCompact ? 88 : buttonControlHeight.sm + SPACING[4] + SPACING[2]), + }, + ], + [bottom, bottomOffset, isCompact], + ); + const sending = state.phase.kind === "sending"; + const label = t(sending ? "review.feedback.sending" : "review.feedback.send", { + count: state.phase.kind === "sending" ? state.phase.commentCount : state.sendableCommentCount, + }); + const unavailableNotice = + state.disabledReason && state.commentCount > 0 ? ( + {t(`review.feedback.${state.disabledReason}`)} + ) : null; + let notice: ReactNode = unavailableNotice; + if (state.phase.kind === "failed") + notice = {state.phase.message}; + else if (state.phase.kind === "sent") + notice = ( + <> + + {unavailableNotice} + + ); + return ( + <> + {state.commentCount > 0 ? ( + + + + {state.recipients.map((recipient) => ( + + ))} + + + ) : null} + {notice ? ( + + {notice} + + ) : null} + + ); +} + +function FeedbackTrigger({ + model, + label, + disabled, + bottomInset, +}: { + model: FeedbackModel; + label: string; + disabled: boolean; + bottomInset: number; +}) { + const { triggerRef } = useMenuContext("FeedbackTrigger"); + const isCompact = useIsCompactFormFactor(); + const press = useCallback(() => { + void model.press(); + }, [model]); + if (!isCompact) { + return ( + + + + ); + } + return ( + + ); +} + +function RecipientOption({ + recipient, + model, + serverId, + workspaceId, +}: { + recipient: FeedbackRecipient; + model: FeedbackModel; + serverId: string; + workspaceId: string; +}) { + const choose = useCallback(() => { + void model.send(recipient.agentId); + }, [model, recipient.agentId]); + const render = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + ), + [choose], + ); + return ( + + {render} + + ); +} + +function RecipientRow({ + presentation, + onSelect, +}: { + presentation: WorkspaceTabPresentation; + onSelect: () => void; +}) { + const leading = useMemo( + () => , + [presentation], + ); + return ( + + {presentation.label} + + ); +} + +function SentNotice({ + recipient, + serverId, + workspaceId, +}: { + recipient: FeedbackRecipient; + serverId: string; + workspaceId: string; +}) { + const { t } = useTranslation(); + const render = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + {t("review.feedback.sent", { recipient: presentation.label })} + + ), + [t], + ); + return ( + + {render} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + wideAction: { position: "absolute", left: 0, right: 0, alignItems: "center" }, + centered: { alignItems: "center" }, + note: { + position: "absolute", + right: theme.spacing[4], + left: theme.spacing[4], + alignItems: "flex-end", + }, + text: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, +})); diff --git a/packages/app/src/review/feedback/use-feedback.ts b/packages/app/src/review/feedback/use-feedback.ts new file mode 100644 index 0000000000..e2b151953e --- /dev/null +++ b/packages/app/src/review/feedback/use-feedback.ts @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { + useReviewDraftComments, + getReviewDraftComments, + clearSentReviewDraftComments, +} from "../store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useAppSettings } from "@/hooks/use-settings"; +import { dispatchComposerAgentMessage } from "@/composer/actions"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; +import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store"; +import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { SPACING } from "@/styles/theme"; +import { i18n } from "@/i18n/i18next"; +import { + createReviewFeedbackScope, + type FeedbackAttachment, + type FeedbackRecipient, +} from "./index"; + +const feedbackScope = createReviewFeedbackScope(); + +export function useReviewFeedback(input: { + reviewDraftKey: string; + serverId: string; + workspaceId?: string; + attachment: FeedbackAttachment | null; +}) { + const { serverId, reviewDraftKey, workspaceId } = input; + const isCompact = useIsCompactFormFactor(); + const workspaceKey = workspaceId + ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) + : null; + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const { settings } = useAppSettings(); + const comments = useReviewDraftComments(reviewDraftKey); + const layout = useWorkspaceLayoutStore((store) => + workspaceKey ? store.layoutByWorkspace[workspaceKey] : undefined, + ); + // The adapter keeps live host and diff inputs available to commands without reconstructing the workflow. + const live = useRef({ + client, + connected, + attachment: input.attachment, + sendBehavior: settings.sendBehavior, + }); + live.current = { + client, + connected, + attachment: input.attachment, + sendBehavior: settings.sendBehavior, + }; + const model = useMemo( + () => + feedbackScope.connect(reviewDraftKey, { + read: () => ({ + comments: getReviewDraftComments(reviewDraftKey) ?? [], + attachment: live.current.attachment, + connected: live.current.connected, + recipients: readRecipients(workspaceKey), + }), + send: async (recipient, attachment) => { + const { client: targetClient, sendBehavior } = live.current; + if (!targetClient) throw new Error(i18n.t("workspace.terminal.hostDisconnected")); + const activeTurnBehavior = sendBehavior === "steer" ? "steer" : "interrupt"; + await dispatchComposerAgentMessage({ + client: targetClient, + agentId: recipient.agentId, + text: i18n.t("review.feedback.prompt"), + attachments: [attachment], + encodeImages: async () => [], + submission: createMessageSubmissionWriter(serverId), + activeTurnBehavior, + activeTurnId: + activeTurnBehavior === "steer" + ? (selectAgentTurnPresentation( + useSessionStore.getState().sessions[serverId], + recipient.agentId, + ).turnId ?? undefined) + : undefined, + }); + }, + clearSent: (sentComments) => + clearSentReviewDraftComments({ key: reviewDraftKey, comments: sentComments }), + failedMessage: () => i18n.t("review.feedback.failed"), + }), + [reviewDraftKey, serverId, workspaceKey], + ); + useEffect(() => { + model.refresh(); + }, [model, comments, layout, connected, input.attachment]); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + return { + model, + state, + clearance: + state.commentCount > 0 + ? (isCompact ? FLOATING_ACTION_BUTTON_CLEARANCE : buttonControlHeight.sm + SPACING[4]) + 44 + : 0, + }; +} + +function readRecipients(workspaceKey: string | null): FeedbackRecipient[] { + const tabs = workspaceKey + ? useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey) + : []; + const descriptors = deriveWorkspaceTabModel({ tabs }).tabs; + const recipients: FeedbackRecipient[] = []; + for (const { descriptor } of descriptors) { + if (descriptor.target.kind === "agent") + recipients.push({ agentId: descriptor.target.agentId, tab: descriptor }); + } + return recipients; +} diff --git a/packages/app/src/review/geometry.ts b/packages/app/src/review/geometry.ts index 819c1d29b1..46bbf88ecc 100644 --- a/packages/app/src/review/geometry.ts +++ b/packages/app/src/review/geometry.ts @@ -19,6 +19,7 @@ export interface InlineReviewActions { onStartComment: (target: ReviewableDiffTarget) => void; onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void; onCancelEditor: () => void; + onChangeEditorBody: (body: string) => void; onSaveEditor: (body: string) => void; onDeleteComment: (id: string) => void; } diff --git a/packages/app/src/review/index.ts b/packages/app/src/review/index.ts index 014447090b..308f955259 100644 --- a/packages/app/src/review/index.ts +++ b/packages/app/src/review/index.ts @@ -31,3 +31,8 @@ export { SMALL_ACTION_HIT_SLOP, useInlineReviewController, } from "./surface"; + +export { ReviewCommentSheet } from "./comment-sheet"; + +export { useReviewFeedback } from "./feedback/use-feedback"; +export { ReviewFeedbackAction } from "./feedback/surface"; diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index 344b28b61d..222a3f57f3 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest"; import type { StateStorage } from "zustand/middleware"; import type { ParsedDiffFile } from "@/git/use-diff-query"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; -import { buildReviewAttachmentSnapshot, buildReviewDraftKey } from "./store"; +import { + buildReviewAttachmentSnapshot, + buildReviewDraftKey, + addReviewDraftComment, + saveReviewDraftComment, + clearSentReviewDraftComments, + getReviewDraftComments, + resetReviewDraftStore, + useReviewDraftStore, +} from "./store"; import { addCommentToState, clearReviewInState, @@ -304,3 +313,49 @@ describe("buildReviewAttachmentSnapshot", () => { }); }); }); + +describe("successful feedback cleanup", () => { + it("clears sent versions only, preserving edits, additions and other workspace drafts", () => { + resetReviewDraftStore(); + const first = addReviewDraftComment({ key: "first", comment: makeComment() }); + const edited = addReviewDraftComment({ + key: "first", + comment: makeComment({ id: "edited", body: "Before send" }), + }); + const other = addReviewDraftComment({ key: "other", comment: makeComment({ id: "other" }) }); + useReviewDraftStore.getState().updateComment({ + key: "first", + id: edited.id, + updates: { body: "After send" }, + updatedAt: edited.updatedAt, + }); + const added = addReviewDraftComment({ key: "first", comment: makeComment({ id: "added" }) }); + clearSentReviewDraftComments({ key: "first", comments: [first, edited] }); + expect(getReviewDraftComments("first")).toEqual([{ ...edited, body: "After send" }, added]); + expect(getReviewDraftComments("other")).toEqual([other]); + resetReviewDraftStore(); + }); +}); + +it("saves an acknowledged open edit as a new version and keeps subsequent edits on that record", () => { + resetReviewDraftStore(); + const submitted = addReviewDraftComment({ key: "first", comment: makeComment() }); + clearSentReviewDraftComments({ key: "first", comments: [submitted] }); + saveReviewDraftComment({ + key: "first", + id: submitted.id, + comment: { ...submitted, body: "Open edit after acknowledgement" }, + }); + const restored = getReviewDraftComments("first")![0]!; + expect(restored.body).toBe("Open edit after acknowledgement"); + expect(restored.id).not.toBe(submitted.id); + clearSentReviewDraftComments({ key: "first", comments: [submitted] }); + expect(getReviewDraftComments("first")).toEqual([restored]); + saveReviewDraftComment({ + key: "first", + id: restored.id, + comment: { ...restored, body: "Saved again" }, + }); + expect(getReviewDraftComments("first")).toMatchObject([{ id: restored.id, body: "Saved again" }]); + resetReviewDraftStore(); +}); diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index bac1659751..614572190a 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -320,3 +320,43 @@ export function useReviewAttachmentSnapshot(input: { [comments, input.key, input.cwd, input.mode, input.baseRef, input.diffFiles], ); } + +/** Acknowledgement removes only the versions included in that submission. */ +export function clearSentReviewDraftComments(input: { + key: string; + comments: readonly ReviewDraftComment[]; +}): void { + const sentById = new Map(input.comments.map((comment) => [comment.id, comment])); + useReviewDraftStore.setState((state) => { + const current = state.drafts[input.key]; + if (!current) return state; + const remaining = current.filter((comment) => { + const sent = sentById.get(comment.id); + return !sent || sent.updatedAt !== comment.updatedAt || sent.body !== comment.body; + }); + if (remaining.length === current.length) return state; + return { drafts: { ...state.drafts, [input.key]: remaining } }; + }); +} + +/** Acknowledgement can remove the original while the user still has an edit open. */ +export function saveReviewDraftComment(input: { + key: string; + id: string | null; + comment: Pick; +}): void { + const store = useReviewDraftStore.getState(); + if (input.id && store.drafts[input.key]?.some((comment) => comment.id === input.id)) { + store.updateComment({ key: input.key, id: input.id, updates: { body: input.comment.body } }); + } else { + store.addComment({ + key: input.key, + comment: { + filePath: input.comment.filePath, + side: input.comment.side, + lineNumber: input.comment.lineNumber, + body: input.comment.body, + }, + }); + } +} diff --git a/packages/app/src/review/surface.test.tsx b/packages/app/src/review/surface.test.tsx index e23f321d38..925ebe6b24 100644 --- a/packages/app/src/review/surface.test.tsx +++ b/packages/app/src/review/surface.test.tsx @@ -9,45 +9,24 @@ import { useReviewDraftStore, type ReviewDraftComment } from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { getInlineReviewThreadState, - getInlineReviewThreadViewportStyle, getSplitInlineReviewThreadState, + type InlineReviewActions, +} from "./geometry"; +import { + getInlineReviewThreadViewportStyle, groupInlineReviewCommentsByTarget, InlineReviewEditor, InlineReviewGutterCell, InlineReviewThread, SMALL_ACTION_HIT_SLOP, useInlineReviewController, - type InlineReviewActions, -} from "./index"; +} from "./surface"; void testI18n; -const { theme, pressablePropsByLabel } = vi.hoisted(() => { +const { pressablePropsByLabel } = vi.hoisted(() => { Object.assign(globalThis, { __DEV__: false }); - return { - theme: { - spacing: { 1: 4, 2: 8, 3: 12 }, - borderWidth: { 1: 1 }, - borderRadius: { base: 4, md: 6, lg: 8, xl: 12, full: 999 }, - opacity: { 50: 0.5 }, - fontSize: { xs: 11, sm: 13 }, - fontWeight: { normal: "400", medium: "500" }, - lineHeight: { diff: 18 }, - colors: { - accent: "#0a84ff", - accentForeground: "#fff", - border: "#555", - destructive: "#ff453a", - foreground: "#fff", - foregroundMuted: "#aaa", - surface1: "#111", - surface2: "#222", - surface3: "#333", - palette: { white: "#fff" }, - }, - }, - pressablePropsByLabel: new Map>(), - }; + return { pressablePropsByLabel: new Map>() }; }); vi.mock("react-native", async (importOriginal) => { @@ -88,13 +67,16 @@ vi.mock("react-native", async (importOriginal) => { }; }); -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), - }, - withUnistyles: (component: T) => component, - useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), -})); +vi.mock("react-native-unistyles", async () => { + const { darkTheme: theme } = await import("@/styles/theme"); + return { + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + withUnistyles: (component: T) => component, + useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), + }; +}); vi.mock("@/constants/platform", () => ({ getIsElectron: () => false, @@ -103,19 +85,6 @@ vi.mock("@/constants/platform", () => ({ isWeb: true, })); -vi.mock("lucide-react-native", () => { - const createIcon = (name: string) => (props: Record) => - React.createElement("span", { ...props, "data-icon": name }); - return { - Check: createIcon("Check"), - CircleDot: createIcon("CircleDot"), - Pencil: createIcon("Pencil"), - Plus: createIcon("Plus"), - Trash2: createIcon("Trash2"), - X: createIcon("X"), - }; -}); - function target(overrides: Partial = {}): ReviewableDiffTarget { return { filePath: "src/example.ts", @@ -147,6 +116,7 @@ function buildReviewActions(overrides: Partial = {}): Inlin onStartComment: vi.fn(), onEditComment: vi.fn(), onCancelEditor: vi.fn(), + onChangeEditorBody: () => {}, onSaveEditor: vi.fn(), onDeleteComment: vi.fn(), ...overrides, @@ -312,8 +282,9 @@ describe("git diff inline review helpers", () => { it("keeps the line number visible and only floats the plus for line hover", () => { const reviewTarget = target(); - const { container, queryByText, rerender } = render( + const { queryByTestId, queryByText, rerender } = render( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + expect(queryByTestId("add-comment-glyph")).toBeNull(); rerender( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + expect(queryByTestId("add-comment-glyph")).toBeNull(); rerender( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + expect(queryByTestId("add-comment-glyph")).toBeNull(); rerender( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeTruthy(); + expect(queryByTestId("add-comment-glyph")).toBeTruthy(); }); }); @@ -384,7 +358,7 @@ describe("InlineReviewEditor", () => { const onCancel = vi.fn(); const onSave = vi.fn(); const { getByTestId } = render( - { const onCancel = vi.fn(); const onSave = vi.fn(); const { getByTestId } = render( - { removeEventListener: vi.fn(), }); const { getByTestId, queryByText } = render( - { expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1"); }); }); + +function ReviewEditorFixture({ + initialBody, + ...props +}: Omit, "body" | "onChangeBody"> & { + initialBody: string; +}) { + const [body, onChangeBody] = React.useState(initialBody); + return ; +} diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index 43b6af914a..7466052456 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -1,4 +1,11 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Pencil, Plus, Trash2 } from "lucide-react-native"; @@ -17,10 +24,17 @@ import { EditingTextInput as TextInput, type EditingTextInputHandle, } from "@/components/ui/text-input"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { createReviewEditorScope } from "./editor"; import { isWeb } from "@/constants/platform"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { Theme } from "@/styles/theme"; -import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; +import { + useReviewDraftComments, + useReviewDraftStore, + saveReviewDraftComment, + type ReviewDraftComment, +} from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { INLINE_REVIEW_EDITOR_HEIGHT, @@ -98,92 +112,42 @@ export function groupInlineReviewCommentsByTarget( return grouped; } -export function useInlineReviewController(input: { reviewDraftKey: string }): InlineReviewActions { +const reviewEditors = createReviewEditorScope({ + saveComment: saveReviewDraftComment, + deleteComment: (input) => useReviewDraftStore.getState().deleteComment(input), +}); + +export function useInlineReviewController(input: { + reviewDraftKey: string; + presentation?: string; +}): InlineReviewActions & { + sheetEditor: InlineReviewEditorState | null; + onChangeEditorBody: (body: string) => void; +} { + const isCompact = useIsCompactFormFactor(); const reviewComments = useReviewDraftComments(input.reviewDraftKey); const commentsByTarget = useMemo( () => groupInlineReviewCommentsByTarget(reviewComments), [reviewComments], ); - const [editor, setEditor] = useState(null); - const addComment = useReviewDraftStore((state) => state.addComment); - const updateComment = useReviewDraftStore((state) => state.updateComment); - const deleteComment = useReviewDraftStore((state) => state.deleteComment); - - useEffect(() => { - setEditor(null); - }, [input.reviewDraftKey]); - - const handleStartComment = useCallback((target: ReviewableDiffTarget) => { - setEditor({ target, commentId: null, body: "" }); - }, []); - - const handleEditComment = useCallback( - (target: ReviewableDiffTarget, comment: ReviewDraftComment) => { - setEditor({ target, commentId: comment.id, body: comment.body }); - }, - [], + const model = useMemo( + () => reviewEditors.forDraft(input.reviewDraftKey, input.presentation ?? "diff"), + [input.reviewDraftKey, input.presentation], ); - - const handleCancelEditor = useCallback(() => { - setEditor(null); - }, []); - - const handleSaveEditor = useCallback( - (body: string) => { - const trimmedBody = body.trim(); - if (!editor || trimmedBody.length === 0) { - return; - } - - if (editor.commentId) { - updateComment({ - key: input.reviewDraftKey, - id: editor.commentId, - updates: { body: trimmedBody }, - }); - } else { - addComment({ - key: input.reviewDraftKey, - comment: { - filePath: editor.target.filePath, - side: editor.target.side, - lineNumber: editor.target.lineNumber, - body: trimmedBody, - }, - }); - } - setEditor(null); - }, - [addComment, editor, input.reviewDraftKey, updateComment], - ); - - const handleDeleteComment = useCallback( - (id: string) => { - deleteComment({ key: input.reviewDraftKey, id }); - setEditor((current) => (current?.commentId === id ? null : current)); - }, - [deleteComment, input.reviewDraftKey], - ); - - return useMemo( + const editor = useSyncExternalStore(model.subscribe, model.getState, model.getState); + return useMemo( () => ({ commentsByTarget, - editor, - onStartComment: handleStartComment, - onEditComment: handleEditComment, - onCancelEditor: handleCancelEditor, - onSaveEditor: handleSaveEditor, - onDeleteComment: handleDeleteComment, + editor: isCompact ? null : editor, + sheetEditor: isCompact ? editor : null, + onChangeEditorBody: model.setBody, + onStartComment: model.start, + onEditComment: model.edit, + onCancelEditor: model.cancel, + onSaveEditor: model.save, + onDeleteComment: model.delete, }), - [ - commentsByTarget, - editor, - handleCancelEditor, - handleDeleteComment, - handleEditComment, - handleSaveEditor, - handleStartComment, - ], + [commentsByTarget, editor, isCompact, model], ); } @@ -321,7 +285,8 @@ export function InlineReviewThread({ const editorElement = editor ? ( void; onCancel: () => void; onSave: (body: string) => void; testID?: string; }) { const { t } = useTranslation(); const inputRef = useRef(null); - const [body, setBody] = useState(initialBody); const [isFocused, setIsFocused] = useState(false); const trimmedBody = body.trim(); const canSave = trimmedBody.length > 0; @@ -506,7 +472,7 @@ export function InlineReviewEditor({ placeholderTextColor={styles.placeholderColor.color} multiline initialValue={body} - onChangeText={setBody} + onChangeText={onChangeBody} onFocus={handleFocus} onBlur={handleBlur} style={inputStyle} diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 22c6c4f040..2dea552239 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -31,8 +31,13 @@ import { subscriptionFixture } from "../subscription-fixture"; type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; +type WorkspaceScriptsResult = Awaited>; class FakeDirectoryClient { + supportsBackgroundWorkspaces(): boolean { + return false; + } + supportsWorkspaceLabels = false; listWorkspaceLabelsCalls = 0; fetchAgentsCalls = 0; @@ -177,6 +182,14 @@ class FakeDirectoryClient { }; } + listWorkspaceScriptsCalls = 0; + workspaceScripts: WorkspaceScriptsResult["scripts"] = []; + + async listWorkspaceScripts(workspaceId: string): Promise { + this.listWorkspaceScriptsCalls += 1; + return { requestId: "scripts", workspaceId, scripts: this.workspaceScripts, error: null }; + } + getLastServerInfoMessage() { return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null; } @@ -478,6 +491,52 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it("keeps a workspace's script status live without demanding the directory", async () => { + const serverId = "script-status-demand"; + const { client, directory } = createDirectory(serverId); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + const workspace = normalizeWorkspaceDescriptor({ + ...createWorkspaceEntry("workspace-1"), + scripts: [], + }); + directory.acceptWorkspaces([workspace]); + const script = { + scriptName: "worker", + type: "script" as const, + hostname: "worker.paseo.localhost", + port: null, + proxyUrl: null, + lifecycle: "running" as const, + health: null, + exitCode: null, + terminalId: "terminal-1", + }; + const lifecycle = () => + useSessionStore.getState().sessions[serverId]?.workspaces.get(workspace.id)?.scripts[0] + ?.lifecycle; + client.workspaceScripts = [script]; + const source = {}; + + directory.setScriptStatusDemand(source, workspace.id); + + await expect.poll(lifecycle).toBe("running"); + client.emit({ + type: "script_status_update", + payload: { workspaceId: workspace.id, scripts: [{ ...script, lifecycle: "stopped" }] }, + }); + expect(lifecycle()).toBe("stopped"); + expect(client.fetchAgentsCalls).toBe(0); + expect(client.fetchWorkspacesCalls).toBe(0); + + directory.setScriptStatusDemand(source, null); + client.emit({ + type: "script_status_update", + payload: { workspaceId: workspace.id, scripts: [script] }, + }); + expect(lifecycle()).toBe("stopped"); + directory.dispose(); + }); + it("subscribes to live agent updates when the directory is demanded", async () => { const serverId = "demanded-directory-subscription"; const { client, directory } = createDirectory(serverId); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 0f10d7f893..28b217fea6 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -150,6 +150,7 @@ export class DirectorySync { private agentSubscription: OwnedSubscription | null = null; private workspaceSubscription: OwnedSubscription | null = null; private eventSubscription: ReturnType | null = null; + private scriptStatusSubscription: ReturnType | null = null; private readonly abortSessionWaits = new Set<() => void>(); private cacheLoad: Promise | null = null; private cacheAccepted = false; @@ -157,6 +158,7 @@ export class DirectorySync { private workspaceRevision = 0; private readonly routeDemandIds = new Set(); private readonly fullDemandSources = new Set(); + private readonly scriptStatusDemand = new Map(); private demandRefresh: Promise | null = null; private satisfiedDemandSource: DirectorySourceToken | null = null; private cursors: DirectoryCheckpoint = {}; @@ -189,10 +191,12 @@ export class DirectorySync { } this.flushAbortedTransactions(); this.releaseSubscriptions(); + this.releaseScriptStatusEvents(); workspaceLabels.disconnect(this.serverId); this.connection = connection; this.abortPendingSessionWaits(); if (!connection.client || connection.status !== "online") return true; + if (this.scriptStatusDemand.size > 0) this.observeScriptStatusEvents(); // Reattach labels here because route-only demand can satisfy the epoch before full demand requests them. void this.connectWorkspaceLabels().catch(() => undefined); if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined); @@ -226,10 +230,32 @@ export class DirectorySync { } } + /** + * Keeps one workspace's script status live without demanding the directory: a + * script_status_update subscription, plus one scripts fetch to cover what changed before it. + */ + setScriptStatusDemand(source: object, workspaceId: string | null): void { + if (!workspaceId) { + this.scriptStatusDemand.delete(source); + if (!this.wantsScriptStatus()) this.releaseScriptStatusEvents(); + return; + } + this.scriptStatusDemand.set(source, workspaceId); + if (!this.getOnlineConnection()) return; + // A new subscription fetches every demanded workspace once its snapshot lands. + if (!this.scriptStatusSubscription) this.observeScriptStatusEvents(); + else + void this.scriptStatusSubscription.ready + .then(() => this.fetchScriptStatus(workspaceId)) + .catch(() => undefined); + } + dispose(): void { this.flushAbortedTransactions(); this.abortPendingSessionWaits(); + this.scriptStatusDemand.clear(); this.releaseSubscriptions(); + this.releaseScriptStatusEvents(); this.fullDemandSources.clear(); this.routeDemandIds.clear(); workspaceLabels.disconnect(this.serverId); @@ -247,6 +273,12 @@ export class DirectorySync { this.workspaceSubscription = null; this.eventSubscription = null; this.satisfiedDemandSource = null; + if (!this.wantsScriptStatus()) this.releaseScriptStatusEvents(); + } + + private releaseScriptStatusEvents(): void { + void this.scriptStatusSubscription?.release().catch(() => undefined); + this.scriptStatusSubscription = null; } private receiveAgentDelta(source: DirectorySourceToken, delta: AgentDirectoryDelta): void { @@ -276,22 +308,51 @@ export class DirectorySync { private observeDirectoryEvents(): void { if (this.eventSubscription) return; const { client, source } = this.requireOnline(); - this.eventSubscription = client.observeEvents(["project.update", "script_status_update"]); + this.eventSubscription = client.observeEvents(["project.update"]); this.eventSubscription.subscribe({ snapshot: () => {}, update: (message) => { if (!this.isCurrent(client, source)) return; if (message.type === "project.update") this.receiveWorkspaceDelta(source, message.payload); - if (message.type === "script_status_update") - this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload }); }, }); } + // Script status has its own subscription so a workspace screen can hold it without the directory. + private observeScriptStatusEvents(): void { + if (this.scriptStatusSubscription) return; + const { client, source } = this.requireOnline(); + this.scriptStatusSubscription = client.observeEvents(["script_status_update"]); + this.scriptStatusSubscription.subscribe({ + snapshot: () => { + for (const workspaceId of new Set(this.scriptStatusDemand.values())) + void this.fetchScriptStatus(workspaceId).catch(() => undefined); + }, + update: (message) => { + if (!this.isCurrent(client, source) || message.type !== "script_status_update") return; + this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload }); + }, + }); + } + + private async fetchScriptStatus(workspaceId: string): Promise { + const { client, source } = this.requireOnline(); + const result = await client.listWorkspaceScripts(workspaceId); + if (!this.isCurrent(client, source) || result.error || !result.scripts) return; + this.receiveWorkspaceDelta(source, { + kind: "script_status", + update: { workspaceId, scripts: result.scripts }, + }); + } + private hasDemand(): boolean { return this.fullDemandSources.size > 0 || this.routeDemandIds.size > 0; } + private wantsScriptStatus(): boolean { + return this.hasDemand() || this.scriptStatusDemand.size > 0; + } + private requestDemandRefresh(force = false): Promise { if (this.demandRefresh) return this.demandRefresh; if (!this.hasDemand()) return Promise.resolve(); @@ -299,6 +360,7 @@ export class DirectorySync { return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve(); } this.observeDirectoryEvents(); + this.observeScriptStatusEvents(); const source = this.connection.source; if ( !force && @@ -535,6 +597,7 @@ export class DirectorySync { let subscribe = initialSubscribe; while (true) { const query: Parameters[0] = { + ...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}), sort: [{ key: "activity_at", direction: "desc" }], page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, ...(supportsDirectorySync @@ -634,6 +697,7 @@ export class DirectorySync { const limit = input.page?.limit ?? PAGE_LIMIT; const query: Omit = { ...(input.filter ? { filter: input.filter } : { scope: "active" as const }), + ...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}), sort: AGENT_SORT, page: cursor ? { limit, cursor } : { limit }, ...(!input.filter && cursor === null && this.supportsDirectorySync() diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0954b0e422..61285eb2c6 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -52,6 +52,9 @@ it("requests the managed connection credential through desktop main without a we }); class FakeDaemonClient { + supportsBackgroundWorkspaces(): boolean { + return false; + } private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); private error: string | null = null; @@ -3047,6 +3050,34 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => { + const host = makeHost({ serverId: "srv_steer_queue_drain" }); + const fakeClient = new FakeDaemonClient(); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_steer_queue_drain", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setQueuedMessages( + host.serverId, + new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + + expect(fakeClient.sentAgentMessages[0]?.[2]?.activeTurnBehavior).toBe("steer"); + sessionStore.clearSession(host.serverId); + }); + it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => { const host = makeHost({ serverId: "srv_legacy_queue_attachment" }); const fakeClient = new FakeDaemonClient(); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index de4b40f883..55298ce5a1 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -2426,6 +2426,11 @@ export class HostRuntimeStore { }), encodeImages, submission: createMessageSubmissionWriter(serverId), + // The queue drains when the agent goes idle, but the agent can start a turn of its own + // in that same moment (a wake-up after background work). Steering folds the message + // into that turn instead of interrupting it; with no turn running, the daemon starts + // a normal one. + activeTurnBehavior: "steer", }); }, }) @@ -2601,6 +2606,14 @@ export class HostRuntimeStore { return () => directory.setDemand(source, false); } + acquireScriptStatusDemand(serverId: string, workspaceId: string): () => void { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) return () => undefined; + const source = {}; + directory.setScriptStatusDemand(source, workspaceId); + return () => directory.setScriptStatusDemand(source, null); + } + async prepareAgentRoute(serverId: string, agentId: string): Promise { const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index bb21530a2b..c48777a3e2 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -255,7 +255,93 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void { ]); } +async function cacheWithoutBackgroundVisibility() { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const checkpoint = { + agents: { generation: "g", afterSeq: 12 }, + projects: { generation: "g", afterSeq: 4 }, + workspaces: { generation: "g", afterSeq: 8 }, + }; + const baseline = directory(checkpoint); + commitDirectory(writer, SERVER_ID, baseline); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + const key = `${SERVER_ID}:workspace:workspace-1`; + const row = storage.rows.get(key)!; + const payload = JSON.parse(row.payload) as Record; + delete payload.background; + storage.rows.set(key, { ...row, payload: JSON.stringify(payload) }); + + return { storage, baseline, reader: createCache(storage) }; +} + +async function expectLegacyWorkspaceRefresh({ + storage, + baseline, + reader, +}: Awaited>): Promise { + const restored = await reader.readDirectory(SERVER_ID); + expect(restored.workspaces.size).toBe(0); + expect(restored.checkpoint).toEqual({ + agents: baseline.checkpoint.agents, + projects: baseline.checkpoint.projects, + }); + expect(restored.agents).toEqual(baseline.agents); + expect(restored.projects).toEqual(baseline.projects); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline()); + expect((await createCache(storage).readDirectory(SERVER_ID)).checkpoint).toEqual( + restored.checkpoint, + ); + + baseline.workspaces.set( + "workspace-1", + normalizeWorkspaceDescriptor({ ...workspacePayload(), background: true }), + ); + reader.replaceDirectoryBaseline(SERVER_ID, baseline); + await reader.flush(); + const refreshed = await createCache(storage).readDirectory(SERVER_ID); + expect(refreshed.workspaces.get("workspace-1")?.background).toBe(true); + expect(refreshed.checkpoint).toEqual(baseline.checkpoint); +} + describe("ReplicaCache", () => { + it.each([true, false, undefined])( + "preserves background visibility (%s) when reopening a cached workspace", + async (background) => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const baseline = directory(); + baseline.workspaces.set( + "workspace-1", + normalizeWorkspaceDescriptor({ ...workspacePayload(), background }), + ); + commitDirectory(writer, SERVER_ID, baseline); + await writer.flush(); + + const reader = createCache(storage); + expect( + (await reader.readDirectory(SERVER_ID)).workspaces.get("workspace-1")?.background, + ).toBe(background ?? false); + expect((await reader.readWorkspace(SERVER_ID, "workspace-1"))?.workspace.background).toBe( + background ?? false, + ); + }, + ); + + it("refetches workspaces cached without background visibility after a directory read", async () => { + const cached = await cacheWithoutBackgroundVisibility(); + + await expectLegacyWorkspaceRefresh(cached); + }); + + it("refetches workspaces cached without background visibility after a workspace read", async () => { + const cached = await cacheWithoutBackgroundVisibility(); + + expect(await cached.reader.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); + await expectLegacyWorkspaceRefresh(cached); + }); + it("rejects pre-baseline-fix checkpoints without discarding cached rows or timelines", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); @@ -464,6 +550,19 @@ describe("ReplicaCache", () => { expect((await cache.readDirectory(SERVER_ID)).projects.size).toBe(0); }); + it("does not return an accepted agent that storage rejected", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + storage.persistentWriteFailure = new Error("disk unavailable"); + commitDirectory(cache, SERVER_ID, directory()); + await cache.flush(); + + const result = await cache.readAgent(SERVER_ID, "agent-1"); + + expect(storage.rows.size).toBe(0); + expect(result).toBeUndefined(); + }); + it("fails closed when an accepted deletion cannot be persisted before a read", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); @@ -504,6 +603,25 @@ describe("ReplicaCache", () => { expect((await cache.readWorkspace(SERVER_ID, "workspace-1"))?.workspace.id).toBe("workspace-1"); }); + it("discards an in-flight read across host removal and reactivation", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + commitDirectory(cache, SERVER_ID, directory()); + await cache.flush(); + const started = deferred(); + const release = deferred(); + storage.onRead = started.resolve; + storage.readGate = release.promise; + + const reading = cache.readAgent(SERVER_ID, "agent-1"); + await started.promise; + cache.setHosts([]); + cache.setHosts([SERVER_ID]); + release.resolve(); + + expect(await reading).toBeUndefined(); + }); + it("discards a durable read when the host changes while it is in flight", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); @@ -522,6 +640,36 @@ describe("ReplicaCache", () => { expect(await reading).toBeUndefined(); }); + it("retries a timeline read when that same timeline changes in flight", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Old")); + await cache.flush(); + storage.onRead = () => { + storage.onRead = null; + cache.commitTimeline(SERVER_ID, "agent-1", timeline("New")); + }; + + expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([timelineItem("New")]); + expect(storage.reads).toHaveLength(2); + }); + + it("discards a requested row removed by a directory baseline replacement in flight", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + commitDirectory(cache, SERVER_ID, directory()); + await cache.flush(); + const replacement = directory(); + replacement.agents.clear(); + storage.onRead = () => { + storage.onRead = null; + cache.replaceDirectoryBaseline(SERVER_ID, replacement); + }; + + expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); + expect(storage.reads).toHaveLength(2); + }); + it("never reads a timeline older than an accepted deferred replacement", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); @@ -796,7 +944,7 @@ describe("ReplicaCache", () => { ]); }); - it("retries a timeline read invalidated by a concurrent directory commit", async () => { + it("reads a persisted timeline across a concurrent directory commit", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); cache.commitTimeline(SERVER_ID, "agent-1", timeline("Persisted timeline")); @@ -858,4 +1006,121 @@ describe("ReplicaCache", () => { expect(storage.cleanups).toBe(1); }); + + describe.each(["timeline", "directory"] as const)( + "while another agent streams %s updates", + (kind) => { + async function streamingCache() { + const storage = new MemoryStorage(); + const cache = createCache(storage); + commitDirectory(cache, SERVER_ID, directory()); + cache.commitTimeline(SERVER_ID, "agent-1", timeline()); + await cache.flush(); + const readsBefore = storage.reads.length; + const writesBefore = storage.writes; + let chunks = 0; + storage.onRead = () => { + if (chunks >= 200) return; + chunks += 1; + if (kind === "timeline") { + cache.commitTimeline(SERVER_ID, "agent-2", { + ...timeline(`chunk ${chunks}`), + agentId: "agent-2", + }); + } else { + cache.commitDirectoryMutations( + SERVER_ID, + [{ type: "upsert", kind: "agent", id: "agent-2", value: agent("agent-2") }], + { agents: { generation: "g", afterSeq: 12 + chunks } }, + ); + } + }; + return { + cache, + expectReads: (count: number, writes = 0) => { + expect(storage.reads.length - readsBefore).toBe(count); + expect(storage.writes - writesBefore).toBe(writes); + expect(chunks).toBe(count); + }, + }; + } + + it("reads another cached agent without waiting for the stream to stop", async () => { + const { cache, expectReads } = await streamingCache(); + + expect((await cache.readAgent(SERVER_ID, "agent-1"))?.title).toBe("Cached agent"); + expectReads(1); + await cache.flush(); + }); + + it("reads another cached workspace without waiting for the stream to stop", async () => { + const { cache, expectReads } = await streamingCache(); + + expect((await cache.readWorkspace(SERVER_ID, "workspace-1"))?.workspace.name).toBe("main"); + expectReads(2, 1); + await cache.flush(); + }); + + it("reads another cached timeline without waiting for the stream to stop", async () => { + const { cache, expectReads } = await streamingCache(); + + expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline()); + expectReads(1); + await cache.flush(); + }); + }, + ); + + it("reads the directory after persisting a pending baseline replacement", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + commitDirectory(cache, SERVER_ID, directory()); + cache.commitTimeline(SERVER_ID, "agent-1", timeline()); + await cache.flush(); + const replacement = directory({ agents: { generation: "g2", afterSeq: 1 } }); + replacement.projects.clear(); + replacement.agents.set("agent-2", agent("agent-2")); + cache.replaceDirectoryBaseline(SERVER_ID, replacement); + + const read = await cache.readDirectory(SERVER_ID); + + expect([...read.agents.keys()].sort()).toEqual(["agent-1", "agent-2"]); + expect(read.projects.size).toBe(0); + expect(read.checkpoint).toEqual({ agents: { generation: "g2", afterSeq: 1 } }); + expect(await cache.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline()); + expect(storage.writes).toBe(2); + }); +}); + +it("retains semantic agent messages across disk-cache reconstruction", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const value = timeline(); + value.items = [ + { + kind: "tool_call", + id: "delivery", + timestamp: new Date(1), + timelineCursor: { epoch: "epoch-1", seq: 12 }, + payload: { + source: "agent", + data: { + provider: "codex", + callId: "delivery", + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Done" }, + agentMessage: { + event: "finished", + sender: { id: "remote::worker", title: "Reviewer" }, + text: "Done", + }, + }, + }, + }, + ]; + writer.commitTimeline(SERVER_ID, "agent-1", value); + await writer.flush(); + expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(value); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index ef78112a49..681d11a2c4 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -306,6 +306,8 @@ const StoredWorkspaceSchema = z.strictObject({ worktreeSlug: z.string().optional(), projectKind: z.enum(["git", "non_git", "directory"]), workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), + // Require the flag so older rows that lost background visibility invalidate the workspace cursor. + background: z.boolean(), name: z.string(), title: z.string().nullable(), pinnedAt: z.string().nullable(), @@ -421,6 +423,7 @@ function serializeAgentToolCall(data: AgentToolCallData): StoredToolCall { name: data.name, detail: data.detail, ...(data.metadata ? { metadata: data.metadata } : {}), + ...(data.agentMessage ? { agentMessage: data.agentMessage } : {}), }; switch (data.status) { case "running": @@ -584,6 +587,7 @@ function deserializeBuiltinTimelineItem( error: tool.error, detail: tool.detail, ...(tool.metadata ? { metadata: tool.metadata } : {}), + ...(tool.agentMessage ? { agentMessage: tool.agentMessage } : {}), }, }, }; @@ -696,6 +700,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { worktreeSlug: workspace.worktreeSlug, projectKind: workspace.projectKind, workspaceKind: workspace.workspaceKind, + background: workspace.background ?? false, name: workspace.name, title: workspace.title ?? null, pinnedAt: workspace.pinnedAt ?? null, @@ -878,9 +883,24 @@ function directoryEntityForRow(row: ReplicaRow): keyof DirectoryCheckpoint | und return undefined; } +interface ReplicaRead { + serverId: string; + kinds: readonly ReplicaRowKind[]; + ids?: readonly string[]; + invalidated: boolean; +} + +function matchesReplicaRead(read: ReplicaRead, row: ReplicaRowKey): boolean { + return ( + read.serverId === row.serverId && + read.kinds.includes(row.kind) && + (!read.ids || read.ids.includes(row.id)) + ); +} + export class ReplicaCache { private readonly activeServerIds = new Set(); - private readonly hostRevisions = new Map(); + private readonly activeReads = new Set(); private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); @@ -1002,10 +1022,22 @@ export class ReplicaCache { // accepted commits to land. A store that rejects them will keep rejecting them: fail closed // rather than re-attempt the write on every pass. A write rejected for another host leaves // this host's stored rows readable. - if (!(await this.syncPending()) && this.hasPendingHostChanges(serverId)) return []; - const revision = this.hostRevisions.get(serverId) ?? 0; - const rows = await this.rowStore.read(serverId, kinds, ids); - if (this.canReadHostRevision(serverId, revision)) return rows; + const read: ReplicaRead = { serverId, kinds, ids, invalidated: false }; + this.activeReads.add(read); + try { + if (!(await this.syncPending()) && this.hasPendingHostChanges(serverId)) return []; + if (read.invalidated || this.hasPendingReadChanges(read)) continue; + const rows = await this.rowStore.read(serverId, kinds, ids); + if ( + this.activeServerIds.has(serverId) && + !read.invalidated && + !this.hasPendingReadChanges(read) + ) { + return rows; + } + } finally { + this.activeReads.delete(read); + } } return []; } catch { @@ -1090,7 +1122,6 @@ export class ReplicaCache { ): void { if (!this.activeServerIds.has(serverId)) return; if (this.invalidatedHosts.has(serverId)) return; - this.advanceHostRevision(serverId); for (const mutation of mutations) { if (mutation.type === "delete") { this.queueEntityDelete(serverId, mutation.kind, mutation.id); @@ -1116,7 +1147,7 @@ export class ReplicaCache { replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void { if (!this.activeServerIds.has(serverId)) return; - this.advanceHostRevision(serverId); + this.invalidateHostReads(serverId, ["agent", "workspace", "project", "checkpoint"]); // Replacing the directory must not discard independently accepted timeline changes. for (const [key, row] of this.pendingUpserts) { if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key); @@ -1148,7 +1179,6 @@ export class ReplicaCache { commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void { if (!this.activeServerIds.has(serverId)) return; if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); - this.advanceHostRevision(serverId); this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline }); this.schedulePersist(); } @@ -1157,7 +1187,7 @@ export class ReplicaCache { const next = new Set(serverIds); const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId)); const added = [...next].filter((serverId) => !this.activeServerIds.has(serverId)); - for (const serverId of [...removed, ...added]) this.advanceHostRevision(serverId); + for (const serverId of [...removed, ...added]) this.invalidateHostReads(serverId); this.activeServerIds.clear(); for (const serverId of next) this.activeServerIds.add(serverId); for (const serverId of removed) { @@ -1169,8 +1199,8 @@ export class ReplicaCache { } reconcileServerId(oldServerId: string, newServerId: string): void { - this.advanceHostRevision(oldServerId); - this.advanceHostRevision(newServerId); + this.invalidateHostReads(oldServerId); + this.invalidateHostReads(newServerId); const rows = this.storedRows.get(oldServerId); if (rows) { const newRows = this.storedRows.get(newServerId) ?? new Map(); @@ -1248,12 +1278,14 @@ export class ReplicaCache { } private queueUpsert(row: StructuredReplicaUpsert): void { + this.invalidateRowReads(row); const key = pendingRowKey(row); this.pendingDeletes.delete(key); this.pendingUpserts.set(key, row); } private queueDelete(key: ReplicaRowKey): void { + this.invalidateRowReads(key); const pendingKey = pendingRowKey(key); this.pendingUpserts.delete(pendingKey); this.pendingDeletes.set(pendingKey, key); @@ -1265,14 +1297,36 @@ export class ReplicaCache { ); } - private canReadHostRevision(serverId: string, revision: number): boolean { + private hasPendingReadChanges(read: ReplicaRead): boolean { + if ( + this.pendingBaselines.has(read.serverId) && + read.kinds.some((kind) => kind !== "timeline") + ) { + return true; + } return ( - this.activeServerIds.has(serverId) && - (this.hostRevisions.get(serverId) ?? 0) === revision && - !this.hasPendingHostChanges(serverId) + [...this.pendingUpserts.values()].some((row) => matchesReplicaRead(read, row)) || + [...this.pendingDeletes.values()].some((row) => matchesReplicaRead(read, row)) ); } + private invalidateRowReads(row: ReplicaRowKey): void { + for (const read of this.activeReads) { + if (matchesReplicaRead(read, row)) read.invalidated = true; + } + } + + private invalidateHostReads(serverId: string, kinds?: readonly ReplicaRowKind[]): void { + for (const read of this.activeReads) { + if ( + read.serverId === serverId && + (!kinds || kinds.some((kind) => read.kinds.includes(kind))) + ) { + read.invalidated = true; + } + } + } + private hasPendingHostChanges(serverId: string): boolean { if (this.pendingBaselines.has(serverId)) return true; for (const row of this.pendingUpserts.values()) { @@ -1284,10 +1338,6 @@ export class ReplicaCache { return false; } - private advanceHostRevision(serverId: string): void { - this.hostRevisions.set(serverId, (this.hostRevisions.get(serverId) ?? 0) + 1); - } - private drainPendingChanges(): PendingReplicaChanges { const changes = { upserts: [...this.pendingUpserts.values()], diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts index 739738e0f9..a6c1e81845 100644 --- a/packages/app/src/screens/new-workspace-picker-item.test.ts +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { buildVisibleComboboxOptions } from "@/components/ui/combobox-options"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import { type BranchPickerDetail, @@ -340,3 +341,48 @@ describe("defaultBasePickerItem", () => { expect(defaultBasePickerItem({ currentBranch: null })).toBeNull(); }); }); + +describe("starting-ref search", () => { + it.each(["gitlab", "github"] as const)( + "keeps a %s change request returned for a body-only match", + (forge) => { + const item: ForgeSearchItem = { + ...prItem, + forge, + number: 3896, + title: "fix(config): send environment credentials only to trusted hosts", + body: "This mirrors the fix shipped for CVE-2024-53858.", + headRefName: "8545-duo-test", + }; + const { options } = buildPickerOptionData({ + branchDetails: [], + prItems: [item], + baseItem: null, + }); + const visible = buildVisibleComboboxOptions({ + options, + searchQuery: "2024", + searchable: true, + allowCustomValue: false, + customValuePrefix: "Use", + }); + expect(visible.map((option) => option.label)).toEqual([ + `${forge === "gitlab" ? "!" : "#"}3896 fix(config): send environment credentials only to trusted hosts`, + ]); + const selected = buildPickerOptionData({ + branchDetails: [], + prItems: [], + baseItem: { kind: "github-pr", item }, + }); + expect( + buildVisibleComboboxOptions({ + options: selected.options, + searchQuery: "2024", + searchable: true, + allowCustomValue: false, + customValuePrefix: "Use", + }), + ).toEqual(visible); + }, + ); +}); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index 0cb0680605..d02c7a0bf2 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -264,7 +264,10 @@ export function buildPickerOptionData(input: { itemById.set(id, { kind: "github-pr", item: pr }); const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0; const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000); - timedOptions.push({ option: { id, label: formatPrLabel(pr) }, timestamp }); + timedOptions.push({ + option: { id, label: formatPrLabel(pr), searchText: pr.body ?? undefined }, + timestamp, + }); } const baseItem = input.baseItem; @@ -279,7 +282,13 @@ export function buildPickerOptionData(input: { const labeledItem = disambiguate(baseItem, timedOptions); itemById.set(selectedOptionId, labeledItem); timedOptions.push({ - option: { id: selectedOptionId, label: pickerItemLabel(labeledItem) }, + option: { + id: selectedOptionId, + label: pickerItemLabel(labeledItem), + ...(labeledItem.kind === "github-pr" + ? { searchText: labeledItem.item.body ?? undefined } + : {}), + }, // The base sorts first: it is what a workspace is created from unless you pick something. timestamp: Number.POSITIVE_INFINITY, }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index c919ab476d..6d8fd3de5a 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -17,6 +17,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { ComposerDock } from "@/composer/dock"; +import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -31,8 +32,7 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { SidebarMenuToggle } from "@/components/headers/menu-header"; -import { ScreenHeader } from "@/components/headers/screen-header"; +import { MenuHeader } from "@/components/headers/menu-header"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; @@ -892,7 +892,7 @@ function buildWorkspaceDraftSetupFromComposer(input: { return { provider: input.provider, cwd: input.cwd, - modeId: input.composerState.selectedMode || null, + modeId: input.composerState.effectiveModeId || null, model: input.composerState.effectiveModelId || null, thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, featureValues: input.composerState.featureValues ?? {}, @@ -972,7 +972,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise) => { getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); @@ -1724,7 +1726,7 @@ export function NewWorkspaceScreen({ [terminalComposerValue], ); const terminalTextReplacement = useMemo( - () => ({ key: launchFocusKey, text: terminalComposerValue }), + () => ({ kind: "replace" as const, key: launchFocusKey, text: terminalComposerValue }), [launchFocusKey, terminalComposerValue], ); @@ -2122,6 +2124,7 @@ export function NewWorkspaceScreen({ return; } outcome = "navigated"; + leaveFullscreen(); navigateToWorkspace({ serverId: targetServerId, workspaceId }); }, }); @@ -2172,6 +2175,7 @@ export function NewWorkspaceScreen({ forkDraftSetup, isStillOnCreateScreen, launchTarget, + leaveFullscreen, selectedServerId, supportsForgeSearch, t, @@ -2228,6 +2232,7 @@ export function NewWorkspaceScreen({ return; } outcome = "navigated"; + leaveFullscreen(); navigateToWorkspace({ serverId: targetServerId, workspaceId, target }); }, }); @@ -2251,6 +2256,7 @@ export function NewWorkspaceScreen({ t, terminalPromptText, toast, + leaveFullscreen, updateFormPreferences, withConnectedClient, ]); @@ -2368,13 +2374,14 @@ export function NewWorkspaceScreen({ }, }); - const screenHeaderLeft = useMemo(() => , []); const importSession = useImportSession({ serverId: selectedServerId }); + const isComposerFullscreen = composerEditing.isFullscreen(isTerminalLaunch); const composer = isTerminalLaunch ? ( - - - - - {composer} - {errorMessage ? {errorMessage} : null} - - + + {composer} + {errorMessage ? {errorMessage} : null} + {importSession.sheet} ); } +/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */ +function useNewWorkspaceComposerEditing() { + const terminal = useComposerEditingSession("terminal"); + const chat = useComposerEditingSession("chat"); + const isTerminalFullscreen = useComposerFullscreen(terminal); + const isChatFullscreen = useComposerFullscreen(chat); + // The sessions outlive the screen (its draft keeps them), so a launch that leaves + // the screen also leaves fullscreen; the next draft starts inline. + const leaveFullscreen = useCallback(() => { + terminal.setFullscreen(false); + chat.setFullscreen(false); + }, [chat, terminal]); + return { + terminal, + chat, + leaveFullscreen, + isFullscreen: (isTerminalLaunch: boolean) => + isTerminalLaunch ? isTerminalFullscreen : isChatFullscreen, + }; +} + function NewWorkspaceLayout({ isCompact, + fullscreen, title, formStack, onImportSession, children, }: { isCompact: boolean; + /** The composer covers the screen; the header leaves and the dock fills the content. */ + fullscreen: boolean; title: string; formStack: ReactNode; onImportSession: () => void; @@ -2474,13 +2503,19 @@ function NewWorkspaceLayout({ ); return ( - - {setupFields} - <> - {children} - {isCompact ? null : importSessionButton} - - + <> + {fullscreen ? null : } + + + + {setupFields} + <> + {children} + {isCompact || fullscreen ? null : importSessionButton} + + + + ); } diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 925607a858..3950c0778e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -120,6 +120,7 @@ import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-p import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { densityIconBox, densityIconSize } from "@/styles/density"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { type EnableBuiltInDaemonOption, @@ -758,7 +759,7 @@ function SidebarSectionButton({ isSelected, onSelect, }: SidebarSectionButtonProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); const handlePress = useCallback(() => { onSelect(itemId); }, [onSelect, itemId]); @@ -775,7 +776,7 @@ function SidebarSectionButton({ style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle} > @@ -801,7 +802,7 @@ function SidebarHostSectionButton({ isSelected, onSelect, }: SidebarHostSectionButtonProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); const handlePress = useCallback(() => { onSelect(itemId); }, [onSelect, itemId]); @@ -819,7 +820,7 @@ function SidebarHostSectionButton({ style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle} > @@ -912,6 +913,16 @@ function HostPicker({ ); } +/** A labelled group of sidebar rows. On compact layouts the rows sit together in one island. */ +function SidebarGroup({ label, children }: { label?: string; children: ReactNode }) { + return ( + + {label ? {label} : null} + {children} + + ); +} + interface SettingsSidebarProps { view: SettingsView; onSelectSection: (section: SettingsSectionSlug) => void; @@ -933,7 +944,7 @@ function SettingsSidebar({ activeHostServerId, layout, }: SettingsSidebarProps) { - const { theme } = useUnistyles(); + const { theme, rt } = useUnistyles(); const { t } = useTranslation(); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); @@ -960,8 +971,7 @@ function SettingsSidebar({ const sidebarBody = ( <> - - {t("settings.groups.app")} + {items.map((item) => ( ))} - + {hasHosts ? ( - - {t("settings.groups.host")} + ))} - + ) : ( - + @@ -1021,7 +1030,7 @@ function SettingsSidebar({ style={sidebarItemStyle} > @@ -1030,7 +1039,7 @@ function SettingsSidebar({ ) : null} - + )} ); @@ -1572,26 +1581,33 @@ const sidebarStyles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], }, - list: { + // Compact values draw each group as an island of tiles; desktop keeps a plain list. + group: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], - gap: theme.spacing[0.5], + gap: { xs: theme.spacing[1], md: theme.spacing[0.5] }, }, groupLabel: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundExtraMuted, - paddingHorizontal: theme.spacing[2], + paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] }, paddingVertical: theme.spacing[1], }, + island: { + gap: { xs: 2, md: theme.spacing[0.5] }, + borderRadius: { xs: theme.borderRadius["2xl"], md: 0 }, + overflow: "hidden", + }, item: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], - minHeight: 28, + gap: { xs: theme.spacing[3], md: theme.spacing[2] }, + minHeight: { xs: 52, md: 28 }, paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.lg, + paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] }, + borderRadius: { xs: 0, md: theme.borderRadius.lg }, + backgroundColor: { xs: theme.colors.surface1, md: "transparent" }, }, itemHovered: { backgroundColor: theme.colors.surfaceSidebarHover, @@ -1600,7 +1616,7 @@ const sidebarStyles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surfaceSidebarHover, }, label: { - fontSize: theme.fontSize.base, + fontSize: { xs: theme.fontSize.lg, md: theme.fontSize.base }, color: theme.colors.foregroundMuted, fontWeight: theme.fontWeight.normal, flex: 1, @@ -1608,11 +1624,12 @@ const sidebarStyles = StyleSheet.create((theme) => ({ pickerTrigger: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], - minHeight: 28, + gap: { xs: theme.spacing[3], md: theme.spacing[2] }, + minHeight: { xs: 52, md: 28 }, paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.lg, + paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] }, + borderRadius: { xs: 0, md: theme.borderRadius.lg }, + backgroundColor: { xs: theme.colors.surface1, md: "transparent" }, }, pickerTriggerHovered: { backgroundColor: theme.colors.surfaceSidebarHover, @@ -1620,14 +1637,14 @@ const sidebarStyles = StyleSheet.create((theme) => ({ pickerTriggerLabel: { flex: 1, minWidth: 0, - fontSize: theme.fontSize.base, + fontSize: { xs: theme.fontSize.lg, md: theme.fontSize.base }, color: theme.colors.foreground, fontWeight: theme.fontWeight.normal, }, // Match the setting items' icon footprint so the host label aligns with them. pickerTriggerDot: { - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), alignItems: "center", justifyContent: "center", }, diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 6e98819323..2dfea09fd0 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -241,7 +241,7 @@ interface FontFamilyRowProps { draft: string; withBorder: boolean; onChangeDraft: (value: string) => void; - onCommit: (value: string) => void; + onCommit: (value: string) => string; } function FontFamilyRow({ @@ -255,13 +255,16 @@ function FontFamilyRow({ onChangeDraft, onCommit, }: FontFamilyRowProps) { + const inputRef = useRef(null); const handleCommit = useCallback(() => { - onCommit(draft); + const committed = onCommit(draft); + inputRef.current?.replaceText(committed); }, [draft, onCommit]); // Resync from the committed value when it changes elsewhere. useEffect(() => { onChangeDraft(value); + inputRef.current?.replaceText(value); // Only resync on external value changes, not on local keystrokes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); @@ -273,6 +276,7 @@ function FontFamilyRow({ {hint} [...current, comboString]); + const capturedCombo = normalizeCapturedShortcutCombo(capturingBindingId!, comboString); + setCapturedCombos((current) => [...current, capturedCombo]); } window.addEventListener("keydown", handleKeyDown, true); diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 0ede3f803b..8a502749db 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -80,6 +80,7 @@ vi.mock("@gorhom/bottom-sheet", async () => { await vi.importActual("react-native"); return { BottomSheetBackdrop: () => null, + BottomSheetHandle: () => null, BottomSheetModal: ReactModule.forwardRef(() => null), BottomSheetScrollView: ScrollView, BottomSheetTextInput: TextInput, diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 77aaa4d8ec..0a702ae641 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; +import { ToastApiProvider } from "@/contexts/toast-api-context"; const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted( () => ({ @@ -281,6 +282,12 @@ vi.mock("@/utils/confirm-dialog", () => ({ import { ProvidersSection } from "./providers-section"; +function unexpectedToast(): never { + throw new Error("Provider row interactions should not display a toast"); +} + +const toastApi = { show: unexpectedToast, copied: unexpectedToast, error: unexpectedToast }; + const claudeEntry: ProviderSnapshotEntry = { provider: "claude", status: "ready", @@ -366,7 +373,11 @@ describe("ProvidersSection", () => { function render(): void { act(() => { - root?.render(); + root?.render( + + + , + ); }); } diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 4495acba83..72234b7602 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -11,6 +11,7 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-api-context"; import { settingsStyles } from "@/styles/settings"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostFeature } from "@/runtime/host-features"; @@ -19,6 +20,7 @@ import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildAcpProviderConfigPatch, + getAcpProviderCatalog, type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; import { ProviderCatalogList } from "@/components/provider-catalog-list"; @@ -325,10 +327,12 @@ export interface ProvidersSectionProps { export function ProvidersSection({ serverId }: ProvidersSectionProps) { const { t } = useTranslation(); + const toast = useToast(); const isConnected = useHostRuntimeIsConnected(serverId); + const supportsHopliteProvider = useHostFeature(serverId, "hopliteProvider"); const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval"); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); - const { patchConfig } = useDaemonConfig(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); const openProviderSettings = useProviderSettingsStore((state) => state.open); const [pendingProviderId, setPendingProviderId] = useState(null); const [removingProviderId, setRemovingProviderId] = useState(null); @@ -338,6 +342,19 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; + /** Config owns derived providers and opt-in bundled catalog providers. */ + const isConfigOwnedProvider = useCallback( + (providerId: string) => + Boolean( + config?.providers?.[providerId] && + (config.providers[providerId].extends || + getAcpProviderCatalog().some( + (entry) => entry.id === providerId && entry.bundledProvider, + )), + ), + [config], + ); + const handleOpenProviderSettings = useCallback( (providerId: string) => { openProviderSettings({ serverId, provider: providerId }); @@ -399,18 +416,15 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { if (installingProviderId) return; setInstallingProviderId(entry.id); try { - await patchConfig(buildAcpProviderConfigPatch(entry)); + await patchConfig(buildAcpProviderConfigPatch(entry, supportsHopliteProvider)); await refresh([entry.id]); } catch (error) { - Alert.alert( - t("settings.providers.addErrorTitle"), - error instanceof Error ? error.message : String(error), - ); + toast.error(error instanceof Error ? error.message : String(error)); } finally { setInstallingProviderId((current) => (current === entry.id ? null : current)); } }, - [installingProviderId, patchConfig, refresh, t], + [installingProviderId, patchConfig, refresh, supportsHopliteProvider, toast], ); return ( @@ -444,7 +458,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { enabled={entry.enabled ?? true} isToggling={pendingProviderId === def.id} isRemoving={removingProviderId === def.id} - canRemove={supportsProviderRemoval && entry.source === "custom"} + canRemove={ + supportsProviderRemoval && + entry.source === "custom" && + isConfigOwnedProvider(def.id) + } isFirst={index === 0} onPress={handleOpenProviderSettings} onToggleEnabled={handleToggleEnabled} diff --git a/packages/app/src/screens/workspace/compact-workspace-header/index.tsx b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx new file mode 100644 index 0000000000..2a7c4a6a57 --- /dev/null +++ b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx @@ -0,0 +1,72 @@ +import { useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { CompactHeader } from "@/components/headers/compact-header"; +import { + EXPLORER_TOGGLE_KEYS, + WorkspaceExplorerToggle, +} from "@/screens/workspace/workspace-explorer-toggle"; +import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row"; + +export { MobileWorkspaceTabSwitcher } from "./tab-switcher"; + +interface CompactWorkspaceHeaderProps { + serverId: string; + isLoading: boolean; + title: string; + subtitle: string; + isSubtitleDistinct: boolean; + onToggleExplorer: () => void; + explorerToggleLabel: string; + explorerToggleAccessibilityState: { expanded: boolean }; + /** Tab switcher and workspace menu, before the explorer toggle. */ + children: ReactNode; +} + +/** The compact header with the workspace's project row and controls. */ +export function CompactWorkspaceHeader({ + isLoading, + title, + subtitle, + isSubtitleDistinct, + onToggleExplorer, + explorerToggleLabel, + explorerToggleAccessibilityState, + children, + serverId, +}: CompactWorkspaceHeaderProps) { + const { t } = useTranslation(); + const projectRow = useMemo( + () => ( + + ), + [subtitle, isSubtitleDistinct, serverId], + ); + const actions = ( + <> + {children} + + + ); + + return ( + + ); +} diff --git a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx new file mode 100644 index 0000000000..970e77aaa9 --- /dev/null +++ b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx @@ -0,0 +1,501 @@ +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { Keyboard, Pressable, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { SquarePen, SquareTerminal } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { useIconButtonChromeStyle } from "@/components/ui/icon-button-chrome"; +import { + WorkspaceTabPresentationResolver, + WorkspaceTabOptionRow, + type WorkspaceTabPresentation, +} from "@/screens/workspace/workspace-tab-presentation"; +import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; +import { + buildWorkspaceTabMenuEntries, + type WorkspaceTabMenuLabels, +} from "@/screens/workspace/workspace-tab-menu"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +// The trigger draws at 32pt beside its island neighbours, so only vertical slop is free. +const TAB_SWITCHER_TRIGGER_HIT_SLOP = { top: 8, bottom: 8 } as const; + +function getFallbackTabOptionLabel( + tab: WorkspaceTabDescriptor, + labels: { + newTab: string; + newAgent: string; + setup: string; + terminal: string; + browser: string; + agent: string; + changes: string; + files: string; + pullRequest: string; + }, +): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } + if (tab.target.kind === "draft") { + return labels.newAgent; + } + if (tab.target.kind === "setup") { + return labels.setup; + } + if (tab.target.kind === "terminal") { + return labels.terminal; + } + if (tab.target.kind === "browser") { + return labels.browser; + } + if (tab.target.kind === "file") { + return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; + } + if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { + return labels.changes; + } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } + return labels.agent; +} + +function getFallbackTabOptionDescription( + tab: WorkspaceTabDescriptor, + labels: { + newTab: string; + newAgent: string; + workspaceSetup: string; + agent: string; + terminal: string; + browser: string; + changes: string; + files: string; + pullRequest: string; + }, +): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } + if (tab.target.kind === "draft") { + return labels.newAgent; + } + if (tab.target.kind === "setup") { + return labels.workspaceSetup; + } + if (tab.target.kind === "agent") { + return labels.agent; + } + if (tab.target.kind === "terminal") { + return labels.terminal; + } + if (tab.target.kind === "browser") { + return labels.browser; + } + if (tab.target.kind === "provider_subagent") { + return labels.agent; + } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } + if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { + return labels.changes; + } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } + if (tab.target.kind === "plugin") { + return tab.target.panelId; + } + return tab.target.path; +} + +interface MobileWorkspaceTabSwitcherProps { + tabs: WorkspaceTabDescriptor[]; + activeTabKey: string; + normalizedServerId: string; + normalizedWorkspaceId: string; + onSelectSwitcherTab: (key: string) => void; + createTerminalDisabled: boolean; + onCreateDraftTab: () => void; + onCreateTerminal: () => void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsAbove: (tabId: string) => Promise | void; + onCloseTabsBelow: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +} + +function MobileWorkspaceTabOption({ + tab, + tabIndex, + tabCount, + normalizedServerId, + normalizedWorkspaceId, + selected, + active, + onPress, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, +}: { + tab: WorkspaceTabDescriptor; + tabIndex: number; + tabCount: number; + normalizedServerId: string; + normalizedWorkspaceId: string; + selected: boolean; + active: boolean; + onPress: () => void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsAbove: (tabId: string) => Promise | void; + onCloseTabsBelow: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +}) { + const { t } = useTranslation(); + const tabMenuLabels = useMemo( + () => ({ + copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), + copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), + copyFilePath: t("workspace.tabs.menu.copyFilePath"), + rename: t("workspace.tabs.menu.rename"), + closeAbove: t("workspace.tabs.menu.closeAbove"), + closeBelow: t("workspace.tabs.menu.closeBelow"), + closeLeft: t("workspace.tabs.menu.closeLeft"), + closeRight: t("workspace.tabs.menu.closeRight"), + closeOthers: t("workspace.tabs.menu.closeOthers"), + reloadAgent: t("workspace.tabs.menu.reloadAgent"), + reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), + close: t("workspace.tabs.menu.close"), + }), + [t], + ); + const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; + const menuEntries = buildWorkspaceTabMenuEntries({ + surface: "mobile", + tab, + index: tabIndex, + tabCount, + menuTestIDBase, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsBefore: onCloseTabsAbove, + onCloseTabsAfter: onCloseTabsBelow, + onCloseOtherTabs, + labels: tabMenuLabels, + }); + + const fallbackLabels = useMemo( + () => ({ + newTab: t("workspace.tabs.actions.newTab"), + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), + }), + [t], + ); + const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels); + const trailingAccessory = useMemo( + () => ( + + ), + [menuTestIDBase, fallbackLabel, menuEntries], + ); + + const renderPresentation = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + ), + [selected, active, onPress, trailingAccessory], + ); + + return ( + + {renderPresentation} + + ); +} + +export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ + tabs, + activeTabKey, + normalizedServerId, + normalizedWorkspaceId, + onSelectSwitcherTab, + createTerminalDisabled, + onCreateDraftTab, + onCreateTerminal, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, +}: MobileWorkspaceTabSwitcherProps) { + const { t } = useTranslation(); + const tabFallbackLabels = useMemo( + () => ({ + newTab: t("workspace.tabs.actions.newTab"), + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), + }), + [t], + ); + + const tabSwitcherOptions = useMemo( + () => + tabs.map((tab) => ({ + id: tab.key, + label: getFallbackTabOptionLabel(tab, tabFallbackLabels), + description: getFallbackTabOptionDescription(tab, tabFallbackLabels), + })), + [tabFallbackLabels, tabs], + ); + + const tabByKey = useMemo(() => new Map(tabs.map((tab) => [tab.key, tab])), [tabs]); + const triggerStyle = useIconButtonChromeStyle({ size: "large" }); + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + const tabIndexByKey = useMemo(() => { + const map = new Map(); + tabs.forEach((tab, index) => { + map.set(tab.key, index); + }); + return map; + }, [tabs]); + + const handleOpenSwitcher = useCallback(() => { + Keyboard.dismiss(); + setIsOpen(true); + }, []); + + const handleNewAgent = useCallback(() => { + setIsOpen(false); + onCreateDraftTab(); + }, [onCreateDraftTab]); + + const handleNewTerminal = useCallback(() => { + setIsOpen(false); + onCreateTerminal(); + }, [onCreateTerminal]); + + const footer = useMemo( + () => ( + + + + + ), + [createTerminalDisabled, handleNewAgent, handleNewTerminal, t], + ); + + const renderTabOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => { + const tab = tabByKey.get(option.id); + if (!tab) { + return ; + } + const tabIndex = tabIndexByKey.get(tab.key) ?? -1; + if (tabIndex < 0) { + return ; + } + return ( + + ); + }, + [ + tabByKey, + tabIndexByKey, + tabs.length, + normalizedServerId, + normalizedWorkspaceId, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, + ], + ); + + return ( + <> + + + {tabs.length} + + + + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + tabSwitcherFooter: { + flexDirection: "row", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[3], + }, + tabSwitcherFooterButton: { + flex: 1, + }, + // Sized and stroked like the 16px header glyphs beside it (1.5 stroke on a 24 viewbox = 1px). + tabCountBadge: { + minWidth: 16, + height: 16, + paddingHorizontal: 2, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.sm, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.foregroundMuted, + }, + // Android pads text above the ascender by default, which pushes the digit below center. + tabCountText: { + fontSize: 10, + lineHeight: 12, + includeFontPadding: false, + textAlign: "center", + textAlignVertical: "center", + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + fontVariant: ["tabular-nums"], + }, +})); diff --git a/packages/app/src/screens/workspace/scripts/index.ts b/packages/app/src/screens/workspace/scripts/index.ts new file mode 100644 index 0000000000..efff482e80 --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/index.ts @@ -0,0 +1,10 @@ +/** + * Workspace scripts: the desktop header popover, and the pages the compact header menu carries. + * Both share one set of actions and one reading of script status. + */ +export { WorkspaceScriptsButton } from "./workspace-scripts-button"; +export { + useWorkspaceScriptsMenuPages, + type UseWorkspaceScriptsMenuPagesInput, + type WorkspaceScriptsMenuPages, +} from "./workspace-scripts-menu-pages"; diff --git a/packages/app/src/screens/workspace/scripts/script-marks.tsx b/packages/app/src/screens/workspace/scripts/script-marks.tsx new file mode 100644 index 0000000000..ae4f9a9edb --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/script-marks.tsx @@ -0,0 +1,78 @@ +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; +import { Globe, SquareTerminal } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceScriptLinkKind } from "@/utils/workspace-script-links"; +import { scriptStatus, scriptToneColorMapping } from "./script-status"; + +const ThemedGlobe = withUnistyles(Globe); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); + +/** A globe for a service, a terminal for a plain script, in the colour of what it is doing. */ +export function ScriptIcon({ + script, + size, +}: { + script: WorkspaceDescriptor["scripts"][number]; + size: number; +}): ReactElement { + const Icon = script.type === "service" ? ThemedGlobe : ThemedSquareTerminal; + return ( + + ); +} + +export function ExitCodeBadge({ code }: { code: number }): ReactElement { + const { t } = useTranslation(); + const exitTextStyle = + code === 0 ? styles.exitBadgeText : [styles.exitBadgeText, styles.exitBadgeTextError]; + return ( + + {t("workspace.scripts.states.exitCode", { code })} + + ); +} + +/** The label of a URL route, as the user picks between them. */ +export function routeLabelKey( + kind: WorkspaceScriptLinkKind, +): + | "workspace.scripts.routes.public" + | "workspace.scripts.routes.paseo" + | "workspace.scripts.routes.direct" { + switch (kind) { + case "public": + return "workspace.scripts.routes.public"; + case "paseo": + return "workspace.scripts.routes.paseo"; + case "direct": + return "workspace.scripts.routes.direct"; + } +} + +const styles = StyleSheet.create((theme) => ({ + scriptIcon: { + flexShrink: 0, + }, + exitBadge: { + paddingHorizontal: theme.spacing[1.5], + paddingVertical: 1, + borderRadius: 2, + backgroundColor: theme.colors.surface2, + }, + exitBadgeText: { + fontSize: 10, + lineHeight: 12, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + exitBadgeTextError: { + color: theme.colors.statusDanger, + }, +})); diff --git a/packages/app/src/screens/workspace/scripts/script-status.test.ts b/packages/app/src/screens/workspace/scripts/script-status.test.ts new file mode 100644 index 0000000000..7ca5dc81b3 --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/script-status.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; +import { scriptStatus, summarizeScripts } from "./script-status"; + +function service( + scriptName: string, + state: Pick, +): WorkspaceScriptPayload { + return { ...script(scriptName, state.lifecycle), type: "service", health: state.health }; +} + +function script( + scriptName: string, + lifecycle: WorkspaceScriptPayload["lifecycle"], +): WorkspaceScriptPayload { + return { + scriptName, + type: "script", + hostname: scriptName, + port: null, + proxyUrl: null, + lifecycle, + health: null, + exitCode: null, + terminalId: null, + }; +} + +describe("scriptStatus", () => { + it("colours services by their health and plain scripts by whether they run", () => { + expect(scriptStatus(script("lint", "stopped"))).toBe("idle"); + expect(scriptStatus(script("lint", "running"))).toBe("running"); + expect(scriptStatus(service("web", { lifecycle: "stopped", health: "unhealthy" }))).toBe( + "idle", + ); + expect(scriptStatus(service("web", { lifecycle: "running", health: null }))).toBe("running"); + expect(scriptStatus(service("web", { lifecycle: "running", health: "healthy" }))).toBe( + "healthy", + ); + expect(scriptStatus(service("web", { lifecycle: "running", health: "unhealthy" }))).toBe( + "unhealthy", + ); + }); +}); + +describe("summarizeScripts", () => { + it("is idle with nothing running when nothing runs", () => { + expect(summarizeScripts([])).toEqual({ tone: "idle", running: [] }); + expect(summarizeScripts([script("lint", "stopped")])).toEqual({ tone: "idle", running: [] }); + }); + + it("lists the running scripts by name", () => { + expect( + summarizeScripts([ + script("lint", "stopped"), + service("web", { lifecycle: "running", health: null }), + ]), + ).toEqual({ tone: "running", running: ["web"] }); + }); + + it("is unhealthy when any running service is", () => { + expect( + summarizeScripts([ + service("web", { lifecycle: "running", health: "healthy" }), + service("api", { lifecycle: "running", health: "unhealthy" }), + ]), + ).toEqual({ tone: "unhealthy", running: ["web", "api"] }); + }); + + it("is healthy only when every running service reports healthy", () => { + expect( + summarizeScripts([ + script("lint", "running"), + service("web", { lifecycle: "running", health: "healthy" }), + service("api", { lifecycle: "stopped", health: "unhealthy" }), + ]), + ).toEqual({ tone: "healthy", running: ["lint", "web"] }); + expect( + summarizeScripts([ + service("web", { lifecycle: "running", health: "healthy" }), + service("api", { lifecycle: "running", health: null }), + ]), + ).toEqual({ tone: "running", running: ["web", "api"] }); + expect(summarizeScripts([script("lint", "running")])).toEqual({ + tone: "running", + running: ["lint"], + }); + }); +}); diff --git a/packages/app/src/screens/workspace/scripts/script-status.ts b/packages/app/src/screens/workspace/scripts/script-status.ts new file mode 100644 index 0000000000..ff7398dda4 --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/script-status.ts @@ -0,0 +1,63 @@ +import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; +import type { Theme } from "@/styles/theme"; + +/** + * What a script, or a set of them, is doing, as the one colour its ▶ or row icon is drawn in. + * + * Health is a service's own report, so it only colours running services. A plain script that is + * running is just running. + */ +export type ScriptTone = "idle" | "running" | "healthy" | "unhealthy"; + +export interface ScriptsSummary { + tone: ScriptTone; + /** Names of the running scripts, in the order the workspace lists them. */ + running: string[]; +} + +export function scriptStatus(script: WorkspaceScriptPayload): ScriptTone { + if (script.lifecycle !== "running") return "idle"; + if (script.type !== "service") return "running"; + if (script.health === "healthy") return "healthy"; + if (script.health === "unhealthy") return "unhealthy"; + return "running"; +} + +/** + * One unhealthy service makes the whole set unhealthy. The set reads healthy only when every + * running service says so; a service that has not reported yet keeps it at plain running. + */ +export function summarizeScripts(scripts: readonly WorkspaceScriptPayload[]): ScriptsSummary { + const running = scripts.filter((script) => script.lifecycle === "running"); + if (running.length === 0) return { tone: "idle", running: [] }; + return { tone: aggregateTone(running), running: running.map((script) => script.scriptName) }; +} + +function aggregateTone(running: readonly WorkspaceScriptPayload[]): ScriptTone { + const serviceTones = running + .filter((script) => script.type === "service") + .map((script) => scriptStatus(script)); + if (serviceTones.includes("unhealthy")) return "unhealthy"; + if (serviceTones.length > 0 && serviceTones.every((tone) => tone === "healthy")) { + return "healthy"; + } + return "running"; +} + +const idleColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const runningColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] }); +const healthyColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); +const unhealthyColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); + +export function scriptToneColorMapping(tone: ScriptTone): (theme: Theme) => { color: string } { + switch (tone) { + case "idle": + return idleColorMapping; + case "running": + return runningColorMapping; + case "healthy": + return healthyColorMapping; + case "unhealthy": + return unhealthyColorMapping; + } +} diff --git a/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts b/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts new file mode 100644 index 0000000000..fb7809990f --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import * as Clipboard from "expo-clipboard"; +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; +import { useToast } from "@/contexts/toast-context"; +import { + resolveWorkspaceScriptLink, + type WorkspaceScriptLinkKind, + type WorkspaceScriptLinkTarget, +} from "@/utils/workspace-script-links"; +import { useWorkspaceServiceRoutePreferencesStore } from "@/workspace-service-routes/store"; + +type WorkspaceScript = WorkspaceDescriptor["scripts"][number]; + +export interface WorkspaceScriptLinks { + /** The URL this host's route preference picks, or null when the script serves none. */ + selected: WorkspaceScriptLinkTarget | null; + targets: WorkspaceScriptLinkTarget[]; +} + +export interface WorkspaceScriptActions { + start: (scriptName: string) => void; + stop: (scriptName: string) => void; + /** Stops the script, then starts it again once the daemon reports it stopped. */ + restart: (scriptName: string) => void; + copyUrl: (target: WorkspaceScriptLinkTarget) => void; + selectRoute: (kind: WorkspaceScriptLinkKind) => void; + resolveLinks: (script: WorkspaceScript) => WorkspaceScriptLinks; + isStartPending: boolean; + isStopPending: boolean; +} + +export interface UseWorkspaceScriptActionsInput { + serverId: string; + workspaceId: string; + scripts: WorkspaceDescriptor["scripts"]; + onScriptTerminalStarted?: (terminalId: string) => void; +} + +/** + * Run, stop, restart and URL handling for a workspace's scripts, shared by every surface that + * lists them. + * + * A restart outlives the press that asked for it — the second half waits for the daemon to + * report the script stopped — so mount this with whatever owns the menu, not inside a menu page + * that unmounts when the menu closes. + */ +export function useWorkspaceScriptActions({ + serverId, + workspaceId, + scripts, + onScriptTerminalStarted, +}: UseWorkspaceScriptActionsInput): WorkspaceScriptActions { + const { t } = useTranslation(); + const toast = useToast(); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; + const preferredRouteKind = useWorkspaceServiceRoutePreferencesStore( + (state) => state.byServerId[serverId] ?? null, + ); + const setPreferredRoute = useWorkspaceServiceRoutePreferencesStore( + (state) => state.setPreferredRoute, + ); + const pendingRestartRef = useRef>(new Set()); + + const startScriptMutation = useMutation({ + mutationFn: async (scriptName: string) => { + if (!client) { + throw new Error(t("common.errors.daemonClientUnavailable")); + } + const result = await client.startWorkspaceScript(workspaceId, scriptName); + if (result.error) { + throw new Error(result.error); + } + return result; + }, + onError: (error, scriptName) => { + toast.show( + error instanceof Error + ? error.message + : t("workspace.scripts.states.startFailed", { scriptName }), + { + variant: "error", + }, + ); + }, + onSuccess: (result) => { + if (result.terminalId) { + onScriptTerminalStarted?.(result.terminalId); + } + }, + }); + const startScript = startScriptMutation.mutate; + + const stopScriptMutation = useMutation({ + mutationFn: async (scriptName: string) => { + if (!client) { + throw new Error(t("common.errors.daemonClientUnavailable")); + } + const terminalId = scripts.find((s) => s.scriptName === scriptName)?.terminalId; + if (!terminalId) { + throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); + } + const result = await client.killTerminal(terminalId); + if (!result.success) { + throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); + } + }, + onError: (error, scriptName) => { + pendingRestartRef.current.delete(scriptName); + toast.show( + error instanceof Error + ? error.message + : t("workspace.scripts.states.stopFailed", { scriptName }), + { + variant: "error", + }, + ); + }, + }); + const stopScript = stopScriptMutation.mutate; + + // Restart = kill the script terminal, then start again once the daemon + // reports the script as stopped (it tears the runtime entry down on exit). + useEffect(() => { + const pending = pendingRestartRef.current; + if (pending.size === 0) return; + for (const script of scripts) { + if (!pending.has(script.scriptName) || script.lifecycle === "running") continue; + pending.delete(script.scriptName); + startScript(script.scriptName); + } + }, [scripts, startScript]); + + const restart = useCallback( + (scriptName: string) => { + pendingRestartRef.current.add(scriptName); + stopScript(scriptName); + }, + [stopScript], + ); + + const copyUrl = useCallback( + (target: WorkspaceScriptLinkTarget) => { + void Clipboard.setStringAsync(target.url); + toast.copied(target.label); + }, + [toast], + ); + + const selectRoute = useCallback( + (kind: WorkspaceScriptLinkKind) => setPreferredRoute(serverId, kind), + [serverId, setPreferredRoute], + ); + + const resolveLinks = useCallback( + (script: WorkspaceScript): WorkspaceScriptLinks => { + const link = resolveWorkspaceScriptLink({ script, activeConnection }); + const selected = + link.targets.find((target) => target.kind === preferredRouteKind) ?? link.primary; + return { selected, targets: link.targets }; + }, + [activeConnection, preferredRouteKind], + ); + + return useMemo( + () => ({ + start: startScript, + stop: stopScript, + restart, + copyUrl, + selectRoute, + resolveLinks, + isStartPending: startScriptMutation.isPending, + isStopPending: stopScriptMutation.isPending, + }), + [ + startScript, + stopScript, + restart, + copyUrl, + selectRoute, + resolveLinks, + startScriptMutation.isPending, + stopScriptMutation.isPending, + ], + ); +} diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx similarity index 96% rename from packages/app/src/screens/workspace/workspace-scripts-button.test.tsx rename to packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx index 82e0132cd3..290f424e97 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx @@ -8,7 +8,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRoot } from "react-dom/client"; -import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; +import { WorkspaceScriptsButton } from "./workspace-scripts-button"; void testI18n; @@ -235,7 +235,6 @@ const LIVE_TERMINAL_IDS: string[] = ["terminal-script-1"]; interface RenderScriptsOptions { hideLabels?: boolean; - presentation?: "split" | "ghost"; } function renderScripts( @@ -264,7 +263,6 @@ function renderScripts( scripts={nextScripts} liveTerminalIds={LIVE_TERMINAL_IDS} hideLabels={options.hideLabels} - presentation={options.presentation} /> ); @@ -431,17 +429,6 @@ describe("WorkspaceScriptsButton", () => { ); }); - it("removes the trigger caret in ghost presentation", () => { - current = renderScripts([script({ scriptName: "dev" })], { - hideLabels: true, - presentation: "ghost", - }); - - const trigger = document.querySelector('[data-testid="workspace-scripts-button"]'); - expect(trigger?.querySelector('[data-icon="Play"]')?.getAttribute("data-size")).toBe("16"); - expect(trigger?.querySelector('[data-icon="ChevronDown"]')).toBeNull(); - }); - it("keeps the trigger caret in split presentation", () => { current = renderScripts([script({ scriptName: "dev" })]); diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx similarity index 63% rename from packages/app/src/screens/workspace/workspace-scripts-button.tsx rename to packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx index 59ddd9a0fb..3a1c472518 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx @@ -1,13 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, type ReactElement } from "react"; +import { useCallback, useMemo, type ReactElement } from "react"; import type { GestureResponderEvent } from "react-native"; import { Pressable, Text, View } from "react-native"; -import * as Clipboard from "expo-clipboard"; -import { useMutation } from "@tanstack/react-query"; import { ChevronDown, Copy, Eye, - Globe, Play, RotateCw, Square, @@ -16,8 +13,6 @@ import { import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; -import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { DropdownMenu, DropdownMenuContent, @@ -27,17 +22,20 @@ import { } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { MENU_ITEM_HEIGHT } from "@/components/ui/menu/menu-geometry"; -import { useToast } from "@/contexts/toast-context"; import { openServiceUrl } from "@/utils/open-service-url"; -import { - resolveWorkspaceScriptLink, - type WorkspaceScriptLinkKind, - type WorkspaceScriptLinkTarget, +import type { + WorkspaceScriptLinkKind, + WorkspaceScriptLinkTarget, } from "@/utils/workspace-script-links"; import type { Theme } from "@/styles/theme"; -import { useWorkspaceServiceRoutePreferencesStore } from "@/workspace-service-routes/store"; import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; import { extraMutedIconColorMapping } from "@/components/ui/icon-color"; +import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks"; +import { scriptToneColorMapping } from "./script-status"; +import { + useWorkspaceScriptActions, + type WorkspaceScriptActions, +} from "./use-workspace-script-actions"; type RowActionIcon = "copy" | "open" | "restart" | "start" | "stop" | "terminal"; @@ -50,37 +48,23 @@ interface WorkspaceScriptsButtonProps { onViewTerminal?: (terminalId: string) => void; onOpenUrlInBrowserTab?: (url: string) => void; hideLabels?: boolean; - presentation?: "split" | "ghost"; } const ThemedPlay = withUnistyles(Play); const ThemedSquareTerminal = withUnistyles(SquareTerminal); -const ThemedGlobe = withUnistyles(Globe); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedEye = withUnistyles(Eye); const ThemedCopy = withUnistyles(Copy); const ThemedRotateCw = withUnistyles(RotateCw); const ThemedSquare = withUnistyles(Square); -const GHOST_TRIGGER_ICON_SIZE = 16; - const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground, }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -const blueColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.blue[500], -}); -const greenColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.green[500], -}); -const redColorMapping = (theme: Theme) => ({ - color: theme.colors.palette.red[500], -}); const playFillTransparent = { fill: "transparent" }; -const ghostPlayStroke = { strokeWidth: 1.5 }; interface ScriptRowActionButtonProps { accessibilityLabel: string; @@ -163,23 +147,7 @@ interface ServiceLinkRowProps { targets: WorkspaceScriptLinkTarget[]; scriptName: string; onSelectKind: (kind: WorkspaceScriptLinkKind) => void; - onCopy: (url: string, label: string) => void; -} - -function routeLabelKey( - kind: WorkspaceScriptLinkKind, -): - | "workspace.scripts.routes.public" - | "workspace.scripts.routes.paseo" - | "workspace.scripts.routes.direct" { - switch (kind) { - case "public": - return "workspace.scripts.routes.public"; - case "paseo": - return "workspace.scripts.routes.paseo"; - case "direct": - return "workspace.scripts.routes.direct"; - } + onCopy: (target: WorkspaceScriptLinkTarget) => void; } function ServiceRouteOption({ @@ -298,12 +266,12 @@ function ServiceLinkRow({ }: ServiceLinkRowProps): ReactElement { const { t } = useTranslation(); const closeMenu = useDropdownMenuClose(); - const { label, url } = selectedTarget; + const { label } = selectedTarget; const handleCopy = useCallback(() => { closeMenu(); - onCopy(url, label); - }, [url, label, onCopy, closeMenu]); + onCopy(selectedTarget); + }, [selectedTarget, onCopy, closeMenu]); return ( @@ -333,84 +301,29 @@ function ServiceLinkRow({ ); } -function ExitCodeBadge({ code }: { code: number }): ReactElement { - const { t } = useTranslation(); - const exitTextStyle = - code === 0 ? styles.exitBadgeText : [styles.exitBadgeText, styles.exitBadgeTextError]; - return ( - - {t("workspace.scripts.states.exitCode", { code })} - - ); -} - interface ScriptRowProps { script: WorkspaceDescriptor["scripts"][number]; liveTerminalIdSet: Set; - activeConnection: ReturnType extends infer R - ? R extends { activeConnection: infer A } - ? A - : null - : null; - isStartPending: boolean; - isStopPending: boolean; - onStartScript: (scriptName: string) => void; - onStopScript: (scriptName: string) => void; - onRestartScript: (scriptName: string) => void; - onCopyUrl: (url: string, label: string) => void; - preferredRouteKind: WorkspaceScriptLinkKind | null; - onSelectRouteKind: (kind: WorkspaceScriptLinkKind) => void; + actions: WorkspaceScriptActions; onViewTerminal?: (terminalId: string) => void; onOpenUrlInBrowserTab?: (url: string) => void; } -function resolveScriptIconColorMapping(args: { - script: WorkspaceDescriptor["scripts"][number]; - isService: boolean; - isRunning: boolean; -}): (theme: Theme) => { color: string } { - const { script, isService, isRunning } = args; - if (isService) { - if (isRunning && script.health === "healthy") return greenColorMapping; - if (isRunning && script.health === "unhealthy") return redColorMapping; - if (isRunning) return blueColorMapping; - return mutedColorMapping; - } - if (isRunning) return blueColorMapping; - return mutedColorMapping; -} - function ScriptRow({ script, liveTerminalIdSet, - activeConnection, - isStartPending, - isStopPending, - onStartScript, - onStopScript, - onRestartScript, - onCopyUrl, - preferredRouteKind, - onSelectRouteKind, + actions, onViewTerminal, onOpenUrlInBrowserTab, }: ScriptRowProps): ReactElement { const { t } = useTranslation(); const isRunning = script.lifecycle === "running"; - const isService = (script.type ?? "service") === "service"; const exitCode = script.exitCode ?? null; - const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection }); - const selectedLink = - isService && isRunning - ? (serviceLink.targets.find((target) => target.kind === preferredRouteKind) ?? - serviceLink.primary) - : null; + const { selected: selectedLink, targets: linkTargets } = actions.resolveLinks(script); const liveTerminalId = script.terminalId && liveTerminalIdSet.has(script.terminalId) ? script.terminalId : null; - - const iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning }); - const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal; const showExitBadge = !isRunning && exitCode !== null; + const { start, stop, restart } = actions; const closeMenu = useDropdownMenuClose(); const handleOpenService = useCallback(() => { @@ -424,16 +337,16 @@ function ScriptRow({ }, [liveTerminalId, onViewTerminal]); const handleRun = useCallback(() => { - onStartScript(script.scriptName); - }, [onStartScript, script.scriptName]); + start(script.scriptName); + }, [start, script.scriptName]); const handleStop = useCallback(() => { - onStopScript(script.scriptName); - }, [onStopScript, script.scriptName]); + stop(script.scriptName); + }, [stop, script.scriptName]); const handleRestart = useCallback(() => { - onRestartScript(script.scriptName); - }, [onRestartScript, script.scriptName]); + restart(script.scriptName); + }, [restart, script.scriptName]); const scriptNameStyle = useMemo( () => (isRunning ? [styles.scriptName, styles.scriptNameActive] : styles.scriptName), @@ -471,7 +384,7 @@ function ScriptRow({ scriptName: script.scriptName, })} testID={`workspace-scripts-stop-${script.scriptName}`} - disabled={isStopPending} + disabled={actions.isStopPending} icon="stop" onPress={handleStop} tooltipLabel={t("workspace.scripts.actions.stop")} @@ -482,7 +395,7 @@ function ScriptRow({ scriptName: script.scriptName, })} testID={`workspace-scripts-start-${script.scriptName}`} - disabled={isStartPending} + disabled={actions.isStartPending} icon="start" onPress={handleRun} tooltipLabel={t("workspace.scripts.actions.run")} @@ -497,7 +410,7 @@ function ScriptRow({ })} > - + {script.scriptName} @@ -511,7 +424,7 @@ function ScriptRow({ scriptName: script.scriptName, })} testID={`workspace-scripts-restart-${script.scriptName}`} - disabled={isStopPending} + disabled={actions.isStopPending} icon="restart" onPress={handleRestart} tooltipLabel={t("workspace.scripts.actions.restart")} @@ -523,10 +436,10 @@ function ScriptRow({ ) : null} @@ -543,142 +456,35 @@ export function WorkspaceScriptsButton({ onViewTerminal, onOpenUrlInBrowserTab, hideLabels, - presentation = "split", }: WorkspaceScriptsButtonProps): ReactElement | null { const { t } = useTranslation(); - const toast = useToast(); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; - const preferredRouteKind = useWorkspaceServiceRoutePreferencesStore( - (state) => state.byServerId[serverId] ?? null, - ); - const setPreferredRoute = useWorkspaceServiceRoutePreferencesStore( - (state) => state.setPreferredRoute, - ); + const actions = useWorkspaceScriptActions({ + serverId, + workspaceId, + scripts, + onScriptTerminalStarted, + }); const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]); - const pendingRestartRef = useRef>(new Set()); - - const startScriptMutation = useMutation({ - mutationFn: async (scriptName: string) => { - if (!client) { - throw new Error(t("common.errors.daemonClientUnavailable")); - } - const result = await client.startWorkspaceScript(workspaceId, scriptName); - if (result.error) { - throw new Error(result.error); - } - return result; - }, - onError: (error, scriptName) => { - toast.show( - error instanceof Error - ? error.message - : t("workspace.scripts.states.startFailed", { scriptName }), - { - variant: "error", - }, - ); - }, - onSuccess: (result) => { - if (result.terminalId) { - onScriptTerminalStarted?.(result.terminalId); - } - }, - }); - const startScript = startScriptMutation.mutate; - - const stopScriptMutation = useMutation({ - mutationFn: async (scriptName: string) => { - if (!client) { - throw new Error(t("common.errors.daemonClientUnavailable")); - } - const terminalId = scripts.find((s) => s.scriptName === scriptName)?.terminalId; - if (!terminalId) { - throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); - } - const result = await client.killTerminal(terminalId); - if (!result.success) { - throw new Error(t("workspace.scripts.states.stopFailed", { scriptName })); - } - }, - onError: (error, scriptName) => { - pendingRestartRef.current.delete(scriptName); - toast.show( - error instanceof Error - ? error.message - : t("workspace.scripts.states.stopFailed", { scriptName }), - { - variant: "error", - }, - ); - }, - }); - - // Restart = kill the script terminal, then start again once the daemon - // reports the script as stopped (it tears the runtime entry down on exit). - useEffect(() => { - const pending = pendingRestartRef.current; - if (pending.size === 0) return; - for (const script of scripts) { - if (!pending.has(script.scriptName) || script.lifecycle === "running") continue; - pending.delete(script.scriptName); - startScript(script.scriptName); - } - }, [scripts, startScript]); const triggerStyle = useCallback( ({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [ - presentation === "ghost" ? styles.ghostButton : styles.splitButtonPrimary, - (hovered || pressed || open) && - (presentation === "ghost" ? styles.ghostButtonHovered : styles.splitButtonPrimaryHovered), + styles.splitButtonPrimary, + (hovered || pressed || open) && styles.splitButtonPrimaryHovered, ], - [presentation], - ); - - const handleStartScript = useCallback( - (scriptName: string) => startScriptMutation.mutate(scriptName), - [startScriptMutation], - ); - - const handleStopScript = useCallback( - (scriptName: string) => stopScriptMutation.mutate(scriptName), - [stopScriptMutation], - ); - - const handleRestartScript = useCallback( - (scriptName: string) => { - pendingRestartRef.current.add(scriptName); - stopScriptMutation.mutate(scriptName); - }, - [stopScriptMutation], - ); - - const handleCopyUrl = useCallback( - (url: string, label: string) => { - void Clipboard.setStringAsync(url); - toast.copied(label); - }, - [toast], - ); - - const handleSelectRouteKind = useCallback( - (kind: WorkspaceScriptLinkKind) => setPreferredRoute(serverId, kind), - [serverId, setPreferredRoute], + [], ); if (scripts.length === 0) { return null; } + // The trigger only says whether anything runs; health is for the rows inside. const hasAnyRunning = scripts.some((s) => s.lifecycle === "running"); - const triggerPlayMapping = hasAnyRunning ? blueColorMapping : mutedColorMapping; - const triggerIconSize = presentation === "ghost" ? GHOST_TRIGGER_ICON_SIZE : 14; - const triggerPlayProps = - presentation === "ghost" ? { ...playFillTransparent, ...ghostPlayStroke } : playFillTransparent; + const triggerPlayMapping = scriptToneColorMapping(hasAnyRunning ? "running" : "idle"); return ( - + - + {!hideLabels && ( {t("workspace.scripts.title")} )} - {presentation === "split" ? ( - - ) : null} + @@ -750,21 +542,6 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.borderAccent, overflow: "hidden", }, - ghostButtonFrame: { - flexDirection: "row", - alignItems: "stretch", - }, - ghostButton: { - width: theme.spacing[8], - height: theme.spacing[8], - padding: 0, - borderRadius: theme.borderRadius.lg, - alignItems: "center", - justifyContent: "center", - }, - ghostButtonHovered: { - backgroundColor: theme.colors.surface2, - }, splitButtonPrimary: { paddingHorizontal: { xs: theme.spacing[3], @@ -798,9 +575,6 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], minHeight: MENU_ITEM_HEIGHT, }, - scriptIcon: { - flexShrink: 0, - }, scriptName: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, @@ -844,21 +618,6 @@ const styles = StyleSheet.create((theme) => ({ hostLabelActive: { color: theme.colors.foreground, }, - exitBadge: { - paddingHorizontal: theme.spacing[1.5], - paddingVertical: 1, - borderRadius: 2, - backgroundColor: theme.colors.surface2, - }, - exitBadgeText: { - fontSize: 10, - lineHeight: 12, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foregroundMuted, - }, - exitBadgeTextError: { - color: theme.colors.palette.red[300], - }, iconActionButton: { padding: 2, }, diff --git a/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx new file mode 100644 index 0000000000..3d14d9faff --- /dev/null +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx @@ -0,0 +1,416 @@ +import { useCallback, useMemo, type ReactElement } from "react"; +import { View } from "react-native"; +import { Copy, Eye, Play, RotateCw, Square, SquareTerminal } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSubTrigger, + type MenuPageDefinition, +} from "@/components/ui/dropdown-menu"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { Theme } from "@/styles/theme"; +import { openServiceUrl } from "@/utils/open-service-url"; +import type { + WorkspaceScriptLinkKind, + WorkspaceScriptLinkTarget, +} from "@/utils/workspace-script-links"; +import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks"; +import { scriptToneColorMapping, summarizeScripts, type ScriptsSummary } from "./script-status"; +import { + useWorkspaceScriptActions, + type WorkspaceScriptActions, +} from "./use-workspace-script-actions"; + +type WorkspaceScript = WorkspaceDescriptor["scripts"][number]; + +const ThemedPlay = withUnistyles(Play); +const ThemedEye = withUnistyles(Eye); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); +const ThemedCopy = withUnistyles(Copy); +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedSquare = withUnistyles(Square); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const playFillTransparent = { fill: "transparent" }; + +const ROW_ICON_SIZE = 16; +const TRAILING_ICON_SIZE = 14; + +const OPEN_SERVICE_ICON = ; +const VIEW_TERMINAL_ICON = ( + +); +const COPY_URL_ICON = ; +const RESTART_ICON = ; +const STOP_ICON = ; +const RUN_ICON = ( + +); + +const SCRIPTS_PAGE_ID = "workspace-scripts"; + +function scriptPageId(scriptName: string): string { + return `${SCRIPTS_PAGE_ID}/${scriptName}`; +} + +export interface UseWorkspaceScriptsMenuPagesInput { + serverId: string; + workspaceId: string; + scripts: WorkspaceDescriptor["scripts"]; + liveTerminalIds: readonly string[]; + onScriptTerminalStarted: (terminalId: string) => void; + onViewTerminal: (terminalId: string) => void; + onOpenUrlInBrowserTab: (url: string) => void; +} + +export interface WorkspaceScriptsMenuPages { + /** The root row that leads to the scripts page. Null when the workspace has no scripts. */ + trigger: ReactElement | null; + pages: MenuPageDefinition[]; + /** A running service reports unhealthy, so the menu's own trigger should say so. */ + needsAttention: boolean; +} + +/** + * Workspace scripts as pages of a sheet menu: a Scripts row on the root, a page listing every + * script, and one page per script. + * + * Every script gets a page whether or not it runs, so stopping one from its own page flips that + * page to its stopped state instead of pulling it out from under the user. + * + * The actions live here rather than in a page, so call this from whatever owns the menu: a restart + * finishes after the sheet has closed. + */ +export function useWorkspaceScriptsMenuPages({ + serverId, + workspaceId, + scripts, + liveTerminalIds, + onScriptTerminalStarted, + onViewTerminal, + onOpenUrlInBrowserTab, +}: UseWorkspaceScriptsMenuPagesInput): WorkspaceScriptsMenuPages { + const { t } = useTranslation(); + const actions = useWorkspaceScriptActions({ + serverId, + workspaceId, + scripts, + onScriptTerminalStarted, + }); + const summary = useMemo(() => summarizeScripts(scripts), [scripts]); + + const trigger = useMemo( + () => (scripts.length === 0 ? null : ), + [scripts.length, summary], + ); + + const pages = useMemo(() => { + if (scripts.length === 0) return []; + const liveTerminalIdSet = new Set(liveTerminalIds); + return [ + { + id: SCRIPTS_PAGE_ID, + title: t("workspace.scripts.title"), + content: , + }, + ...scripts.map((script) => ({ + id: scriptPageId(script.scriptName), + title: script.scriptName, + content: ( + + ), + })), + ]; + }, [actions, liveTerminalIds, onOpenUrlInBrowserTab, onViewTerminal, scripts, t]); + + return { trigger, pages, needsAttention: summary.tone === "unhealthy" }; +} + +/** The root row: ▶ in the colour of the whole set, and what is running as its value. */ +function ScriptsMenuTrigger({ summary }: { summary: ScriptsSummary }): ReactElement { + const { t } = useTranslation(); + const leading = useMemo( + () => ( + + ), + [summary.tone], + ); + const [onlyRunning] = summary.running; + const value = + summary.running.length > 1 + ? t("workspace.scripts.states.runningCount", { count: summary.running.length }) + : onlyRunning; + return ( + + {t("workspace.scripts.title")} + + ); +} + +function ScriptsPage({ + scripts, + actions, +}: { + scripts: WorkspaceDescriptor["scripts"]; + actions: WorkspaceScriptActions; +}): ReactElement { + return ( + <> + {scripts.map((script) => + script.lifecycle === "running" ? ( + + ) : ( + + ), + )} + + ); +} + +function RunningScriptRow({ script }: { script: WorkspaceScript }): ReactElement { + const { t } = useTranslation(); + const leading = useMemo(() => , [script]); + return ( + + {script.scriptName} + + ); +} + +/** Pressing a stopped script runs it. Starting opens its terminal tab, so the sheet closes. */ +function StoppedScriptRow({ + script, + actions, +}: { + script: WorkspaceScript; + actions: WorkspaceScriptActions; +}): ReactElement { + const { start } = actions; + const handleSelect = useCallback(() => start(script.scriptName), [start, script.scriptName]); + const exitCode = script.exitCode ?? null; + const leading = useMemo(() => , [script]); + const trailing = useMemo( + () => ( + + {exitCode === null ? null : } + + + ), + [exitCode], + ); + return ( + + {script.scriptName} + + ); +} + +interface ScriptPageProps { + script: WorkspaceScript; + actions: WorkspaceScriptActions; + liveTerminalId: string | null; + onViewTerminal: (terminalId: string) => void; + onOpenUrlInBrowserTab: (url: string) => void; +} + +function ScriptPage(props: ScriptPageProps): ReactElement { + return props.script.lifecycle === "running" ? ( + + ) : ( + + ); +} + +/** + * Rows that open something — the service, the terminal, the clipboard toast — close the sheet. + * Stop and the URL choice stay: the page is where their result shows. + */ +function RunningScriptPage({ + script, + actions, + liveTerminalId, + onViewTerminal, + onOpenUrlInBrowserTab, +}: ScriptPageProps): ReactElement { + const { t } = useTranslation(); + const { scriptName } = script; + const { selected, targets } = actions.resolveLinks(script); + const { stop, restart, copyUrl, selectRoute } = actions; + + const handleOpenService = useCallback(() => { + if (!selected) return; + void openServiceUrl(selected.url, { openInApp: onOpenUrlInBrowserTab }); + }, [selected, onOpenUrlInBrowserTab]); + const handleViewTerminal = useCallback(() => { + if (liveTerminalId) onViewTerminal(liveTerminalId); + }, [liveTerminalId, onViewTerminal]); + const handleCopyUrl = useCallback(() => { + if (selected) copyUrl(selected); + }, [selected, copyUrl]); + const handleRestart = useCallback(() => restart(scriptName), [restart, scriptName]); + const handleStop = useCallback(() => stop(scriptName), [stop, scriptName]); + + return ( + <> + {selected ? ( + + {t("workspace.scripts.actions.openService")} + + ) : null} + {liveTerminalId ? ( + + {t("workspace.scripts.actions.view")} + + ) : null} + {selected ? ( + + {t("workspace.scripts.actions.copyUrl")} + + ) : null} + + {t("workspace.scripts.actions.restart")} + + + {t("workspace.scripts.actions.stop")} + + {selected && targets.length > 1 ? ( + <> + + {targets.map((target) => ( + + ))} + + ) : null} + + ); +} + +/** A pick-one URL route. Choosing one keeps the page open so the check moves under the thumb. */ +function ServiceRouteOption({ + scriptName, + target, + selected, + onSelect, +}: { + scriptName: string; + target: WorkspaceScriptLinkTarget; + selected: boolean; + onSelect: (kind: WorkspaceScriptLinkKind) => void; +}): ReactElement { + const { t } = useTranslation(); + const handleSelect = useCallback(() => onSelect(target.kind), [onSelect, target.kind]); + return ( + + {t(routeLabelKey(target.kind))} + + ); +} + +function StoppedScriptPage({ + script, + actions, +}: { + script: WorkspaceScript; + actions: WorkspaceScriptActions; +}): ReactElement { + const { t } = useTranslation(); + const { start } = actions; + const handleRun = useCallback(() => start(script.scriptName), [start, script.scriptName]); + const exitCode = script.exitCode ?? null; + const trailing = useMemo( + () => (exitCode === null ? null : ), + [exitCode], + ); + return ( + + {t("workspace.scripts.actions.run")} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + }, +})); diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx new file mode 100644 index 0000000000..2b4e927a13 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -0,0 +1,311 @@ +import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet"; +import { useIsFocused } from "@react-navigation/native"; +import { + type ComponentType, + type FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useStore } from "zustand"; +import { createStore } from "zustand/vanilla"; +import { Maximize2 } from "lucide-react-native"; +import { + SheetBackground, + SheetHandle, + SheetHeaderIconButton, + SheetHeaderView, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { + IsolatedBottomSheetModal, + type IsolatedBottomSheetModalRef, + systemBackPress, +} from "@/components/ui/isolated-bottom-sheet-modal"; +import type { PanelIconProps } from "@/panels/panel-registry"; +import { getPanelRegistration } from "@/panels/panel-registry"; +import { ensurePanelsRegistered } from "@/panels/register-panels"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { + WorkspacePaneContent, + type WorkspacePaneContentModel, +} from "@/screens/workspace/workspace-pane-content"; +import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; + +const SNAP_POINTS = ["92%"]; +const NO_SHEETS: readonly SheetTab[] = []; + +function PanelIcon({ + icon: Icon, + size, + color, +}: { icon: ComponentType } & PanelIconProps) { + return ; +} + +const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +interface SheetTabHandleValue { + header: SheetHeader; + onClose: () => void; +} + +/** + * The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and + * renders it on the far side of the portal: a new function per render would remount the header on + * every change, and no context from here reaches it. So each sheet gets one handle component for + * its lifetime, which reads the header through a store the sheet keeps current. + */ +function useSheetTabHandle(value: SheetTabHandleValue): FC { + const [store] = useState(() => createStore(() => value)); + useEffect(() => store.setState(value, true), [store, value]); + const [Handle] = useState( + () => + function SheetTabHandle(props: BottomSheetHandleProps) { + const { header, onClose } = useStore(store); + return ( + + + + ); + }, + ); + return Handle; +} + +export interface SheetTabsProps { + workspaceKey: string | null; + serverId: string; + workspaceId: string; + isWorkspaceFocused: boolean; + buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; + /** The workspace's ordinary tab close, including its unsaved-changes guard. */ + onCloseTab: (tabId: string) => Promise; +} + +interface SheetTabSheetProps extends Omit { + sheet: SheetTab; + /** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */ + isTop: boolean; + /** Runs the guarded close and resolves to whether the tab is still open. */ + onClose: (tabId: string) => Promise; + onOpenAsTab: (tabId: string) => void; +} + +/** + * The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. They present from inside + * the workspace's `SheetHostScope`, so each panel keeps every context an ordinary pane has, and a + * sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of + * it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`, + * `SheetHeaderView`) keep them looking and dragging like every other sheet. + */ +export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) { + const sheets = useWorkspaceLayoutStore((state) => + workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, + ); + const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); + const handleOpenAsTab = useCallback( + (tabId: string) => { + if (workspaceKey) openSheetAsTab(workspaceKey, tabId); + }, + [openSheetAsTab, workspaceKey], + ); + const handleClose = useCallback( + async (tabId: string) => { + await onCloseTab(tabId); + // A declined unsaved-changes prompt leaves the tab in its sheet. + const open = workspaceKey + ? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey] + : undefined; + return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false; + }, + [onCloseTab, workspaceKey], + ); + return ( + <> + {sheets.map((sheet, index) => ( + + ))} + + ); +} + +function SheetTabSheet({ + sheet, + isTop, + serverId, + workspaceId, + isWorkspaceFocused, + buildContent, + onClose, + onOpenAsTab, +}: SheetTabSheetProps) { + const { t } = useTranslation(); + const { tab } = sheet; + const sheetRef = useRef(null); + const isScreenFocused = useIsFocused(); + ensurePanelsRegistered(); + const registration = getPanelRegistration(tab.target.kind); + if (!registration) { + throw new Error(`No panel registration for kind: ${tab.target.kind}`); + } + const descriptor = registration.useDescriptor(tab.target as never, { + serverId, + workspaceId, + tabId: tab.tabId, + }); + + // The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off + // the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still + // reports that as `onChange(-1)`, which must not read as the user closing it: the store already + // moved on, and the same tab id may now be a pane tab (Open as tab). + const unmounted = useRef(false); + useEffect(() => { + sheetRef.current?.present(); + return () => { + unmounted.current = true; + }; + }, []); + + // Every close path (drag, backdrop, Close, Back) slides the sheet down first; `onChange(-1)` then + // runs the one guarded close. The sheet keeps its content through the close, so a declined + // unsaved-changes prompt only has to bring it back up. + const close = useCallback(() => sheetRef.current?.close(), []); + const handleChange = useCallback( + (index: number) => { + if (index !== -1 || unmounted.current) return; + void onClose(tab.tabId).then((stillOpen) => { + if (stillOpen) sheetRef.current?.snapToIndex(0); + return stillOpen; + }); + }, + [onClose, tab.tabId], + ); + // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns Back. + const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused; + useEffect(() => { + if (!ownsBackPress) return; + return systemBackPress.subscribe(() => { + close(); + return true; + }); + }, [close, ownsBackPress]); + + const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); + const header = useMemo( + () => ({ + title: descriptor.label, + leading: , + // The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into + // the switcher, where it gets its composer back. + actions: sheet.policy.openAsTab ? ( + + ) : undefined, + }), + [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], + ); + const handleValue = useMemo( + () => ({ header, onClose: close }), + [close, header], + ); + // The header is the sheet's handle: the panel scrolls on its own, so dragging the content would + // fight it, and the header is where people reach to pull the sheet down. + const SheetTabHandle = useSheetTabHandle(handleValue); + + return ( + + {/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */} + + + ); +} + +function SheetTabBody({ + tab, + label, + isPaneFocused, + isWorkspaceFocused, + buildContent, +}: { + tab: SheetTab["tab"]; + label: string; + /** Only the top sheet's pane is focused; the ones below are covered. */ + isPaneFocused: boolean; + isWorkspaceFocused: boolean; + buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; +}) { + const tabDescriptor = useMemo( + () => ({ + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + state: tab.state, + }), + [tab], + ); + const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]); + // The sheet reaches the bottom of the screen, so it owns the system navigation inset. + const { bottom: bottomInset } = useSafeAreaInsets(); + const contentStyle = useMemo( + () => [styles.content, { paddingBottom: bottomInset }], + [bottomInset], + ); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + content: { + flex: 1, + minHeight: 0, + }, +}); diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts index 6620889475..d7b6f33a72 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.test.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts @@ -110,3 +110,25 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to }), }).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] }); }); + +test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => { + const layout: WorkspaceLayout = { + focusedPaneId: "main", + root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } }, + }; + const tabs: WorkspaceTab[] = [ + { tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + ]; + const sheetTab: WorkspaceTab = { + tabId: "sheet", + target: { kind: "agent", agentId: "agent-child" }, + createdAt: 2, + }; + + expect( + selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }), + ).toEqual(["agent-a", "agent-child"]); + expect( + selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }), + ).toEqual([]); +}); diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts index 6fdd246a9b..324d4121c3 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -6,6 +6,8 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state"; export function selectVisibleAgentIds(input: { layout: WorkspaceLayout | null; tabs: WorkspaceTab[]; + /** The workspace sheet's tab, shown over the panes. */ + sheetTab?: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; }): string[] { @@ -16,20 +18,21 @@ export function selectVisibleAgentIds(input: { ? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId) : collectAllPanes(input.layout.root); + const targets = panes.flatMap((pane) => { + const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor + .target; + return target ? [target] : []; + }); + if (input.sheetTab) targets.push(input.sheetTab.target); return [ - ...new Set( - panes.flatMap((pane) => { - const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor - .target; - return target?.kind === "agent" ? [target.agentId] : []; - }), - ), + ...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))), ].sort(); } export function useVisibleAgentIds(input: { layout: WorkspaceLayout | null; tabs: WorkspaceTab[]; + sheetTab: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; }): string[] { diff --git a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx index 041aa6954b..5e8c3eea78 100644 --- a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx +++ b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx @@ -11,6 +11,8 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; const ThemedPanelRight = withUnistyles(PanelRight); +export const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; + interface WorkspaceExplorerToggleProps { onPress: () => void; label: string; diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx index 1ffd99425f..74a95c7548 100644 --- a/packages/app/src/screens/workspace/workspace-header-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-header-menu.tsx @@ -2,14 +2,7 @@ import React, { useCallback, useMemo } from "react"; import { View } from "react-native"; import { useRouter, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; -import { - Copy, - Ellipsis, - Globe, - Import as ImportIcon, - Settings, - SquarePen, -} from "lucide-react-native"; +import { Copy, Ellipsis, Globe, Import as ImportIcon, Settings } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; import { @@ -24,6 +17,7 @@ import { extraMutedIconColorMapping, iconButtonChromeGlyphSize, iconButtonChromeStyle, + useIconButtonChromeStyle, } from "@/components/ui/icon-button-chrome"; import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; @@ -33,19 +27,20 @@ import { } from "@getpaseo/protocol/terminal-profiles"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { Theme } from "@/styles/theme"; +import { + useWorkspaceScriptsMenuPages, + type UseWorkspaceScriptsMenuPagesInput, +} from "@/screens/workspace/scripts"; const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedCopy = withUnistyles(Copy); -const ThemedSquarePen = withUnistyles(SquarePen); const ThemedGlobe = withUnistyles(Globe); const ThemedImport = withUnistyles(ImportIcon); const ThemedSettings = withUnistyles(Settings); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const MENU_NEW_AGENT_ICON = ; const MENU_NEW_BROWSER_ICON = ; -const MENU_NEW_TERMINAL_ICON = ; const MENU_IMPORT_ICON = ; const MENU_COPY_ICON = ; const MENU_SETTINGS_ICON = ; @@ -58,6 +53,16 @@ function WorkspaceHeaderMenuTriggerIcon() { ); } +/** The ⋯ with a dot on it, for when something inside the menu needs looking at. */ +function WorkspaceHeaderMenuAttentionTriggerIcon() { + return ( + + + + + ); +} + /** * The compact header buttons draw at 32pt, under the 44pt touch minimum, and sit flush against * each other — so the slop can only grow vertically. Widening it would put two buttons' slop over @@ -201,30 +206,32 @@ function HeaderMenuProfileItem({ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions { normalizedServerId: string; + scripts: UseWorkspaceScriptsMenuPagesInput; showCreateBrowserTab: boolean; createTerminalDisabled: boolean; - onCreateDraftTab: () => void; - onCreateTerminal: () => void; onCreateTerminalWithProfile: (profile: TerminalProfile) => void; onCreateBrowser: () => void; } /** - * Compact layouts have no tab strip to launch from, so new tabs live here alongside the workspace - * actions. + * Compact layouts have no tab strip. New agent and New terminal sit in the tab switcher's footer; + * the rarer launches stay here alongside the workspace actions. + * + * The header has no room for a scripts button either, so scripts are a page of this menu, and the + * ⋯ carries a dot while a running service is unhealthy. */ export function WorkspaceHeaderMenuMobile({ normalizedServerId, + scripts, showCreateBrowserTab, createTerminalDisabled, - onCreateDraftTab, - onCreateTerminal, onCreateTerminalWithProfile, onCreateBrowser, ...workspaceActions }: WorkspaceHeaderMenuMobileProps) { const { t } = useTranslation(); const router = useRouter(); + const triggerStyle = useIconButtonChromeStyle({ size: "large" }); const { config } = useDaemonConfig(normalizedServerId); const profiles = useMemo( () => resolveTerminalProfiles(config?.terminalProfiles), @@ -234,31 +241,35 @@ export function WorkspaceHeaderMenuMobile({ const handleEditProfiles = useCallback(() => { router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href); }, [normalizedServerId, router]); + const scriptsMenu = useWorkspaceScriptsMenuPages(scripts); return ( - + {scriptsMenu.needsAttention ? ( + + ) : ( + + )} - - {t("workspace.header.actions.newAgent")} - + {scriptsMenu.trigger} {showCreateBrowserTab ? ( {t("workspace.tabs.actions.terminalProfilesMenu")} - - {t("workspace.header.actions.newTerminal")} - {profiles.map((profile) => ( ({ headerMenuProfileIconWrapper: { width: 16, height: 16, }, -}); + // Same red as the Scripts row's unhealthy ▶, so the dot and the row it leads to agree. + attentionDot: { + position: "absolute", + top: -1, + right: -2, + width: ATTENTION_DOT_SIZE, + height: ATTENTION_DOT_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.palette.red[500], + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-header-project-row.tsx b/packages/app/src/screens/workspace/workspace-header-project-row.tsx new file mode 100644 index 0000000000..949bf29a19 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-header-project-row.tsx @@ -0,0 +1,68 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { HostBadge } from "@/hosts/host-badge"; +import { useHostBadges } from "@/hosts/use-host-badges"; + +/** + * Which project the workspace belongs to, and which machine it runs on. + * + * Compact gets both, on their own line under the workspace name: this header is the only thing on + * screen that says where the workspace lives, because the sidebar that normally carries the host + * badge is closed. It still follows the host's own badge setting, so a purely local setup stays + * quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit + * side by side, and kept on compact, where the line exists for the host anyway. + */ +export function WorkspaceHeaderProjectRow({ + subtitle, + isSubtitleDistinct, + serverId, +}: { + subtitle: string; + isSubtitleDistinct: boolean; + serverId: string; +}) { + const isCompact = useIsCompactFormFactor(); + const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null; + const showProject = isSubtitleDistinct || isCompact; + if (!showProject && !hostBadge) { + return null; + } + return ( + + {showProject ? ( + + {subtitle} + + ) : null} + {showProject && hostBadge ? · : null} + {hostBadge ? : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // The compact header stretches this row to its available title width. Within that width, + // the project truncates and HostBadge yields its label while retaining its fixed icon. + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + minWidth: 0, + flexShrink: 1, + }, + project: { + color: theme.colors.foregroundMuted, + fontSize: { + xs: theme.fontSize.sm, + md: theme.fontSize.base, + }, + flexShrink: 1, + minWidth: 0, + }, + separator: { + color: theme.colors.foregroundExtraMuted, + fontSize: theme.fontSize.sm, + flexShrink: 0, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index e9534769b3..0ef496a94b 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -1,3 +1,4 @@ +import { ComposerEditingScope } from "@/composer/editing-session"; import React, { useMemo, type ComponentType } from "react"; import invariant from "tiny-invariant"; import { @@ -156,7 +157,12 @@ export function WorkspacePaneContent({ > - + + + diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d94b7e5e82..d8a37b7156 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,4 +1,9 @@ +import { + WorkspaceComposerEditingSessionsProvider, + useComposerTabFullscreen, +} from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; import { @@ -14,23 +19,26 @@ import { } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; -import { BackHandler, Keyboard, Pressable, Text, View } from "react-native"; +import { BackHandler, Text, View } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; -import { ChevronDown } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import invariant from "tiny-invariant"; -import { SidebarMenuToggle } from "@/components/headers/menu-header"; +import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle"; import { ScreenHeader } from "@/components/headers/screen-header"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { FloatingHeaderLayout } from "@/components/floating-header"; +import { StreamTopOverlayProvider, supportsStreamTopOverlay } from "@/agent-stream/top-overlay"; +import { + CompactWorkspaceHeader, + MobileWorkspaceTabSwitcher, +} from "@/screens/workspace/compact-workspace-header"; +import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row"; import { ScreenTitle } from "@/components/headers/screen-title"; -import { HostBadge } from "@/hosts/host-badge"; -import { useHostBadges } from "@/hosts/use-host-badges"; -import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; -import type { ShortcutKey } from "@/utils/format-shortcut"; import { FloatingPanelPortalHost, FloatingPanelPortalHostNameProvider, @@ -39,7 +47,7 @@ import { SplitContainer } from "@/components/split-container"; import { RetainedPanel } from "@/components/retained-panel"; import { WorkspaceActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button"; -import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; +import { WorkspaceScriptsButton } from "@/screens/workspace/scripts"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useNavigateToImportedAgent } from "@/hooks/use-import-session"; import { useToast } from "@/contexts/toast-context"; @@ -69,7 +77,9 @@ import { getFocusedBrowserId, FOCUSED_PANE_PLACEMENT, selectExplorerSidebarPaneId, + selectPaneFocusSuspended, type WorkspaceLayout, + type SheetTab, type WorkspaceTabPlacement, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, @@ -115,30 +125,20 @@ import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; import { resolveWorkspaceRouteId } from "@/utils/workspace-identity"; import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels"; -import { - WorkspaceTabPresentationResolver, - WorkspaceTabIcon, - WorkspaceTabOptionRow, - type WorkspaceTabPresentation, -} from "@/screens/workspace/workspace-tab-presentation"; +import { WorkspaceTabPresentationResolver } from "@/screens/workspace/workspace-tab-presentation"; import { useWorkspaceTabRename, WorkspaceTabRenameModal, } from "@/screens/workspace/use-workspace-tab-rename"; -import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; -import { - buildWorkspaceTabMenuEntries, - type WorkspaceTabMenuLabels, -} from "@/screens/workspace/workspace-tab-menu"; import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { resolveWorkspaceExplorerToggleOwner, - WorkspaceExplorerToggle, + EXPLORER_TOGGLE_KEYS, WorkspaceExplorerSidebarToggle, WorkspaceHeaderExplorerToggle, } from "@/screens/workspace/workspace-explorer-toggle"; @@ -166,6 +166,8 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { SheetTabs } from "@/screens/workspace/sheet-tabs"; +import type { PaneHost } from "@/panels/panel-manifest"; import { WorkspaceFocusProvider } from "@/workspace/focus"; import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache"; import type { NewTabSelection } from "@/workspace-tabs/new-tab"; @@ -189,7 +191,6 @@ import { import { findAdjacentPane } from "@/utils/split-navigation"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; -import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; @@ -208,6 +209,7 @@ import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; +const NO_SHEETS: readonly SheetTab[] = []; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; @@ -246,12 +248,9 @@ function buildWorkspaceFileLocation( } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const GATED_WORKSPACE_HEADER_LEFT = ; - interface WorkspaceScreenProps { serverId: string; workspaceId: string; @@ -301,187 +300,6 @@ function useSyncWorkspaceActiveBrowser(input: { }, [focusedBrowserId, input.workspaceId]); } -function getFallbackTabOptionLabel( - tab: WorkspaceTabDescriptor, - labels: { - newTab: string; - newAgent: string; - setup: string; - terminal: string; - browser: string; - agent: string; - changes: string; - files: string; - pullRequest: string; - }, -): string { - if (tab.target.kind === "new_tab") { - return labels.newTab; - } - if (tab.target.kind === "draft") { - return labels.newAgent; - } - if (tab.target.kind === "setup") { - return labels.setup; - } - if (tab.target.kind === "terminal") { - return labels.terminal; - } - if (tab.target.kind === "browser") { - return labels.browser; - } - if (tab.target.kind === "file") { - return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; - } - if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { - return labels.changes; - } - if (tab.target.kind === "files") { - return labels.files; - } - if (tab.target.kind === "pull_request") { - return labels.pullRequest; - } - if (tab.target.kind === "commit_diff") { - return tab.target.sha.slice(0, 7); - } - return labels.agent; -} - -function getFallbackTabOptionDescription( - tab: WorkspaceTabDescriptor, - labels: { - newTab: string; - newAgent: string; - workspaceSetup: string; - agent: string; - terminal: string; - browser: string; - changes: string; - files: string; - pullRequest: string; - }, -): string { - if (tab.target.kind === "new_tab") { - return labels.newTab; - } - if (tab.target.kind === "draft") { - return labels.newAgent; - } - if (tab.target.kind === "setup") { - return labels.workspaceSetup; - } - if (tab.target.kind === "agent") { - return labels.agent; - } - if (tab.target.kind === "terminal") { - return labels.terminal; - } - if (tab.target.kind === "browser") { - return labels.browser; - } - if (tab.target.kind === "provider_subagent") { - return labels.agent; - } - if (tab.target.kind === "commit_diff") { - return tab.target.sha.slice(0, 7); - } - if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { - return labels.changes; - } - if (tab.target.kind === "files") { - return labels.files; - } - if (tab.target.kind === "pull_request") { - return labels.pullRequest; - } - if (tab.target.kind === "plugin") { - return tab.target.panelId; - } - return tab.target.path; -} - -interface MobileWorkspaceTabSwitcherProps { - tabs: WorkspaceTabDescriptor[]; - activeTabKey: string; - activeTab: WorkspaceTabDescriptor | null; - tabSwitcherOptions: ComboboxOption[]; - tabByKey: Map; - normalizedServerId: string; - normalizedWorkspaceId: string; - onSelectSwitcherTab: (key: string) => void; - onCopyResumeCommand: (agentId: string) => Promise | void; - onCopyAgentId: (agentId: string) => Promise | void; - onCopyTerminalId: (terminalId: string) => Promise | void; - onCopyFilePath: (path: string) => Promise | void; - onReloadAgent: (agentId: string) => Promise | void; - onRenameTab: (tab: WorkspaceTabDescriptor) => void; - onCloseTab: (tabId: string) => Promise | void; - onCloseTabsAbove: (tabId: string) => Promise | void; - onCloseTabsBelow: (tabId: string) => Promise | void; - onCloseOtherTabs: (tabId: string) => Promise | void; -} - -function MobileActiveTabTrigger({ - activeTab, - normalizedServerId, - normalizedWorkspaceId, - backdrop, -}: { - activeTab: WorkspaceTabDescriptor | null; - normalizedServerId: string; - normalizedWorkspaceId: string; - backdrop: SurfaceBackdrop; -}) { - if (!activeTab) { - return null; - } - - return ( - - ); -} - -function ResolvedMobileActiveTabTrigger({ - activeTab, - normalizedServerId, - normalizedWorkspaceId, - backdrop, -}: { - activeTab: WorkspaceTabDescriptor; - normalizedServerId: string; - normalizedWorkspaceId: string; - backdrop: SurfaceBackdrop; -}) { - const { t } = useTranslation(); - return ( - - {(presentation) => ( - <> - - - - - - {presentation.titleState === "loading" - ? t("workspace.tabs.loading") - : presentation.label} - - - )} - - ); -} - function WorkspaceDocumentTitleEffect({ label, titleState, @@ -504,277 +322,6 @@ function WorkspaceDocumentTitleEffect({ return null; } -function switcherTriggerStyle({ pressed }: { pressed?: boolean }) { - return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed]; -} - -function MobileWorkspaceTabOption({ - tab, - tabIndex, - tabCount, - normalizedServerId, - normalizedWorkspaceId, - selected, - active, - onPress, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, -}: { - tab: WorkspaceTabDescriptor; - tabIndex: number; - tabCount: number; - normalizedServerId: string; - normalizedWorkspaceId: string; - selected: boolean; - active: boolean; - onPress: () => void; - onCopyResumeCommand: (agentId: string) => Promise | void; - onCopyAgentId: (agentId: string) => Promise | void; - onCopyTerminalId: (terminalId: string) => Promise | void; - onCopyFilePath: (path: string) => Promise | void; - onReloadAgent: (agentId: string) => Promise | void; - onRenameTab: (tab: WorkspaceTabDescriptor) => void; - onCloseTab: (tabId: string) => Promise | void; - onCloseTabsAbove: (tabId: string) => Promise | void; - onCloseTabsBelow: (tabId: string) => Promise | void; - onCloseOtherTabs: (tabId: string) => Promise | void; -}) { - const { t } = useTranslation(); - const tabMenuLabels = useMemo( - () => ({ - copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), - copyAgentId: t("workspace.tabs.menu.copyAgentId"), - copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), - copyFilePath: t("workspace.tabs.menu.copyFilePath"), - rename: t("workspace.tabs.menu.rename"), - closeAbove: t("workspace.tabs.menu.closeAbove"), - closeBelow: t("workspace.tabs.menu.closeBelow"), - closeLeft: t("workspace.tabs.menu.closeLeft"), - closeRight: t("workspace.tabs.menu.closeRight"), - closeOthers: t("workspace.tabs.menu.closeOthers"), - reloadAgent: t("workspace.tabs.menu.reloadAgent"), - reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), - close: t("workspace.tabs.menu.close"), - }), - [t], - ); - const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; - const menuEntries = buildWorkspaceTabMenuEntries({ - surface: "mobile", - tab, - index: tabIndex, - tabCount, - menuTestIDBase, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsBefore: onCloseTabsAbove, - onCloseTabsAfter: onCloseTabsBelow, - onCloseOtherTabs, - labels: tabMenuLabels, - }); - - const fallbackLabels = useMemo( - () => ({ - newTab: t("workspace.tabs.actions.newTab"), - newAgent: t("workspace.tabs.fallback.newAgent"), - setup: t("workspace.tabs.fallback.setup"), - terminal: t("workspace.tabs.fallback.terminal"), - browser: t("workspace.tabs.fallback.browser"), - agent: t("workspace.tabs.fallback.agent"), - changes: t("panels.diff.changesLabel"), - files: t("panels.files.label"), - pullRequest: t("panels.pullRequest.label"), - }), - [t], - ); - const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels); - const trailingAccessory = useMemo( - () => ( - - ), - [menuTestIDBase, fallbackLabel, menuEntries], - ); - - const renderPresentation = useCallback( - (presentation: WorkspaceTabPresentation) => ( - - ), - [selected, active, onPress, trailingAccessory], - ); - - return ( - - {renderPresentation} - - ); -} - -const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ - tabs, - activeTabKey, - activeTab, - tabSwitcherOptions, - tabByKey, - normalizedServerId, - normalizedWorkspaceId, - onSelectSwitcherTab, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, -}: MobileWorkspaceTabSwitcherProps) { - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); - const anchorRef = useRef(null); - const tabIndexByKey = useMemo(() => { - const map = new Map(); - tabs.forEach((tab, index) => { - map.set(tab.key, index); - }); - return map; - }, [tabs]); - - const handleOpenSwitcher = useCallback(() => { - Keyboard.dismiss(); - setIsOpen(true); - }, []); - - const renderTabOption = useCallback( - ({ - option, - selected, - active, - onPress, - }: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => { - const tab = tabByKey.get(option.id); - if (!tab) { - return ; - } - const tabIndex = tabIndexByKey.get(tab.key) ?? -1; - if (tabIndex < 0) { - return ; - } - return ( - - ); - }, - [ - tabByKey, - tabIndexByKey, - tabs.length, - normalizedServerId, - normalizedWorkspaceId, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, - ], - ); - - return ( - - - {({ pressed }) => ( - <> - - - - - - )} - - - - - ); -}); - interface MobileMountedTabSlotProps { tabDescriptor: WorkspaceTabDescriptor; isVisible: boolean; @@ -902,47 +449,6 @@ function useCloseTabs(): UseCloseTabsResult { return { closingTabIds, closeTab }; } -/** - * Which project the workspace belongs to, and which machine it runs on. - * - * Compact gets both, on their own line under the workspace name: this header is the only thing on - * screen that says where the workspace lives, because the sidebar that normally carries the host - * badge is closed. It still follows the host's own badge setting, so a purely local setup stays - * quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit - * side by side, and kept on compact, where the line exists for the host anyway. - */ -function WorkspaceHeaderProjectRow({ - subtitle, - isSubtitleDistinct, - serverId, -}: { - subtitle: string; - isSubtitleDistinct: boolean; - serverId: string; -}) { - const isCompact = useIsCompactFormFactor(); - const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null; - const showProject = isSubtitleDistinct || isCompact; - if (!showProject && !hostBadge) { - return null; - } - return ( - - {showProject ? ( - - {subtitle} - - ) : null} - {showProject && hostBadge ? · : null} - {hostBadge ? : null} - - ); -} - interface WorkspaceHeaderTitleBarProps { isLoading: boolean; title: string; @@ -950,26 +456,13 @@ interface WorkspaceHeaderTitleBarProps { isSubtitleDistinct: boolean; currentBranchName: string | null; normalizedServerId: string; - normalizedWorkspaceId: string; - workspaceScripts: WorkspaceDescriptor["scripts"]; - liveTerminalIds: string[]; showWorkspaceSetup: boolean; - showCreateBrowserTab: boolean; - isMobile: boolean; - createTerminalDisabled: boolean; importAgentDisabled: boolean; copyPathDisabled: boolean; - onCreateDraftTab: () => void; - onCreateTerminal: () => void; - onCreateTerminalWithProfile: (profile: TerminalProfile) => void; - onCreateBrowser: () => void; onOpenImportSheet: () => void; onCopyWorkspacePath: () => void; onCopyBranchName: () => void; onOpenSetupTab: () => void; - onScriptTerminalStarted: (terminalId: string) => void; - onViewScriptTerminal: (terminalId: string) => void; - onOpenUrlInBrowserTab: (url: string) => void; } function WorkspaceHeaderTitleBar({ @@ -979,26 +472,13 @@ function WorkspaceHeaderTitleBar({ isSubtitleDistinct, currentBranchName, normalizedServerId, - normalizedWorkspaceId, - workspaceScripts, - liveTerminalIds, showWorkspaceSetup, - showCreateBrowserTab, - isMobile, - createTerminalDisabled, importAgentDisabled, copyPathDisabled, - onCreateDraftTab, - onCreateTerminal, - onCreateTerminalWithProfile, - onCreateBrowser, onOpenImportSheet, onCopyWorkspacePath, onCopyBranchName, onOpenSetupTab, - onScriptTerminalStarted, - onViewScriptTerminal, - onOpenUrlInBrowserTab, }: WorkspaceHeaderTitleBarProps) { return ( @@ -1017,49 +497,16 @@ function WorkspaceHeaderTitleBar({ )} - {isMobile ? ( - - ) : ( - - )} - {isMobile && workspaceScripts.length > 0 ? ( - - ) : null} + ); @@ -1293,7 +740,7 @@ function shouldInspectWorkspaceRecovery( function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { return ( <> - + {children} ); @@ -1313,17 +760,21 @@ function WorkspaceContentProviders({ ); } -function WorkspacePanelContent({ +function WorkspacePanelProviders({ launcher, - content, + children, + tabIds, + workspaceKey, }: { + workspaceKey: string; launcher: NewTabLauncher; - content: ReactNode; + children: ReactNode; + tabIds: readonly string[]; }) { return ( - - {content} - + + {children} + ); } @@ -1375,6 +826,20 @@ function WorkspaceDocumentTitleEffectSlot({ ); } +function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean { + return supportsStreamTopOverlay && activeTab?.kind === "agent"; +} + +/** A fullscreen composer covers the compact header; the column lays out without it. */ +function resolveCompactHeaderLayout(input: { + header: ReactNode; + activeTab: WorkspaceTabDescriptor | null; + composerFullscreen: boolean; +}): { header: ReactNode; floating: boolean } { + if (input.composerFullscreen) return { header: null, floating: false }; + return { header: input.header, floating: floatsHeaderOverContent(input.activeTab) }; +} + function shouldShowWorkspaceScreenHeader(input: { isFocusModeEnabled: boolean; isMobile: boolean; @@ -1561,6 +1026,15 @@ function WorkspaceScreenContent({ .prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId) .catch(() => undefined); }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]); + // The focused workspace keeps its script status live even when no sidebar or viewed agent + // timeline demands the directory. Retained, hidden workspaces release it. + useEffect(() => { + if (!isRouteFocused || !normalizedServerId || !normalizedWorkspaceId) return; + return getHostRuntimeStore().acquireScriptStatusDemand( + normalizedServerId, + normalizedWorkspaceId, + ); + }, [isRouteFocused, normalizedServerId, normalizedWorkspaceId]); const workspaceScripts = getWorkspaceScripts(workspaceDescriptor); const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = useWorkspaceRouteActions(normalizedServerId); @@ -1819,6 +1293,9 @@ function WorkspaceScreenContent({ const explorerSidebarPaneId = useWorkspaceLayoutStore((state) => persistenceKey ? selectExplorerSidebarPaneId(state, persistenceKey) : null, ); + const paneFocusSuspended = useWorkspaceLayoutStore((state) => + persistenceKey ? selectPaneFocusSuspended(state, persistenceKey) : false, + ); const lastMainPaneRef = useLastMainPane({ workspaceKey: persistenceKey, layout: workspaceLayout, @@ -1857,6 +1334,13 @@ function WorkspaceScreenContent({ const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const sheets = useWorkspaceLayoutStore((state) => + persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS, + ); + // Only the top sheet is on screen. + const sheetTab = useWorkspaceLayoutStore((state) => + persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null, + ); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState); @@ -1929,6 +1413,7 @@ function WorkspaceScreenContent({ const visibleAgentIds = useVisibleAgentIds({ layout: workspaceLayout, tabs: uiTabs, + sheetTab, routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, }); @@ -2230,7 +1715,6 @@ function WorkspaceScreenContent({ } const tabId = openPreferredWorkspaceTarget({ - isCompact: isMobile, workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), source: "chatFiles", @@ -2300,14 +1784,6 @@ function WorkspaceScreenContent({ terminalsQueryKey, }); - const tabByKey = useMemo(() => { - const map = new Map(); - for (const tab of tabs) { - map.set(tab.key, tab); - } - return map; - }, [tabs]); - const allTabDescriptorsById = useMemo(() => { const map = new Map(); for (const tab of uiTabs) { @@ -2353,32 +1829,6 @@ function WorkspaceScreenContent({ : t("workspace.tabs.explorerSidebar.open"); const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]); - const tabFallbackLabels = useMemo( - () => ({ - newTab: t("workspace.tabs.actions.newTab"), - newAgent: t("workspace.tabs.fallback.newAgent"), - setup: t("workspace.tabs.fallback.setup"), - workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"), - terminal: t("workspace.tabs.fallback.terminal"), - browser: t("workspace.tabs.fallback.browser"), - agent: t("workspace.tabs.fallback.agent"), - changes: t("panels.diff.changesLabel"), - files: t("panels.files.label"), - pullRequest: t("panels.pullRequest.label"), - }), - [t], - ); - - const tabSwitcherOptions = useMemo( - () => - tabs.map((tab) => ({ - id: tab.key, - label: getFallbackTabOptionLabel(tab, tabFallbackLabels), - description: getFallbackTabOptionDescription(tab, tabFallbackLabels), - })), - [tabFallbackLabels, tabs], - ); - const handleCreateDraftTab = useCallback( (input?: { paneId?: string }) => { openWorkspaceDraftTab({ paneId: input?.paneId }); @@ -2648,6 +2098,12 @@ function WorkspaceScreenContent({ const handleCloseTabById = useCallback( async (tabId: string) => { + if (persistenceKey && sheets.some((sheet) => sheet.tab.tabId === tabId)) { + if (await confirmDiscardModifiedTab(tabId)) { + closeWorkspaceTab(persistenceKey, tabId); + } + return; + } const tab = allTabDescriptorsById.get(tabId); if (!tab) { return; @@ -2667,10 +2123,13 @@ function WorkspaceScreenContent({ }, [ allTabDescriptorsById, + closeWorkspaceTab, confirmDiscardModifiedTab, handleCloseAgentTab, handleClosePassiveTab, handleCloseTerminalTab, + persistenceKey, + sheets, ], ); @@ -3504,17 +2963,19 @@ function WorkspaceScreenContent({ tab: WorkspaceTabDescriptor; paneId?: string | null; focusPaneBeforeOpen?: boolean; + host?: PaneHost; }) => buildWorkspacePaneContentModel({ tab: input.tab, normalizedServerId, normalizedWorkspaceId, host: - canRenderDesktopPaneSplits && + input.host ?? + (canRenderDesktopPaneSplits && input.paneId !== null && input.paneId === explorerSidebarPaneId ? "explorer" - : "main", + : "main"), fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0, onOpenTab: (target) => { if (!persistenceKey) { @@ -3533,7 +2994,6 @@ function WorkspaceScreenContent({ onOpenPreferredTarget: (target, source) => { if (!persistenceKey) return; const tabId = openPreferredWorkspacePreview({ - isCompact: isMobile, workspaceKey: persistenceKey, serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -3595,7 +3055,6 @@ function WorkspaceScreenContent({ canRenderDesktopPaneSplits, openImportSheet, openInSidePane, - isMobile, requestFileNavigation, revealWorkspaceChildTab, persistenceKey, @@ -3639,6 +3098,17 @@ function WorkspaceScreenContent({ }, [buildPaneContentModel], ); + const buildSheetPaneContentModel = useCallback( + function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) { + return buildPaneContentModel({ + tab, + paneId: null, + focusPaneBeforeOpen: false, + host: "sheet", + }); + }, + [buildPaneContentModel], + ); const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) { if (!persistenceKey || paneFocusSuppressedRef.current) { return; @@ -3654,7 +3124,7 @@ function WorkspaceScreenContent({ focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, - paneFocusSuspended: Boolean(unfocusedPaneId), + paneFocusSuspended, onFocusPane: handleFocusPane, buildMobilePaneContentModel, }); @@ -3759,7 +3229,7 @@ function WorkspaceScreenContent({ () => ( - {!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? ( + {workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? ( ) : null} - {!isMobile && workspaceDirectory ? ( + {workspaceDirectory ? ( ) : null} - {!isMobile && workspaceDirectory ? ( + {workspaceDirectory ? ( <> ) : null} - {isMobile ? ( - - ) : null} ), [ - isMobile, workspaceDescriptor, normalizedServerId, normalizedWorkspaceId, @@ -3892,26 +3351,13 @@ function WorkspaceScreenContent({ isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct} currentBranchName={currentBranchName} normalizedServerId={normalizedServerId} - normalizedWorkspaceId={normalizedWorkspaceId} - workspaceScripts={workspaceScripts} - liveTerminalIds={liveTerminalIds} showWorkspaceSetup={showWorkspaceSetup} - showCreateBrowserTab={showCreateBrowserTab} - isMobile={isMobile} - createTerminalDisabled={createTerminalDisabled} importAgentDisabled={!canOpenImportSheet} copyPathDisabled={!workspaceDirectory} - onCreateDraftTab={handleCreateDraftTab} - onCreateTerminal={handleCreateTerminal} - onCreateTerminalWithProfile={handleCreateTerminalWithProfile} - onCreateBrowser={handleCreateBrowserTab} onOpenImportSheet={openImportSheet} onCopyWorkspacePath={handleCopyWorkspacePath} onCopyBranchName={handleCopyBranchName} onOpenSetupTab={handleOpenSetupTab} - onScriptTerminalStarted={handleScriptTerminalStarted} - onViewScriptTerminal={handleViewScriptTerminal} - onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab} /> } @@ -3920,33 +3366,132 @@ function WorkspaceScreenContent({ ) : null, [ canOpenImportSheet, - createTerminalDisabled, currentBranchName, handleCopyBranchName, handleCopyWorkspacePath, - handleCreateBrowserTab, - handleCreateDraftTab, - handleCreateTerminal, - handleCreateTerminalWithProfile, handleOpenSetupTab, - handleOpenUrlInBrowserTab, - handleScriptTerminalStarted, - handleViewScriptTerminal, headerRight, - isMobile, isWorkspaceHeaderLoading, - liveTerminalIds, normalizedServerId, - normalizedWorkspaceId, openImportSheet, - showCreateBrowserTab, showScreenHeader, showWorkspaceSetup, workspaceDirectory, workspaceHeaderSubtitle, workspaceHeaderTitle, isWorkspaceHeaderSubtitleDistinct, + ], + ); + const compactHeaderScripts = useMemo( + () => ({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + scripts: workspaceScripts, + liveTerminalIds, + onScriptTerminalStarted: handleScriptTerminalStarted, + onViewTerminal: handleViewScriptTerminal, + onOpenUrlInBrowserTab: handleOpenUrlInBrowserTab, + }), + [ + normalizedServerId, + normalizedWorkspaceId, workspaceScripts, + liveTerminalIds, + handleScriptTerminalStarted, + handleViewScriptTerminal, + handleOpenUrlInBrowserTab, + ], + ); + const compactWorkspaceHeader = useMemo( + () => + isMobile ? ( + + + + + ) : null, + [ + activeTabKey, + canOpenImportSheet, + compactHeaderScripts, + createTerminalDisabled, + currentBranchName, + explorerSidebarToggleAccessibilityState, + explorerSidebarToggleLabel, + handleCloseOtherTabs, + handleCloseTabById, + handleCloseTabsToLeft, + handleCloseTabsToRight, + handleCopyAgentId, + handleCopyBranchName, + handleCopyFilePath, + handleCopyResumeCommand, + handleCopyTerminalId, + handleCopyWorkspacePath, + handleCreateBrowserTab, + handleCreateDraftTab, + handleCreateTerminal, + handleCreateTerminalWithProfile, + handleOpenSetupTab, + handleReloadAgent, + handleRenameTab, + handleSelectSwitcherTab, + handleToggleExplorerSidebar, + isMobile, + isWorkspaceHeaderLoading, + isWorkspaceHeaderSubtitleDistinct, + normalizedServerId, + normalizedWorkspaceId, + openImportSheet, + showCreateBrowserTab, + showWorkspaceSetup, + tabs, + workspaceDirectory, + workspaceHeaderSubtitle, + workspaceHeaderTitle, ], ); const desktopSplitContent = useMemo(() => { @@ -4028,40 +3573,50 @@ function WorkspaceScreenContent({ const desktopContent = desktopSplitContent ?? content; const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null; - const workspacePanelContent = ( - + const workspacePanelTabIds = useMemo( + () => [...uiTabs.map((tab) => tab.tabId), ...sheets.map((sheet) => sheet.tab.tabId)], + [uiTabs, sheets], ); + const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`; + const compactHeaderLayout = resolveCompactHeaderLayout({ + header: compactWorkspaceHeader, + activeTab: activeTabDescriptor, + composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId), + }); - const workspaceCenterColumn = ( + // Sheets opened from the compact column present inside it, so their panels read the workspace + // providers, and they cover the compact header as a modal. + const workspaceCenterColumn = isMobile ? ( + + + + {(topOverlayInset) => ( + + {content} + + )} + + + + + ) : ( {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} - {isMobile ? ( - - ) : null} - {shouldRenderDesktopPaneFallback ? ( ) : null} - {workspacePanelContent} + + + {desktopContent} + + ); @@ -4190,30 +3753,6 @@ const styles = StyleSheet.create((theme) => ({ md: theme.spacing[2], }, }, - // No width cap. A percentage cap resolves against the title group, whose own width comes from - // this row's content, so it clips the project name while there is still room beside it. - // `flexShrink` on both this row and the title already gives up space only when there is none. - headerProjectRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1.5], - minWidth: 0, - flexShrink: 1, - }, - headerProjectTitle: { - color: theme.colors.foregroundMuted, - fontSize: { - xs: theme.fontSize.sm, - md: theme.fontSize.base, - }, - flexShrink: 1, - minWidth: 0, - }, - headerProjectSeparator: { - color: theme.colors.foregroundExtraMuted, - fontSize: theme.fontSize.sm, - flexShrink: 0, - }, headerTitleSkeleton: { width: 220, maxWidth: "100%", @@ -4272,37 +3811,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, newTabTooltipShortcut: {}, - mobileTabsRow: { - backgroundColor: theme.colors.surface0, - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - switcherTrigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[2] + theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - switcherTriggerPressed: { - backgroundColor: theme.colors.surface1, - }, - switcherTriggerLeft: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flex: 1, - minWidth: 0, - }, - switcherTriggerIcon: { - flexShrink: 0, - }, - switcherTriggerText: { - minWidth: 0, - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, headerMenuProfileIconWrapper: { width: 16, height: 16, @@ -4417,5 +3925,3 @@ const styles = StyleSheet.create((theme) => ({ textAlign: "center", }, })); - -const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; diff --git a/packages/app/src/stores/download-store.test.ts b/packages/app/src/stores/download-store.test.ts new file mode 100644 index 0000000000..d3657b4c34 --- /dev/null +++ b/packages/app/src/stores/download-store.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { defaultHostAppearance } from "@/hosts/appearance"; +import { i18n } from "@/i18n/i18next"; +import { + MAX_SESSION_DOWNLOAD_BYTES, + useDownloadStore, + type Download, + type DownloadedBytes, +} from "@/stores/download-store"; +import type { + DirectTcpHostConnection, + HostProfile, + RelayHostConnection, +} from "@/types/host-connection"; + +const FILE_BYTES = new TextEncoder().encode("relay download payload"); +const FILE_PATH = "reports/données été.bin"; +const FILE_NAME = "données été.bin"; +const MIME_TYPE = "application/octet-stream"; + +const relayConnection: RelayHostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "cHVibGljLWtleQ==", +}; + +// The #3753 shape: the relay hostname stored as a directTcp entry that nothing can reach. +const staleTcpConnection: DirectTcpHostConnection = { + id: "direct:relay.paseo.sh:443", + type: "directTcp", + endpoint: "relay.paseo.sh:443", +}; + +const localTcpConnection: DirectTcpHostConnection = { + id: "direct:localhost:6767", + type: "directTcp", + endpoint: "localhost:6767", +}; + +function profileWith(connections: HostProfile["connections"]): HostProfile { + return { + serverId: "srv_remote", + label: "Remote", + appearance: defaultHostAppearance(), + lifecycle: {}, + connections, + preferredConnectionId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function createTokenRequest(size: number | null = FILE_BYTES.byteLength) { + const requested: string[] = []; + return { + requested, + request: async (path: string) => { + requested.push(path); + return { token: "tok", fileName: FILE_NAME, mimeType: MIME_TYPE, size, error: null }; + }, + }; +} + +function createFileReader(read: () => Promise<{ bytes: Uint8Array; size: number }>) { + const requested: string[] = []; + return { + requested, + read: async (path: string) => { + requested.push(path); + return read(); + }, + }; +} + +function createFileSaver() { + const savedFiles: DownloadedBytes[] = []; + return { + savedFiles, + save: async (file: DownloadedBytes) => { + savedFiles.push(file); + return null; + }, + }; +} + +interface StartInput { + profile: HostProfile; + activeConnectionId: string | null; + token?: ReturnType; + reader?: ReturnType; + saver?: ReturnType; +} + +async function startDownload(input: StartInput): Promise { + const token = input.token ?? createTokenRequest(); + const reader = + input.reader ?? + createFileReader(async () => ({ bytes: FILE_BYTES, size: FILE_BYTES.byteLength })); + const saver = input.saver ?? createFileSaver(); + await useDownloadStore.getState().startDownload({ + serverId: "srv_remote", + scopeId: "workspace-1", + fileName: FILE_NAME, + path: FILE_PATH, + daemonProfile: input.profile, + activeConnectionId: input.activeConnectionId, + requestFileDownloadToken: token.request, + readFile: reader.read, + saveFile: saver.save, + }); + const { activeDownloadId, downloads } = useDownloadStore.getState(); + return activeDownloadId ? downloads.get(activeDownloadId) : undefined; +} + +describe("download store transport selection", () => { + beforeEach(() => { + useDownloadStore.setState({ downloads: new Map(), activeDownloadId: null }); + }); + + it("downloads over the session when the active connection is the relay", async () => { + const token = createTokenRequest(); + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + token, + reader, + saver, + }); + + expect(download).toMatchObject({ fileName: FILE_NAME, status: "complete" }); + expect(token.requested).toEqual([FILE_PATH]); + expect(reader.requested).toEqual([FILE_PATH]); + expect(saver.savedFiles).toEqual([ + { bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE }, + ]); + }); + + it("bounds the session read even when the token reports a smaller file", async () => { + const readRequests: Array<{ path: string; maxBytes: number }> = []; + const saver = createFileSaver(); + await useDownloadStore.getState().startDownload({ + serverId: "srv_remote", + scopeId: "workspace-1", + fileName: FILE_NAME, + path: FILE_PATH, + daemonProfile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + requestFileDownloadToken: createTokenRequest(1).request, + readFile: async (path: string, maxBytes: number) => { + readRequests.push({ path, maxBytes }); + return { bytes: FILE_BYTES, size: FILE_BYTES.byteLength }; + }, + saveFile: saver.save, + }); + expect(readRequests).toEqual([{ path: FILE_PATH, maxBytes: MAX_SESSION_DOWNLOAD_BYTES }]); + expect(saver.savedFiles).toEqual([ + { bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE }, + ]); + }); + + it("ignores a directTcp entry that is not the active connection", async () => { + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([staleTcpConnection, relayConnection]), + activeConnectionId: relayConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ status: "complete" }); + expect(reader.requested).toEqual([FILE_PATH]); + expect(saver.savedFiles).toHaveLength(1); + }); + + it("keeps the HTTP token download when the active connection is directTcp", async () => { + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([localTcpConnection, relayConnection]), + activeConnectionId: localTcpConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ status: "complete" }); + expect(reader.requested).toEqual([]); + expect(saver.savedFiles).toEqual([]); + }); + + it("fails the download and saves nothing when the session read fails", async () => { + const reader = createFileReader(async () => { + throw new Error("Connection lost"); + }); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ status: "error", message: "Connection lost" }); + expect(saver.savedFiles).toEqual([]); + }); + + it("refuses a file over the session size limit before reading it", async () => { + const token = createTokenRequest(MAX_SESSION_DOWNLOAD_BYTES + 1); + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + token, + reader, + saver, + }); + + expect(download).toMatchObject({ + status: "error", + message: i18n.t("composer.errors.fileTooLarge", { fileName: FILE_NAME, size: "64 MB" }), + }); + expect(download?.message).toContain("64 MB"); + expect(reader.requested).toEqual([]); + expect(saver.savedFiles).toEqual([]); + }); + + it("fails instead of saving an empty file when the daemon returns no content", async () => { + const reader = createFileReader(async () => ({ bytes: new Uint8Array(), size: 6 })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ + status: "error", + message: "File transfer incomplete: expected 6 bytes, received 0.", + }); + expect(saver.savedFiles).toEqual([]); + }); +}); diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index 6d13b3ed3c..790fbd7dee 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -16,6 +16,16 @@ interface DownloadProgress { eta: number; } +// The session path holds the file in memory two to three times over (client chunks, the +// concatenated result, then the Blob or native write). Lift once the client streams chunks to disk. +export const MAX_SESSION_DOWNLOAD_BYTES = 64 * 1024 * 1024; + +export interface DownloadedBytes { + bytes: Uint8Array; + fileName: string; + mimeType: string | null; +} + export interface Download { id: string; serverId: string; @@ -37,12 +47,18 @@ interface DownloadState { fileName: string; path: string; daemonProfile: HostProfile | undefined; + /** Connection the host runtime is using right now; `null` while disconnected. */ + activeConnectionId: string | null; requestFileDownloadToken: (path: string) => Promise<{ token: string | null; fileName: string | null; mimeType: string | null; + size: number | null; error: string | null; }>; + /** Reads the whole file over the session socket; used when no direct HTTP endpoint is active. */ + readFile: (path: string, maxBytes: number) => Promise<{ bytes: Uint8Array; size: number }>; + saveFile?: typeof saveDownloadedBytes; }) => Promise; updateProgress: (id: string, progress: DownloadProgress) => void; @@ -66,7 +82,10 @@ export const useDownloadStore = create()((set, get) => ({ fileName, path, daemonProfile, + activeConnectionId, requestFileDownloadToken, + readFile, + saveFile = saveDownloadedBytes, }) => { const id = generateDownloadId(); const download: Download = { @@ -89,12 +108,42 @@ export const useDownloadStore = create()((set, get) => ({ throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed")); } - const downloadTarget = resolveDaemonDownloadTarget(daemonProfile); + const resolvedFileName = tokenResponse.fileName ?? fileName; + const downloadTarget = resolveDaemonDownloadTarget(daemonProfile, activeConnectionId); + if (!downloadTarget) { + // Relay, SSH, socket and pipe hosts have no HTTP endpoint: read the file over the + // session socket that is already connected (#543). + if ( + typeof tokenResponse.size === "number" && + tokenResponse.size > MAX_SESSION_DOWNLOAD_BYTES + ) { + throw new Error( + i18n.t("composer.errors.fileTooLarge", { fileName: resolvedFileName, size: "64 MB" }), + ); + } + const { bytes, size } = await readFile(path, MAX_SESSION_DOWNLOAD_BYTES); + // A daemon without a binary channel answers `encoding: "none"` for a binary file: no + // content, real size. Never save that as a complete download. + if (bytes.byteLength !== size) { + throw new Error( + `File transfer incomplete: expected ${size} bytes, received ${bytes.byteLength}.`, + ); + } + const savedUri = await saveFile({ + bytes, + fileName: resolvedFileName, + mimeType: tokenResponse.mimeType, + }); + get().completeDownload(id); + if (savedUri !== null) { + await shareSavedFile(savedUri, tokenResponse.mimeType, resolvedFileName); + } + return; + } if (!downloadTarget.baseUrl) { throw new Error(i18n.t("downloads.hostUnavailable")); } - const resolvedFileName = tokenResponse.fileName ?? fileName; const downloadUrl = buildDownloadUrl( downloadTarget.baseUrl, tokenResponse.token, @@ -146,14 +195,7 @@ export const useDownloadStore = create()((set, get) => ({ get().completeDownload(id); - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(result.uri, { - mimeType: tokenResponse.mimeType ?? undefined, - dialogTitle: resolvedFileName - ? i18n.t("downloads.shareFileNamed", { fileName: resolvedFileName }) - : i18n.t("downloads.shareFile"), - }); - } + await shareSavedFile(result.uri, tokenResponse.mimeType, resolvedFileName); } catch (error) { const message = error instanceof Error ? error.message : i18n.t("downloads.failed"); if (isWeb) { @@ -244,10 +286,18 @@ interface DownloadTarget { authCredentials: { username: string; password: string } | null; } -function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget { - const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null; - if (!connection) { - return { baseUrl: null, authHeader: null, authCredentials: null }; +/** + * The HTTP download route is only reachable over a direct TCP connection. Returns `null` when + * the connection in use is anything else (relay, SSH, socket, pipe, or none yet), so the caller + * reads the file over the session socket instead. + */ +function resolveDaemonDownloadTarget( + daemon: HostProfile | undefined, + activeConnectionId: string | null, +): DownloadTarget | null { + const connection = daemon?.connections.find((conn) => conn.id === activeConnectionId); + if (connection?.type !== "directTcp") { + return null; } let parsed: URL; @@ -299,6 +349,47 @@ function buildDownloadUrl( return url.toString(); } +/** + * Saves bytes read over the session. Web and Electron hand them to the browser's download + * manager and return `null`; native writes the file and returns its URI for the share sheet. + */ +async function saveDownloadedBytes({ + bytes, + fileName, + mimeType, +}: DownloadedBytes): Promise { + if (isWeb) { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const url = URL.createObjectURL( + new Blob([buffer], { type: mimeType ?? "application/octet-stream" }), + ); + triggerBrowserDownload(url, fileName); + // The browser may still be reading the blob while its save prompt is open. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return null; + } + // Resolve and write in one tick so two downloads of the same name cannot pick the same path. + const targetFile = resolveDownloadTargetFile(fileName); + targetFile.write(bytes); + return targetFile.uri; +} + +async function shareSavedFile( + uri: string, + mimeType: string | null, + fileName: string, +): Promise { + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(uri, { + mimeType: mimeType ?? undefined, + dialogTitle: fileName + ? i18n.t("downloads.shareFileNamed", { fileName }) + : i18n.t("downloads.shareFile"), + }); + } +} + function triggerBrowserDownload(url: string, fileName: string) { if (typeof document === "undefined") { if (typeof window !== "undefined") { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 0d59b1c6b5..9b70d90486 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -15,7 +15,7 @@ import { import { acceptMessageSubmission, beginMessageSubmission, - getActiveMessageSubmissions, + hasActiveMessageSubmission, observeMessageSubmissionCanonical, rejectMessageSubmission, type MessageSubmissionRecord, @@ -113,6 +113,7 @@ export interface WorkspaceDescriptor { worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"]; projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; + background?: boolean; name: string; title?: string | null; pinnedAt?: string | null; @@ -150,6 +151,7 @@ export function normalizeWorkspaceDescriptor( worktreeSlug: payload.worktreeSlug, projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, + background: payload.background ?? false, name: payload.name, title: payload.title ?? null, pinnedAt: payload.pinnedAt ?? null, @@ -346,7 +348,7 @@ export function selectAgentTurnPresentation( session?.agents.get(agentId)?.turn ?? session?.agentDetails.get(agentId)?.turn ?? TURN_LIVENESS_IDLE, - getActiveMessageSubmissions(session?.messageSubmissions.get(agentId)).length > 0, + hasActiveMessageSubmission(session?.messageSubmissions.get(agentId)), ); } diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index 902294dd2d..e21c8f8a15 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -32,6 +32,7 @@ function createMemoryStorage(entries: Record): MemoryStor describe("sidebar view store", () => { beforeEach(() => { useSidebarViewStore.setState({ + showBackground: false, groupMode: "project", hostFilters: [], projectFilters: [], @@ -84,6 +85,7 @@ describe("sidebar view store", () => { }, }), ).toEqual({ + showBackground: false, groupMode: "status", hostFilters: [], projectFilters: [], @@ -98,6 +100,7 @@ describe("sidebar view store", () => { hostFilter: "host-a", }), ).toEqual({ + showBackground: false, groupMode: "status", hostFilters: ["host-a"], projectFilters: [], @@ -112,6 +115,7 @@ describe("sidebar view store", () => { hostFilters: ["host-a", "host-b"], }), ).toEqual({ + showBackground: false, groupMode: "status", hostFilters: ["host-a", "host-b"], projectFilters: [], @@ -222,6 +226,7 @@ describe("sidebar view store", () => { projectFilters: ["project-a", "project-b"], }), ).toEqual({ + showBackground: false, groupMode: "project", hostFilters: ["host-a"], projectFilters: ["project-a", "project-b"], @@ -231,6 +236,7 @@ describe("sidebar view store", () => { it("never keeps project filters from state the schema rejects", () => { expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({ + showBackground: false, groupMode: "project", hostFilters: [], projectFilters: [], diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 19f559c89d..0e12c304a8 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -9,7 +9,7 @@ export type SidebarGroupMode = "project" | "status"; const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view"; const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode"; -const SIDEBAR_VIEW_STORE_VERSION = 6; +const SIDEBAR_VIEW_STORE_VERSION = 7; /** * The key standing for "this workspace carries no labels at all". @@ -47,6 +47,7 @@ function toggleFilterEntry(list: readonly string[], key: string): string[] { interface SidebarViewStoreState { groupMode: SidebarGroupMode; + showBackground: boolean; // Empty means "all hosts". A non-empty list pins the sidebar to those hosts. hostFilters: string[]; /** @@ -61,6 +62,7 @@ interface SidebarViewStoreState { */ projectFilters: string[]; labelFilter: SidebarLabelFilter; + toggleBackground: () => void; setGroupMode: (mode: SidebarGroupMode) => void; toggleHostFilter: (serverId: string) => void; clearHostFilters: () => void; @@ -74,6 +76,7 @@ interface SidebarViewStoreState { interface SidebarViewPersistedState { groupMode: SidebarGroupMode; + showBackground: boolean; hostFilters: string[]; projectFilters: string[]; labelFilter: SidebarLabelFilter; @@ -84,6 +87,7 @@ const SidebarLabelFilterSchema = z.object({ labels: z.array(z.string()), }); const SidebarViewPersistedStateSchema = z.strictObject({ + showBackground: z.boolean().optional(), groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(), hostFilter: z.string().nullable().optional(), @@ -122,6 +126,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer const result = SidebarViewPersistedStateSchema.safeParse(persistedState); if (!result.success) { return { + showBackground: false, groupMode: "project", hostFilters: [], projectFilters: [], @@ -133,6 +138,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer const legacyGroupMode = readLegacyGroupMode(state); if (legacyGroupMode) { return { + showBackground: false, groupMode: legacyGroupMode, hostFilters: [], projectFilters: [], @@ -141,6 +147,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer } return { + showBackground: state.showBackground ?? false, groupMode: state.groupMode === "status" ? "status" : "project", hostFilters: readHostFilters(state), projectFilters: state.projectFilters ?? [], @@ -178,10 +185,12 @@ export function createSidebarViewStorage( export const useSidebarViewStore = create()( persist( (set) => ({ + showBackground: false, groupMode: "project", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), + toggleBackground: () => set((state) => ({ showBackground: !state.showBackground })), setGroupMode: (mode) => set({ groupMode: mode }), toggleHostFilter: (serverId) => set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })), @@ -228,6 +237,7 @@ export const useSidebarViewStore = create()( SidebarViewPersistedStateSchema, ), partialize: (state) => ({ + showBackground: state.showBackground ?? false, groupMode: state.groupMode, hostFilters: state.hostFilters, projectFilters: state.projectFilters, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index d1a4768cb5..5f170bed7e 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@react-native-async-storage/async-storage", () => { const storage = new Map(); @@ -16,6 +16,9 @@ vi.mock("@react-native-async-storage/async-storage", () => { }); import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { defaultChangesState, type ChangesState } from "@/panels/changes/state"; @@ -24,6 +27,7 @@ import { canDismissPaneInLayout, collectAllPanes, collectAllTabs, + selectPaneFocusSuspended, createWorkspaceLayoutStore, observeOpenWorkspaceAgentIds, createDefaultLayout, @@ -4438,3 +4442,677 @@ it("persists the once-only PR add after closing, and clears it when purging the expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBeUndefined(); expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBe("pull_request"); }); + +describe("compact workspace sheet", () => { + const workspaceKey = "server-1:sheet-ws"; + const fileTarget = { kind: "file" as const, path: "src/index.ts" }; + + function createCompactStore() { + const store = createWorkspaceLayoutStore(workspaceLayoutIds); + store.setState({ layoutByWorkspace: {}, sheetsByWorkspace: {} }); + store.getState().setLayoutMode("compact"); + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + return store; + } + + function topSheet(store: ReturnType) { + return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1); + } + + function sheetTargets(store: ReturnType) { + return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map( + (sheet) => sheet.tab.target, + ); + } + + function tabKinds(store: ReturnType): string[] { + const layout = store.getState().layoutByWorkspace[workspaceKey]; + const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab])); + return (findPaneById(layout.root, "main")?.tabIds ?? []).map( + (tabId) => tabsById.get(tabId)?.target.kind ?? "missing", + ); + } + + function seedAgents(agents: Array>): void { + const timestamp = new Date("2026-04-21T10:00:00.000Z"); + useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); + useSessionStore.getState().setAgents( + "server-1", + new Map( + agents.map((agent) => [ + agent.id, + { + serverId: "server-1", + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: timestamp, + updatedAt: timestamp, + lastUserMessageAt: null, + lastActivityAt: timestamp, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: undefined, + lastUsage: undefined, + lastError: null, + title: agent.id, + cwd: "/repo", + model: null, + features: undefined, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + labels: {}, + projectPlacement: null, + ...agent, + } satisfies Agent, + ]), + ), + ); + } + + it("reveals a file in the sheet and leaves the tab strip unchanged", () => { + const store = createCompactStore(); + const before = tabKinds(store); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(topSheet(store)?.tab).toEqual({ + tabId, + target: fileTarget, + createdAt: expect.any(Number), + }); + expect(tabKinds(store)).toEqual(before); + }); + + it("focuses a target that is already open as a tab instead of using the sheet", () => { + const store = createCompactStore(); + store.getState().setLayoutMode("split-panes"); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + store.getState().setLayoutMode("compact"); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(tabId).toBe(fileTabId); + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId); + }); + + it("stacks the next sheet target over the open sheet", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(tabKinds(store)).toEqual(["terminal"]); + + store.getState().closeTab(workspaceKey, nextTabId ?? ""); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("closes a lower sheet and keeps the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + store.getState().closeTab(workspaceKey, fileTabId ?? ""); + + expect(sheetTargets(store)).toEqual([nextTarget]); + expect(tabKinds(store)).toEqual(["terminal"]); + }); + + it("uncovers a covered sheet by dropping the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(tabId).toBe(fileTabId); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("opens the top sheet as a tab and dismisses the sheets that would cover it", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + store.getState().openSheetAsTab(workspaceKey, nextTabId ?? ""); + + expect(sheetTargets(store)).toEqual([]); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(nextTabId); + }); + + it("closes the sheet when a tab target is revealed, and keeps it for background opens", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + store.getState().openTab({ + workspaceKey, + target: { kind: "file", path: "src/background.ts" }, + intent: "background", + }); + expect(topSheet(store)?.tab.target).toEqual(fileTarget); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + expect(topSheet(store)).toBeUndefined(); + }); + + it("moves the sheet tab into the focused pane with the same tab id and state", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + if (!sheetTabId) throw new Error("Expected a sheet tab"); + store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 }); + + const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId); + + expect(tabId).toBe(sheetTabId); + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + const pane = findPaneContainingTab(layout.root, sheetTabId); + expect(pane?.id).toBe(layout.focusedPaneId); + expect(pane?.focusedTabId).toBe(sheetTabId); + expect(collectAllTabs(layout.root).find((tab) => tab.tabId === sheetTabId)?.state).toEqual({ + scrollTop: 120, + }); + }); + + it("closes the sheet through the ordinary close and ignores focus requests for it", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + if (!sheetTabId) throw new Error("Expected a sheet tab"); + const layoutBefore = store.getState().layoutByWorkspace[workspaceKey]; + + store.getState().focusTab(workspaceKey, sheetTabId); + expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore); + + store.getState().closeTab(workspaceKey, sheetTabId); + expect(topSheet(store)).toBeUndefined(); + expect(tabKinds(store)).toEqual(["terminal"]); + }); + + it("suspends pane focus while the sheet covers the panes", () => { + const store = createCompactStore(); + expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false); + + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(true); + + store.getState().closeTab(workspaceKey, sheetTabId ?? ""); + expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false); + }); + + it("never uses the sheet on wide layouts", () => { + const store = createCompactStore(); + store.getState().setLayoutMode("split-panes"); + + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(store.getState().sheetsByWorkspace).toEqual({}); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + }); + + it("keeps every stacked sheet as a tab when the layout widens, focusing the top one", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + store.getState().setLayoutMode("split-panes"); + + expect(store.getState().sheetsByWorkspace).toEqual({}); + expect(tabKinds(store)).toEqual(["terminal", "file", "file"]); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId); + }); + + it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + { id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" }, + ]); + const store = createCompactStore(); + + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + expect(topSheet(store)).toMatchObject({ + tab: { target: { kind: "agent", agentId: "child" } }, + policy: { openAsTab: true }, + }); + + store.getState().openTab({ + workspaceKey, + target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" }, + intent: "reveal", + }); + expect(topSheet(store)).toMatchObject({ + tab: { target: { kind: "provider_subagent", subagentId: "task-1" } }, + policy: { openAsTab: false }, + }); + + for (const agentId of ["root", "other-workspace-child"]) { + store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" }); + } + expect(topSheet(store)).toBeUndefined(); + expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); + }); + + it("gives a sheet target opened from inside the sheet the sheet's own parent", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const rootTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" }); + const childSheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + parentTabId: rootTabId ?? undefined, + }); + + const fileSheetTabId = store.getState().openTab({ + workspaceKey, + target: fileTarget, + intent: "reveal", + parentTabId: childSheetTabId ?? undefined, + }); + expect(topSheet(store)?.parentTabId).toBe(rootTabId); + + store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? ""); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId); + }); + + it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + { id: "second-root", parentAgentId: null, workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const rootTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" }); + const openChildSheet = () => + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + parentTabId: rootTabId ?? undefined, + }) ?? undefined; + + const terminalTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + parentTabId: openChildSheet(), + }); + expect(topSheet(store)).toBeUndefined(); + let layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId); + + const secondRootTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "second-root" }, + intent: "reveal", + parentTabId: openChildSheet(), + }); + expect(topSheet(store)).toBeUndefined(); + layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId); + expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId); + expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); + }); + + describe("unsaved sheet work", () => { + const sheetPanel = (tabId: string) => ({ + serverId: "server-1", + workspaceId: "sheet-ws", + tabId, + }); + let modifiedTabId: string | null = null; + + function openModifiedFileSheet(store: ReturnType): string { + const tabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + if (!tabId) throw new Error("Expected a sheet tab"); + store.getState().setTabState(workspaceKey, tabId, { scrollTop: 40 }); + setPanelInstanceAttributes(sheetPanel(tabId), { modified: true }); + modifiedTabId = tabId; + return tabId; + } + + function stripTab(store: ReturnType, tabId: string) { + const layout = store.getState().layoutByWorkspace[workspaceKey]; + return collectAllTabs(layout.root).find((tab) => tab.tabId === tabId) ?? null; + } + + function focusedTabId(store: ReturnType): string | null { + const layout = store.getState().layoutByWorkspace[workspaceKey]; + return findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null; + } + + afterEach(() => { + if (modifiedTabId) setPanelInstanceAttributes(sheetPanel(modifiedTabId), { modified: false }); + modifiedTabId = null; + }); + + function sheetTabId(store: ReturnType): string | null { + return topSheet(store)?.tab.tabId ?? null; + } + + it("stacks the next sheet target over a modified sheet", () => { + const store = createCompactStore(); + openModifiedFileSheet(store); + + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + expect(sheetTabId(store)).toBe(nextTabId); + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.state).toEqual({ + scrollTop: 40, + }); + }); + + it("keeps a covered sheet in place under a modified sheet instead of reordering", () => { + const store = createCompactStore(); + const otherTarget = { kind: "file" as const, path: "src/other.ts" }; + const otherTabId = store + .getState() + .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); + const modifiedSheetTabId = openModifiedFileSheet(store); + const unmodifiedTopTarget = { kind: "file" as const, path: "src/third.ts" }; + store.getState().openTab({ workspaceKey, target: unmodifiedTopTarget, intent: "reveal" }); + const focusedBefore = focusedTabId(store); + + const tabId = store + .getState() + .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); + + expect(tabId).toBe(otherTabId); + // Moving a sheet would remount its panel, so the stack keeps its order: the modified sheet + // stays on top of the one revealed, and only the unmodified sheet above them closes. + expect(sheetTargets(store)).toEqual([otherTarget, fileTarget]); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(stripTab(store, tabId ?? "")).toBeNull(); + expect(focusedTabId(store)).toBe(focusedBefore); + }); + + it("opens a tab target under a modified sheet without closing it", () => { + const store = createCompactStore(); + const modifiedSheetTabId = openModifiedFileSheet(store); + + const terminalTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(focusedTabId(store)).toBe(terminalTabId); + }); + + it("keeps a modified sheet open when another tab is chosen", () => { + const store = createCompactStore(); + const secondTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? ""; + const modifiedSheetTabId = openModifiedFileSheet(store); + + store.getState().focusTab(workspaceKey, firstTabId); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(focusedTabId(store)).toBe(firstTabId); + + store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? ""); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(focusedTabId(store)).toBe(secondTabId); + + store.getState().closeTab(workspaceKey, modifiedSheetTabId); + expect(sheetTabId(store)).toBeNull(); + expect(tabKinds(store)).toEqual(["terminal", "terminal"]); + }); + + it("closes an unmodified sheet when another tab is chosen", () => { + const store = createCompactStore(); + const secondTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? ""; + + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().focusTab(workspaceKey, firstTabId); + expect(sheetTabId(store)).toBeNull(); + expect(focusedTabId(store)).toBe(firstTabId); + + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? ""); + expect(sheetTabId(store)).toBeNull(); + expect(focusedTabId(store)).toBe(secondTabId); + expect(tabKinds(store)).toEqual(["terminal", "terminal"]); + }); + }); + + it("closes the sheet when its agent leaves the workspace", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + const snapshot = { + terminalsHydrated: false, + activeAgentIds: ["root"], + autoOpenAgentIds: [], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }; + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false }); + expect(topSheet(store)).toBeDefined(); + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true }); + expect(topSheet(store)).toBeUndefined(); + }); + + it("drops a departed agent's sheet from the middle of the stack", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + store.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: false, + activeAgentIds: ["root"], + autoOpenAgentIds: [], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + }); + + it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const sheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + const snapshot = { + agentsHydrated: true, + terminalsHydrated: false, + activeAgentIds: ["root", "child"], + autoOpenAgentIds: ["child"], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }; + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] }); + expect(topSheet(store)?.tab.tabId).toBe(sheetTabId); + + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: null, workspaceId: "sheet-ws" }, + ]); + store.getState().reconcileTabs(workspaceKey, snapshot); + + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect( + collectAllTabs(layout.root).filter( + (tab) => tab.target.kind === "agent" && tab.target.agentId === "child", + ), + ).toHaveLength(1); + }); + + it("re-resolves the sheet policy when the sheet tab gets another target", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const sheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" }, + intent: "reveal", + }); + + const replacedTabId = store + .getState() + .replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" }); + + expect(replacedTabId).toBe(sheetTabId); + expect(topSheet(store)).toMatchObject({ + tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } }, + policy: { openAsTab: true }, + }); + }); + + it("moves the sheet tab into the tab strip when it gets a target the sheet does not host", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + const replacedTabId = store.getState().replaceTab(workspaceKey, sheetTabId ?? "", { + kind: "terminal", + terminalId: "term-2", + }); + + expect(topSheet(store)).toBeUndefined(); + expect(replacedTabId).toBe(sheetTabId); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId); + expect(tabKinds(store)).toEqual(["terminal", "terminal"]); + }); + + it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => { + const store = createCompactStore(); + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const received: string[][] = []; + const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store); + + const sheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + store.getState().closeTab(workspaceKey, sheetTabId ?? ""); + stop(); + + expect(received).toEqual([[], ["child"], []]); + }); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 5de128b631..2aea369df6 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -59,7 +59,12 @@ import { type WorkspaceTabSnapshot, type WorkspaceLayout, } from "@/stores/workspace-layout-actions"; -import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; +import { + buildDeterministicWorkspaceTabId, + normalizeWorkspaceTabTarget, +} from "@/workspace-tabs/identity"; +import { isSheetTabModified, resolveCompactSheetPolicy } from "@/workspace-tabs/compact-sheet"; +import { panelResourceKey, type CompactSheetPolicy } from "@/panels/panel-manifest"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations"; @@ -105,7 +110,29 @@ export interface OpenWorkspaceTabInput { state?: JsonValue; } +/** + * How the app shell lays out a workspace: compact (one tab at a time, plus sheets), a single + * wide pane (touch tablets, which cannot render splits), or split panes. + */ +export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes"; + +/** A tab shown in a sheet over the compact workspace instead of in a pane. */ +export interface SheetTab { + tab: WorkspaceTab; + policy: CompactSheetPolicy; + parentTabId: string | null; +} + interface WorkspaceLayoutStore { + /** Published once by the app shell; callers never pass a form factor to open a tab. */ + layoutMode: WorkspaceLayoutMode; + /** + * Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The + * list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount + * its panel. A workspace with no sheet has no entry. Not persisted: a reload returns to the tab + * strip. + */ + sheetsByWorkspace: Record; layoutByWorkspace: Record; splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; @@ -116,7 +143,18 @@ interface WorkspaceLayoutStore { sidePaneIdByWorkspace: Record; /** Workspaces where PR detection already added its tab once; a closed tab never returns. */ pullRequestTabAutoOpenedByWorkspace: Record; + /** + * The one entry point for showing a target. On compact layouts a target whose panel declares + * `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id. + */ openTab: (input: OpenWorkspaceTabInput) => string | null; + /** Whether `openTab` with this input would show the target in a sheet. */ + opensInSheet: ( + input: Pick, + ) => boolean; + setLayoutMode: (mode: WorkspaceLayoutMode) => void; + /** Moves a sheet's tab, keeping its tab id, into the focused pane. */ + openSheetAsTab: (workspaceKey: string, tabId: string) => string | null; /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ autoOpenPullRequestTab: ( workspaceKey: string, @@ -472,6 +510,17 @@ export function selectExplorerSidebarPaneId( return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]); } +/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */ +export function selectPaneFocusSuspended( + state: Pick, + workspaceKey: string, +): boolean { + return ( + Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) || + workspaceKey in state.sheetsByWorkspace + ); +} + /** Whether the Explorer sidebar pane is currently on screen. */ export function selectIsExplorerSidebarVisible( state: ExplorerSidebarState, @@ -637,6 +686,245 @@ function attachParentTab(input: { }); } +const NO_SHEETS: readonly SheetTab[] = []; + +function getSheets( + state: Pick, + workspaceKey: string, +): readonly SheetTab[] { + return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS; +} + +function findSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): SheetTab | undefined { + return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId); +} + +function withSheets( + state: Pick, + workspaceKey: string, + sheets: readonly SheetTab[], +): Pick { + if (sheets.length > 0) { + return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } }; + } + if (!(workspaceKey in state.sheetsByWorkspace)) { + return { sheetsByWorkspace: state.sheetsByWorkspace }; + } + const { [workspaceKey]: _removed, ...sheetsByWorkspace } = state.sheetsByWorkspace; + return { sheetsByWorkspace }; +} + +function withoutSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheet.tab.tabId !== tabId), + ); +} + +function withSheetTab( + state: Pick, + workspaceKey: string, + tabId: string, + update: (sheet: SheetTab) => SheetTab, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).map((sheet) => + sheet.tab.tabId === tabId ? update(sheet) : sheet, + ), + ); +} + +function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) { + if (!layout) return false; + const resourceKey = panelResourceKey(target); + return collectAllTabs(layout.root).some((tab) => panelResourceKey(tab.target) === resourceKey); +} + +function resolveSheetPolicy( + state: Pick, + workspaceKey: string, + target: WorkspaceTabTarget, + intent: WorkspaceTabOpenIntent, +): CompactSheetPolicy | null { + if (state.layoutMode !== "compact" || intent === "background") return null; + if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) { + return null; + } + return resolveCompactSheetPolicy(workspaceKey, target); +} + +/** + * The parent a newly opened tab links to. A sheet tab is not in the layout, so a target opened + * from inside a sheet inherits that sheet's own parent instead. + */ +function resolveOpenParentTabId( + state: Pick, + workspaceKey: string, + parentTabId: string | undefined, +): string | null { + if (!parentTabId) return null; + return findSheet(state, workspaceKey, parentTabId)?.parentTabId ?? parentTabId; +} + +function placeTargetInSheet(input: { + /** The sheet whose tab the target keeps, if any. */ + kept: SheetTab | undefined; + target: WorkspaceTabTarget; + intent: WorkspaceTabOpenIntent; + policy: CompactSheetPolicy; + parentTabId: string | null; + state: JsonValue | undefined; +}): SheetTab { + if (input.kept) { + return { ...input.kept, tab: { ...input.kept.tab, target: input.target } }; + } + return { + tab: { + tabId: + input.intent === "reveal" + ? buildDeterministicWorkspaceTabId(input.target) + : createWorkspaceTabInstanceId(), + target: input.target, + createdAt: Date.now(), + ...(input.state !== undefined ? { state: input.state } : {}), + }, + policy: input.policy, + parentTabId: input.parentTabId, + }; +} + +/** + * Moves one sheet's tab into the workspace's focused pane, keeping its tab id and state. The + * other sheets stay; callers that bring the tab to the front dismiss them. + */ +function promoteSheetToTab( + state: WorkspaceLayoutStore, + workspaceKey: string, + tabId: string, +): { tabId: string; patch: Partial } | null { + const sheet = findSheet(state, workspaceKey, tabId); + if (!sheet) return null; + const { tab } = sheet; + const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT); + const input = { + ...placement, + target: tab.target, + now: tab.createdAt, + createTabId: () => tab.tabId, + state: tab.state, + }; + const result = isTargetOpenAsTab(placement.layout, tab.target) + ? revealTargetInLayout(input) + : createTabInLayout(input); + if (!result) return null; + const nextLayout = attachParentTab({ + layout: keepWorkspaceFocusOutOfExplorerSidebar( + result.layout, + placement.explorerSidebarPaneId, + placement.layout.focusedPaneId, + ), + childTabId: result.tabId, + parentTabId: sheet.parentTabId, + }); + return { + tabId: result.tabId, + patch: { + ...withoutSheet(state, workspaceKey, tab.tabId), + ...withoutFocusRestoration(state, workspaceKey), + hiddenAgentIdsByWorkspace: + tab.target.kind === "agent" + ? removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + workspaceKey, + tab.target.agentId, + ) + : state.hiddenAgentIdsByWorkspace, + layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: nextLayout }, + }, + }; +} + +/** + * A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the + * panel and drop the work. Only the explicit, guarded close removes it. + */ +function sheetHoldsUnsavedWork(workspaceKey: string, sheet: SheetTab): boolean { + return isSheetTabModified(workspaceKey, sheet.tab.tabId); +} + +/** + * Where an open lands among the sheet tabs, or null when it opens as a tab. A new target goes on + * top. A reveal of a resource a sheet already shows keeps that sheet's tab and uncovers it by + * closing the sheets above it, except ones holding unsaved work, which stay where they are. + */ +function resolveSheetPlacement( + state: WorkspaceLayoutStore, + workspaceKey: string, + target: WorkspaceTabTarget, + intent: WorkspaceTabOpenIntent, +): { policy: CompactSheetPolicy; keptIndex: number | null } | null { + const policy = resolveSheetPolicy(state, workspaceKey, target, intent); + if (!policy) return null; + if (intent !== "reveal") return { policy, keptIndex: null }; + const sheets = getSheets(state, workspaceKey); + const resourceKey = panelResourceKey(target); + const index = sheets.findIndex((sheet) => panelResourceKey(sheet.tab.target) === resourceKey); + return { policy, keptIndex: index === -1 ? null : index }; +} + +/** Closes the sheets because navigation moved on underneath them, except ones with unsaved work. */ +function dismissSheets( + state: Pick, + workspaceKey: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheetHoldsUnsavedWork(workspaceKey, sheet)), + ); +} + +/** Moves a sheet's tab to the front of the tab strip, dismissing the sheets that would cover it. */ +function openSheetTabInFront( + state: WorkspaceLayoutStore, + workspaceKey: string, + tabId: string, +): { tabId: string; patch: Partial } | null { + const promoted = promoteSheetToTab(state, workspaceKey, tabId); + if (!promoted) return null; + const next = { ...state, ...promoted.patch }; + return { + tabId: promoted.tabId, + patch: { ...promoted.patch, ...dismissSheets(next, workspaceKey) }, + }; +} + +/** + * A sheet agent closes once the workspace no longer has it (archived or gone), like its tab would, + * and once it stops being a subagent: as a root agent, reconcile gives it its own tab, and the sheet + * would hold a second tab with the same id. + */ +function sheetAgentNoLongerFits( + workspaceKey: string, + sheet: SheetTab, + snapshot: WorkspaceTabSnapshot, +): boolean { + if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false; + if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true; + return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null; +} + /** * Splits an Explorer sidebar out of the pane the user is in. Only reached by layouts saved * before the Explorer became part of the default tree; new ones are born with it. @@ -676,6 +964,8 @@ export function createWorkspaceLayoutStore( } return tabId; }, + layoutMode: "split-panes", + sheetsByWorkspace: {}, layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, @@ -691,6 +981,41 @@ export function createWorkspaceLayoutStore( if (!normalizedWorkspaceKey || !normalizedTarget) { return null; } + const parentTabId = resolveOpenParentTabId( + get(), + normalizedWorkspaceKey, + input.parentTabId, + ); + const sheetPlacement = resolveSheetPlacement( + get(), + normalizedWorkspaceKey, + normalizedTarget, + input.intent, + ); + if (sheetPlacement) { + const sheets = getSheets(get(), normalizedWorkspaceKey); + const { keptIndex } = sheetPlacement; + const sheet = placeTargetInSheet({ + kept: keptIndex === null ? undefined : sheets[keptIndex], + target: normalizedTarget, + intent: input.intent, + policy: sheetPlacement.policy, + parentTabId, + state: input.state, + }); + const next = + keptIndex === null + ? [...sheets, sheet] + : sheets.flatMap((existing, index) => { + if (index === keptIndex) return [sheet]; + if (index < keptIndex) return [existing]; + return sheetHoldsUnsavedWork(normalizedWorkspaceKey, existing) + ? [existing] + : []; + }); + set((state) => withSheets(state, normalizedWorkspaceKey, next)); + return sheet.tab.tabId; + } const placement = getOpenTabPlacement( get(), normalizedWorkspaceKey, @@ -734,6 +1059,8 @@ export function createWorkspaceLayoutStore( const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent"; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + // A tab brought to the front dismisses the sheet over it. + ...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace @@ -751,17 +1078,53 @@ export function createWorkspaceLayoutStore( : state.pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: input.parentTabId + [normalizedWorkspaceKey]: parentTabId ? attachParentTab({ layout: nextLayout, childTabId: result.tabId, - parentTabId: input.parentTabId, + parentTabId, }) : nextLayout, }, })); return result.tabId; }, + opensInSheet: (input) => { + const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey); + const normalizedTarget = normalizeWorkspaceTabTarget(input.target); + if (!normalizedWorkspaceKey || !normalizedTarget) return false; + return ( + resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !== + null + ); + }, + setLayoutMode: (mode) => { + set((state) => { + if (state.layoutMode === mode) return state; + if (mode === "compact") return { layoutMode: mode }; + // Widening keeps whatever the user had open in sheets as ordinary tabs. + const next: WorkspaceLayoutStore = { ...state, layoutMode: mode }; + for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) { + for (const sheet of sheets) { + const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId); + Object.assign( + next, + promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId), + ); + } + } + return next; + }); + }, + openSheetAsTab: (workspaceKey, tabId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + if (!normalizedWorkspaceKey || !normalizedTabId) return null; + const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId); + if (!promoted) return null; + set(promoted.patch); + return promoted.tabId; + }, showExplorerSidebar: (workspaceKey) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -881,6 +1244,9 @@ export function createWorkspaceLayoutStore( } set((state) => { + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withoutSheet(state, normalizedWorkspaceKey, normalizedTabId); + } const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); const explorerSidebarPaneId = resolveExplorerSidebarPaneId( layout, @@ -988,6 +1354,8 @@ export function createWorkspaceLayoutStore( return { ...withoutFocusRestoration(state, normalizedWorkspaceKey), + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1011,6 +1379,8 @@ export function createWorkspaceLayoutStore( }); return nextLayout ? { + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1024,6 +1394,29 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null; + if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) { + const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget); + const next: WorkspaceLayoutStore = { + ...get(), + ...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({ + ...sheet, + policy: policy ?? sheet.policy, + tab: { + ...sheet.tab, + target: normalizedTarget, + ...(tabState !== undefined ? { state: tabState } : {}), + }, + })), + }; + if (policy) { + set({ sheetsByWorkspace: next.sheetsByWorkspace }); + return normalizedTabId; + } + // A target sheets do not host becomes the tab it would have opened as. + const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId); + set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId)); + return promoted?.tabId ?? null; + } const result = replaceTabTargetInLayout({ layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), tabId: normalizedTabId, @@ -1054,6 +1447,15 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); if (!normalizedWorkspaceKey || !normalizedTabId) return; set((state) => { + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withSheetTab(state, normalizedWorkspaceKey, normalizedTabId, (sheet) => { + const { state: _previous, ...tab } = sheet.tab; + return { + ...sheet, + tab: tabState === undefined ? tab : { ...tab, state: tabState }, + }; + }); + } const layout = setTabStateInLayout({ layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), tabId: normalizedTabId, @@ -1148,15 +1550,22 @@ export function createWorkspaceLayoutStore( ); } } + const sheets = getSheets(state, normalizedWorkspaceKey); + const fittingSheets = sheets.filter( + (sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot), + ); + const sheetAgentGone = fittingSheets.length !== sheets.length; if ( state.layoutByWorkspace[normalizedWorkspaceKey] && nextLayout === rawLayout && - pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace + pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace && + !sheetAgentGone ) { return state; } return { + ...(sheetAgentGone ? withSheets(state, normalizedWorkspaceKey, fittingSheets) : {}), pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, @@ -1661,7 +2070,8 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.focusRestorationByWorkspace || normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || normalizedWorkspaceKey in state.sidePaneIdByWorkspace || - normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace; + normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace || + normalizedWorkspaceKey in state.sheetsByWorkspace; if (!hasAny) { return state; } @@ -1690,6 +2100,7 @@ export function createWorkspaceLayoutStore( const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } = state.sidePaneIdByWorkspace; return { + ...withSheets(state, normalizedWorkspaceKey, NO_SHEETS), pullRequestTabAutoOpenedByWorkspace, layoutByWorkspace, splitSizesByWorkspace, @@ -1795,10 +2206,10 @@ export function createWorkspaceLayoutStore( export const useWorkspaceLayoutStore = createWorkspaceLayoutStore(); /** - * The agent tabs that exist right now, across every workspace of this host, independently - * of which workspace views are mounted. Timeline sync uses this to release a chat when its - * tab closes. It is not a subscription source: this layout is restored from disk at launch, - * so subscribing to it would resume every agent the user has ever opened. + * The agent tabs that exist right now, including sheet tabs, across every workspace of this host, + * independently of which workspace views are mounted. Timeline sync uses this to release a chat + * when its tab closes. It is not a subscription source: this layout is restored from disk at + * launch, so subscribing to it would resume every agent the user has ever opened. */ export function observeOpenWorkspaceAgentIds( serverId: string, @@ -1806,11 +2217,21 @@ export function observeOpenWorkspaceAgentIds( store = useWorkspaceLayoutStore, ): () => void { let previous: string[] | undefined; - const publish = (state: Pick) => { + const publish = ( + state: Pick, + ) => { const ids = new Set(); - for (const [workspaceKey, layout] of Object.entries(state.layoutByWorkspace)) { + const tabsByWorkspace = [ + ...Object.entries(state.layoutByWorkspace).map( + ([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const, + ), + ...Object.entries(state.sheetsByWorkspace).map( + ([workspaceKey, sheets]) => [workspaceKey, sheets.map((sheet) => sheet.tab)] as const, + ), + ]; + for (const [workspaceKey, tabs] of tabsByWorkspace) { if (!workspaceKey.startsWith(`${serverId}:`)) continue; - for (const tab of collectAllTabs(layout.root)) { + for (const tab of tabs) { if (tab.target.kind === "agent") ids.add(tab.target.agentId); } } @@ -1821,7 +2242,12 @@ export function observeOpenWorkspaceAgentIds( listener(next); }; const unsubscribe = store.subscribe((state, before) => { - if (state.layoutByWorkspace !== before.layoutByWorkspace) publish(state); + if ( + state.layoutByWorkspace !== before.layoutByWorkspace || + state.sheetsByWorkspace !== before.sheetsByWorkspace + ) { + publish(state); + } }); publish(store.getState()); return unsubscribe; diff --git a/packages/app/src/styles/density.ts b/packages/app/src/styles/density.ts new file mode 100644 index 0000000000..2383685d24 --- /dev/null +++ b/packages/app/src/styles/density.ts @@ -0,0 +1,49 @@ +import { isCompactBreakpoint } from "@/constants/layout"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +/** + * Density sizes for surfaces that opt into touch sizing: rows and icons grow on compact + * layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay + * desktop-sized everywhere, so a surface migrates by swapping its sizes for these. + * + * Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes + * are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping. + */ + +export type DensityIconSize = keyof typeof ICON_SIZE; + +const COMPACT_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; + +// Compact layouts end where `md` begins; see `isCompactBreakpoint`. +function byLayout(compact: T, desktop: T) { + return { xs: compact, md: desktop }; +} + +export const density = { + /** A list row such as a sidebar entry. */ + rowHeight: byLayout(44, 36), + /** A row in a tight group, such as the sidebar's navigation rows. */ + tightRowHeight: byLayout(36, 28), + /** A square icon-only button that sits in a row or toolbar. */ + iconButton: byLayout(44, 28), +}; + +/** The width or height of a slot that holds an icon of `size`. */ +export function densityIconBox(size: DensityIconSize) { + return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]); +} + +export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number { + return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size]; +} + +/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */ +export function densityIconProps

( + size: DensityIconSize, + mapping: (theme: Theme) => P, +) { + return (theme: Theme, rt: { breakpoint?: string }) => ({ + ...mapping(theme), + size: densityIconSize(rt, size), + }); +} diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts index 707085e7c9..461d980ecd 100644 --- a/packages/app/src/styles/theme.test.ts +++ b/packages/app/src/styles/theme.test.ts @@ -68,9 +68,9 @@ describe("Pure black theme", () => { }); describe("Sidebar interaction surfaces", () => { - it("keeps Light selection distinct from the sidebar surface", () => { - expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface1); - expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface3); + it("keeps Light hover and selection darker than the sidebar surface", () => { + expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3); + expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4); expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar); }); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 04b1e8f6ae..8e1e77aac7 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -267,8 +267,8 @@ export function buildLightSemanticColors(tint: LightThemeConfig) { surface4: tint.surface4, surfaceDiffEmpty: tint.surfaceDiffEmpty, surfaceSidebar: tint.surfaceSidebar, - surfaceSidebarHover: tint.surface1, - surfaceSidebarSelected: tint.surface3, + surfaceSidebarHover: tint.surface3, + surfaceSidebarSelected: tint.surface4, surfaceWorkspace: tint.surface0, interactionHighlight: "rgba(0, 0, 0, 0.06)", diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index e93d62eb2d..9c6227a34f 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -9,3 +9,4 @@ export { type ArchiveFinishedStatus, type UseArchiveFinishedSubagentsInput, } from "./use-archive-finished"; +export { useSubagentOpensInSheet } from "./opens-in-sheet"; diff --git a/packages/app/src/subagents/opens-in-sheet.test.tsx b/packages/app/src/subagents/opens-in-sheet.test.tsx new file mode 100644 index 0000000000..0792091135 --- /dev/null +++ b/packages/app/src/subagents/opens-in-sheet.test.tsx @@ -0,0 +1,133 @@ +/** + * @vitest-environment jsdom + */ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { useSubagentOpensInSheet } from "./opens-in-sheet"; +import { selectSubagentsForParent, type SubagentRow } from "./select"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "ws-main"; +const AGENT_TIMESTAMP = new Date("2026-04-21T10:00:00.000Z"); + +const AGENT_DEFAULTS: Agent = { + serverId: SERVER_ID, + id: "agent", + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: AGENT_TIMESTAMP, + updatedAt: AGENT_TIMESTAMP, + lastUserMessageAt: null, + lastActivityAt: AGENT_TIMESTAMP, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: undefined, + lastUsage: undefined, + lastError: null, + title: "Agent", + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, + model: null, + features: undefined, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + parentAgentId: null, + labels: {}, + projectPlacement: null, +}; + +const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, +}); +if (!workspaceKey) throw new Error("workspace key"); + +function seedParentWithChild(): SubagentRow { + const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" }; + const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" }; + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore.getState().setAgents( + SERVER_ID, + new Map([ + [parent.id, parent], + [child.id, child], + ]), + ); + const [row] = selectSubagentsForParent( + useSessionStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent" }, + new Set(), + ); + if (!row) throw new Error("child row"); + return row; +} + +afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); + useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + layoutByWorkspace: {}, + sheetsByWorkspace: {}, + hiddenAgentIdsByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, + }); +}); + +describe("useSubagentOpensInSheet", () => { + it("re-renders the row's answer as the child's tab opens and closes", () => { + const row = seedParentWithChild(); + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("compact"); + const target = { kind: "agent", agentId: "child" } as const; + + // The row renders its answer, as the track does, so a stale render is a stale answer. + const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row)); + expect(result.current).toBe(true); + + let tabId: string | null = null; + act(() => { + const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" }); + tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null; + }); + expect(tabId).not.toBeNull(); + expect(result.current).toBe(false); + + act(() => { + store.closeTab(workspaceKey, tabId ?? ""); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/packages/app/src/subagents/opens-in-sheet.ts b/packages/app/src/subagents/opens-in-sheet.ts new file mode 100644 index 0000000000..26432ea039 --- /dev/null +++ b/packages/app/src/subagents/opens-in-sheet.ts @@ -0,0 +1,29 @@ +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import type { SubagentRow } from "./select"; + +/** The workspace target a subagent row opens. */ +export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget { + return row.kind === "provider" + ? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id } + : { kind: "agent", agentId: row.id }; +} + +/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */ +export function useSubagentOpensInSheet( + workspaceKey: string | null, +): (row: SubagentRow) => boolean { + // The rows render the answer, so this subscribes to what the answer reads: the layout mode and + // the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet, + // and a row rendered while that tab existed must re-render once it closes, or choosing the row + // would still close the list it sits in. + useWorkspaceLayoutStore((state) => state.layoutMode); + useWorkspaceLayoutStore((state) => + workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, + ); + return (row) => + workspaceKey !== null && + useWorkspaceLayoutStore + .getState() + .opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" }); +} diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index 96badfd354..01d6880d5b 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -1,8 +1,11 @@ import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; +import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; import { observeProviderSubagentTimeline, providerSubagentKey, + refreshProviderSubagents, + resyncProviderSubagents, useProviderSubagentStore, } from "./provider-store"; @@ -15,6 +18,7 @@ afterEach(() => { descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), + trackedParents: new Set(), }); }); @@ -727,3 +731,96 @@ describe("projected child history", () => { expect(current().lastSeq).toBe(2000); }); }); + +describe("resyncing after the update feed resubscribes", () => { + function child( + id: string, + parentAgentId: string, + status: ProviderSubagentDescriptorPayload["status"], + ): ProviderSubagentDescriptorPayload { + return { + id, + parentAgentId, + provider: "claude", + title: "Explore", + description: null, + status, + createdAt: "2026-10-07T10:00:00.000Z", + updatedAt: "2026-10-07T10:00:00.000Z", + toolCallId: id, + }; + } + + function fakeDaemon(lists: Record) { + const requested: string[] = []; + return { + requested, + lists, + client: { + listProviderSubagents: async (parentAgentId: string) => { + requested.push(parentAgentId); + return { + requestId: "list", + parentAgentId, + subagents: lists[parentAgentId] ?? [], + error: null, + }; + }, + }, + }; + } + + const status = (parentAgentId: string, subagentId: string) => + useProviderSubagentStore + .getState() + .descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status; + + test("catches up on children that finished or started while updates were not delivered", async () => { + const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] }); + await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID); + expect(status(PARENT_ID, "child-1")).toBe("running"); + + // The connection dropped: the daemon finished child-1 and ran child-2 without telling us. + daemon.lists[PARENT_ID] = [ + child("child-1", PARENT_ID, "completed"), + child("child-2", PARENT_ID, "completed"), + ]; + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(status(PARENT_ID, "child-1")).toBe("completed"); + expect(status(PARENT_ID, "child-2")).toBe("completed"); + }); + + test("catches up on a parent whose first list failed while disconnected", async () => { + const daemon = fakeDaemon({}); + const disconnected = { + listProviderSubagents: async () => { + throw new DaemonConnectionError("Transport not connected"); + }, + }; + await expect( + refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID), + ).rejects.toBeInstanceOf(DaemonConnectionError); + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: child("child-1", PARENT_ID, "running"), + }); + + // The connection dropped again: the daemon finished child-1 without telling us. + daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")]; + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(status(PARENT_ID, "child-1")).toBe("completed"); + }); + + test("lists again only the parents this server was showing", async () => { + const daemon = fakeDaemon({}); + await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID); + await refreshProviderSubagents(daemon.client, "server-2", "other-parent"); + daemon.requested.length = 0; + + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(daemon.requested).toEqual([PARENT_ID]); + }); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 86e857d774..fd44dae8d2 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -28,6 +28,9 @@ interface ProviderSubagentState { descriptors: Map; timelines: Map; hiddenFromTrack: Set; + /** Parents whose list this client asked for, so a resubscribed update feed can catch them up. */ + trackedParents: Set; + trackParent(serverId: string, parentAgentId: string): void; hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void; replaceList( serverId: string, @@ -84,6 +87,7 @@ export function refreshProviderSubagents( const pending = clientRequests.get(requestKey); if (pending) return pending; + useProviderSubagentStore.getState().trackParent(serverId, parentAgentId); const request = client .listProviderSubagents(parentAgentId) .then((payload) => { @@ -101,6 +105,27 @@ function parentPrefix(serverId: string, parentAgentId: string): string { return `${serverId}\0${parentAgentId}\0`; } +/** + * Updates are not replayed across a reconnect, so a child that finished or started while the + * update feed was down stays stale until its parent is listed again. Call this once the feed has + * resubscribed, so every update after the list is delivered. + */ +export async function resyncProviderSubagents( + client: ProviderSubagentListClient, + serverId: string, +): Promise { + const serverPrefix = `${serverId}\0`; + const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents] + .filter((key) => key.startsWith(serverPrefix)) + .map((key) => key.slice(serverPrefix.length, -1)); + await Promise.all( + parentAgentIds.map((parentAgentId) => + // A parent that was archived meanwhile has nothing left to catch up. + refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined), + ), + ); +} + const EMPTY_TIMELINE: ProviderSubagentTimelineState = { tail: [], head: [], @@ -147,6 +172,14 @@ export const useProviderSubagentStore = create((set) => ( descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), + trackedParents: new Set(), + trackParent(serverId, parentAgentId) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + if (state.trackedParents.has(prefix)) return state; + return { trackedParents: new Set(state.trackedParents).add(prefix) }; + }); + }, hideFromTrack(serverId, parentAgentId, subagentIds) { set((state) => { const hiddenFromTrack = new Set(state.hiddenFromTrack); diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 601030562f..c1834587d2 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -35,6 +35,11 @@ export interface SubagentsTrackProps { rows: SubagentRow[]; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + /** + * Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on + * close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list. + */ + opensInSheet: (row: SubagentRow) => boolean; onArchiveSubagent: (id: string) => void; onArchiveFinished?: () => void; archiveFinishedStatus?: ArchiveFinishedStatus; @@ -63,6 +68,7 @@ export function SubagentsTrack({ rows, onOpenSubagent, onOpenProviderSubagent, + opensInSheet, onArchiveSubagent, onArchiveFinished, archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS, @@ -103,6 +109,7 @@ export function SubagentsTrack({ serverId={serverId} onOpenSubagent={onOpenSubagent} onOpenProviderSubagent={onOpenProviderSubagent} + opensInSheet={opensInSheet(row)} onArchiveSubagent={onArchiveSubagent} onDetachSubagent={onDetachSubagent} /> @@ -174,6 +181,7 @@ interface SubagentsTrackRowProps { row: SubagentRow; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + opensInSheet: boolean; onArchiveSubagent: (id: string) => void; onDetachSubagent?: (id: string) => void; } @@ -183,6 +191,7 @@ function SubagentsTrackRow({ row, onOpenSubagent, onOpenProviderSubagent, + opensInSheet, onArchiveSubagent, onDetachSubagent, }: SubagentsTrackRowProps): ReactElement { @@ -245,6 +254,7 @@ function SubagentsTrackRow({ {renderRow} diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index 35dacf397b..3ce6942301 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -12,11 +12,11 @@ function user(id: string, timestamp: Date): StreamItem { }; } -function assistant(id: string, timestamp: Date): StreamItem { +function assistant(id: string, timestamp: Date, text = id): StreamItem { return { kind: "assistant_message", id, - text: id, + text, timestamp, }; } @@ -125,4 +125,97 @@ describe("deriveStreamTurnTiming", () => { durationMs: null, }); }); + + it("keeps the timing map identity while a streamed delta grows the live head", () => { + const tail = [ + user("u1", new Date("2026-05-15T00:00:00.000Z")), + assistant("a1", new Date("2026-05-15T00:00:07.000Z")), + user("u2", new Date("2026-05-15T00:01:00.000Z")), + ]; + const derive = (text: string, at: string) => + deriveStreamTurnTiming({ + isTurnActive: true, + activeTurnStartedAt: tail[2]?.timestamp ?? null, + tail, + head: [assistant("a2", new Date(at), text)], + }); + + const first = derive("one", "2026-05-15T00:01:04.000Z"); + const second = derive("one two", "2026-05-15T00:01:05.000Z"); + + assert.equal(second.byAssistantId, first.byAssistantId); + assert.deepEqual(first.byAssistantId.get("a1"), { + completedAt: tail[1]?.timestamp, + durationMs: 7000, + }); + assert.equal(first.byAssistantId.has("a2"), false); + }); + + it("keeps the timing map identity when the head closed the tail's open turn", () => { + const lastTailAt = new Date("2026-05-15T00:00:07.000Z"); + const tail = [user("u1", new Date("2026-05-15T00:00:00.000Z")), assistant("a1", lastTailAt)]; + const derive = (text: string, at: string) => + deriveStreamTurnTiming({ + isTurnActive: true, + activeTurnStartedAt: null, + tail, + head: [ + user("u2", new Date("2026-05-15T00:01:00.000Z")), + assistant("a2", new Date(at), text), + ], + }); + + const first = derive("one", "2026-05-15T00:01:04.000Z"); + const second = derive("one two", "2026-05-15T00:01:05.000Z"); + + assert.equal(second.byAssistantId, first.byAssistantId); + assert.deepEqual(first.byAssistantId.get("a1"), { + completedAt: lastTailAt, + durationMs: 7000, + }); + }); + + it("produces a new timing map when the streamed turn completes", () => { + const tail = [user("u1", new Date("2026-05-15T00:00:00.000Z"))]; + const completedAt = new Date("2026-05-15T00:00:09.000Z"); + const head = [assistant("a1", completedAt)]; + + const active = deriveStreamTurnTiming({ + isTurnActive: true, + activeTurnStartedAt: tail[0]?.timestamp ?? null, + tail, + head, + }); + const completed = deriveStreamTurnTiming({ + isTurnActive: false, + activeTurnStartedAt: null, + tail, + head, + }); + + assert.notEqual(completed.byAssistantId, active.byAssistantId); + assert.equal(active.byAssistantId.has("a1"), false); + assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 }); + }); + + it("derives fresh timing when the tail itself changes", () => { + const firstTail = [ + user("u1", new Date("2026-05-15T00:00:00.000Z")), + assistant("a1", new Date("2026-05-15T00:00:07.000Z")), + user("u2", new Date("2026-05-15T00:01:00.000Z")), + ]; + const secondTail = [...firstTail, assistant("a2", new Date("2026-05-15T00:01:03.000Z"))]; + const derive = (tail: StreamItem[]) => + deriveStreamTurnTiming({ isTurnActive: false, activeTurnStartedAt: null, tail, head: [] }); + + const first = derive(firstTail); + const second = derive(secondTail); + + assert.notEqual(second.byAssistantId, first.byAssistantId); + assert.equal(first.byAssistantId.has("a2"), false); + assert.deepEqual(second.byAssistantId.get("a2"), { + completedAt: secondTail[3]?.timestamp, + durationMs: 3000, + }); + }); }); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index 4542962af4..c47a6983cd 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -11,61 +11,135 @@ export interface StreamTurnTiming { runningStartedAt: Date | null; } +interface OpenTurn { + userAt: Date | null; + lastItemAt: Date | null; + assistantIds: readonly string[]; + previousItem: StreamItem | null; +} + +interface HeadTurnTiming { + additions: Map; + byAssistantId: Map; +} + +interface TailTurnTiming { + completed: Map; + openTurn: OpenTurn; + lastHead: HeadTurnTiming | null; +} + +const EMPTY_OPEN_TURN: OpenTurn = { + userAt: null, + lastItemAt: null, + assistantIds: [], + previousItem: null, +}; + +function closeTurn(turn: OpenTurn, into: Map): void { + if (!turn.lastItemAt || turn.assistantIds.length === 0) { + return; + } + const timing: TurnTiming = { + completedAt: turn.lastItemAt, + durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null, + }; + for (const id of turn.assistantIds) { + into.set(id, timing); + } +} + +function walkTurns( + items: readonly StreamItem[], + from: OpenTurn, + into: Map, +): OpenTurn { + let userAt = from.userAt; + let lastItemAt = from.lastItemAt; + let assistantIds = [...from.assistantIds]; + let previousItem = from.previousItem; + for (const item of items) { + if (startsNewTurn(item, previousItem)) { + closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into); + userAt = item.kind === "user_message" ? item.timestamp : null; + lastItemAt = null; + assistantIds = []; + } + lastItemAt = item.timestamp; + if (item.kind === "assistant_message") { + assistantIds.push(item.id); + } + previousItem = item; + } + return { userAt, lastItemAt, assistantIds, previousItem }; +} + +function areTimingsEqual(a: Map, b: Map): boolean { + if (a.size !== b.size) { + return false; + } + for (const [id, timing] of a) { + const other = b.get(id); + if ( + !other || + other.completedAt.getTime() !== timing.completedAt.getTime() || + other.durationMs !== timing.durationMs + ) { + return false; + } + } + return true; +} + +// The tail walk is cached by array identity so a streamed delta, which only replaces the +// head, walks the head from the carried open turn. The returned Map keeps its identity +// while the head adds no completed turn, so memos keyed on it do not miss per tick. +const tailTurnTimingCache = new WeakMap(); + +function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming { + const cached = tailTurnTimingCache.get(tail); + if (cached) { + return cached; + } + const completed = new Map(); + const openTurn = walkTurns(tail, EMPTY_OPEN_TURN, completed); + const timing: TailTurnTiming = { completed, openTurn, lastHead: null }; + tailTurnTimingCache.set(tail, timing); + return timing; +} + +function resolveByAssistantId( + tail: TailTurnTiming, + additions: Map, +): Map { + if (additions.size === 0) { + return tail.completed; + } + if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) { + return tail.lastHead.byAssistantId; + } + const byAssistantId = new Map(tail.completed); + for (const [id, timing] of additions) { + byAssistantId.set(id, timing); + } + tail.lastHead = { additions, byAssistantId }; + return byAssistantId; +} + export function deriveStreamTurnTiming(params: { isTurnActive: boolean; activeTurnStartedAt: Date | null; tail: StreamItem[]; head: StreamItem[]; }): StreamTurnTiming { - const byAssistantId = new Map(); - let currentUserAt: Date | null = null; - let currentLastItemAt: Date | null = null; - let currentAssistantIds: string[] = []; - let previousItem: StreamItem | null = null; - - const flushCompletedTurn = () => { - if (!currentLastItemAt || currentAssistantIds.length === 0) { - return; - } - const timing: TurnTiming = { - completedAt: currentLastItemAt, - durationMs: currentUserAt - ? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()) - : null, - }; - for (const id of currentAssistantIds) { - byAssistantId.set(id, timing); - } - }; - - const visitItem = (item: StreamItem) => { - if (startsNewTurn(item, previousItem)) { - flushCompletedTurn(); - currentUserAt = item.kind === "user_message" ? item.timestamp : null; - currentLastItemAt = null; - currentAssistantIds = []; - } - currentLastItemAt = item.timestamp; - if (item.kind === "assistant_message") { - currentAssistantIds.push(item.id); - } - previousItem = item; - }; - - for (const item of params.tail) { - visitItem(item); - } - for (const item of params.head) { - visitItem(item); - } - - const runningStartedAt = params.isTurnActive ? params.activeTurnStartedAt : null; + const tail = getTailTurnTiming(params.tail); + const additions = new Map(); + const openTurn = walkTurns(params.head, tail.openTurn, additions); if (!params.isTurnActive) { - flushCompletedTurn(); + closeTurn(openTurn, additions); } - return { - byAssistantId, - runningStartedAt, + byAssistantId: resolveByAssistantId(tail, additions), + runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null, }; } diff --git a/packages/app/src/tool-calls/presentation.test.ts b/packages/app/src/tool-calls/presentation.test.ts index 3caf235ace..fde0ba020f 100644 --- a/packages/app/src/tool-calls/presentation.test.ts +++ b/packages/app/src/tool-calls/presentation.test.ts @@ -92,3 +92,42 @@ describe("tool-call presentation", () => { expect(presentation.icon).toBe(fakeIcons.brain); }); }); + +it.each([ + ["message", "Message from QA messenger"], + ["finished", "QA messenger finished"], + ["errored", "QA messenger errored"], + ["permission-required", "QA messenger needs permission"], + ["closed", "QA messenger closed"], +] as const)("presents %s from structured provenance without a second label", (event, heading) => { + const presentation = buildToolCallPresentation({ + toolName: "internal_name_should_not_be_visible", + status: "completed", + error: null, + detail: { type: "plain_text", label: "legacy summary", text: "legacy body" }, + agentMessage: { + event, + sender: { id: "server::sender", title: "QA messenger" }, + text: "The delivered message.", + }, + resolveIcon: fakeResolveIcon, + }); + expect(presentation.displayName).toBe(heading); + expect(presentation.summary).toBeUndefined(); + expect(presentation.detail).toEqual({ + type: "plain_text", + icon: "bot", + text: "From agent: server::sender\n\nThe delivered message.", + }); +}); + +it("uses an opaque sender ID when no title is available", () => { + const presentation = buildToolCallPresentation({ + toolName: "agent_message", + status: "completed", + error: null, + agentMessage: { event: "message", sender: { id: "remote::worker" }, text: "Hello" }, + resolveIcon: fakeResolveIcon, + }); + expect(presentation.displayName).toBe("Message from remote::worker"); +}); diff --git a/packages/app/src/tool-calls/presentation.ts b/packages/app/src/tool-calls/presentation.ts index 3db13ccbbd..f25d752624 100644 --- a/packages/app/src/tool-calls/presentation.ts +++ b/packages/app/src/tool-calls/presentation.ts @@ -1,3 +1,5 @@ +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; +import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display"; import type { PlanOutcome } from "@/components/plan-card"; import type { ComponentType } from "react"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; @@ -13,6 +15,7 @@ type ToolCallStatus = "executing" | "running" | "completed" | "failed" | "cancel export type ToolCallPresentationIcon = ComponentType<{ size?: number; color?: string }>; interface BuildToolCallPresentationInput { + agentMessage?: AgentMessage; toolName: string; status: ToolCallStatus; error: unknown; @@ -23,6 +26,7 @@ interface BuildToolCallPresentationInput { } export interface ToolCallPresentation { + detail: ToolCallDetail | undefined; displayName: string; summary?: string; errorText?: string; @@ -51,9 +55,13 @@ function displayDetail(detail: ToolCallDetail | undefined): ToolCallDetail { export function buildToolCallPresentation( input: BuildToolCallPresentationInput, ): ToolCallPresentation { - const detailForDisplay = displayDetail(input.detail); + const detail = input.agentMessage + ? buildAgentMessageDisplay(input.agentMessage).detail + : input.detail; + const detailForDisplay = displayDetail(detail); const displayModel = buildToolCallDisplayModel({ name: input.toolName, + agentMessage: input.agentMessage, status: displayStatus(input.status), error: input.error ?? null, detail: detailForDisplay, @@ -61,21 +69,22 @@ export function buildToolCallPresentation( cwd: input.cwd, }); const isLoadingDetails = isPendingToolCallDetail({ - detail: input.detail, + detail, status: input.status, error: input.error, }); - const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(input.detail); + const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(detail); return { + detail, displayName: displayModel.displayName, summary: displayModel.summary, errorText: displayModel.errorText, - icon: input.resolveIcon(input.toolName, input.detail), + icon: input.resolveIcon(input.toolName, detail), isLoadingDetails, hasDetails, canOpenDetails: hasDetails || isLoadingDetails, - openFilePath: extractToolCallFilePath(input.detail), + openFilePath: extractToolCallFilePath(detail), isPlan: input.detail?.type === "plan", planOutcome: input.detail?.type === "plan" ? resolvePlanOutcome(input) : undefined, }; diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index f33a9c524b..62e26698db 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -2305,3 +2305,28 @@ describe("notification timeline items", () => { expect(new Set(state.map((item) => item.id)).size).toBe(state.length); }); }); + +it("retains agent-message provenance through live delivery and repeated history", () => { + const agentMessage = { + event: "finished" as const, + sender: { id: "remote::worker", title: "Reviewer" }, + text: "Done", + }; + const event = { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "delivery", + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Done" }, + agentMessage, + }, + } satisfies AgentStreamEventPayload; + const live = reduceStreamUpdate([], event, new Date(1)); + const replay = reduceStreamUpdate(live, event, new Date(2)); + expect(replay).toHaveLength(1); + expect(replay[0]).toMatchObject({ payload: { data: { agentMessage } } }); +}); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 78789fa2d8..56cf14558b 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { AgentProvider, AgentTimelineItem, @@ -745,6 +746,7 @@ interface OrchestratorToolCallData { } export interface AgentToolCallData { + agentMessage?: AgentMessage; provider: AgentProvider; callId: string; name: string; @@ -802,6 +804,8 @@ export interface CompactionItem { export interface PluginTimelineStreamItem { kind: "plugin"; id: string; + /** The id of the stream item a timeline transformer replaced; set only by projection. */ + sourceId?: string; timelineCursor?: TimelinePosition; turnId?: string; timestamp: Date; @@ -1446,6 +1450,7 @@ function reduceTimelineToolCall( error: item.error, detail: item.detail, metadata: item.metadata, + ...(item.agentMessage ? { agentMessage: item.agentMessage } : {}), }, timestamp, timelineCursor, diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index d8e2ce74d1..20297c9df0 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -31,7 +31,7 @@ export function UsageControls({ const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); return ( - {hostSelection && hostSelection.hosts.length > 1 ? ( + {hasHostChoice(hostSelection) ? ( 1; +} + function usageHostOptionTestID(serverId: string): string { return `usage-host-filter-item-${serverId}`; } @@ -65,18 +71,22 @@ const styles = StyleSheet.create((theme) => ({ })); /** - * One host's usage and the title-row controls that go with it, for the Usage screen and the - * compact usage sheet. + * One host’s usage and title-row controls for the Usage modal. + * Returns null when there is nothing to control, so headers leave no empty island. */ export function useHostUsageWithControls(hostSelection: UsageHostSelection): { view: UsageView; refresh: () => void; - controls: ReactElement; + controls: ReactElement | null; } { const { view, refresh } = useHostUsage(hostSelection.serverId); + const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable"; const controls = useMemo( - () => , - [hostSelection, refresh, view], + () => + hasControls ? ( + + ) : null, + [hasControls, hostSelection, refresh, view], ); return { view, refresh, controls }; } diff --git a/packages/app/src/usage/hosts.ts b/packages/app/src/usage/hosts.ts index 450e0a6157..dba21dc316 100644 --- a/packages/app/src/usage/hosts.ts +++ b/packages/app/src/usage/hosts.ts @@ -6,7 +6,7 @@ import { } from "@/stores/navigation-active-workspace-store"; import { resolveUsageHostId, - resolveUsageScreenHostId, + resolveUsageModalHostId, type UsageHost, type UsageHostChoice, } from "./model"; @@ -35,7 +35,7 @@ export function useUsageHostId(): string | null { } /** - * The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. A pick + * The host the Usage modal shows, and the hosts to pick from. A pick * is saved on the device, so the sidebar Usage row and later visits show the same host. */ export function useUsageHostSelection(): { @@ -55,5 +55,5 @@ export function useUsageHostSelection(): { }, [updateSettings], ); - return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select }; + return { serverId: resolveUsageModalHostId(choice), connectedHosts, select }; } diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index c9ae924f10..45599aba2d 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -1,7 +1,6 @@ export { AgentUsage } from "./agent-usage"; export { HostUsageSection } from "./host-usage-section"; export { useHostReportsUsage } from "./queries"; -export { UsageScreen } from "./usage-screen"; export { UsageSidebarItem, UsageSidebarRoot, diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 1276157120..a2c493619d 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -6,7 +6,7 @@ import { replaceReport, resolveUsageRefresh, resolveUsageHostId, - resolveUsageScreenHostId, + resolveUsageModalHostId, resolveUsageView, settleReports, upsertReport, @@ -117,35 +117,35 @@ describe("resolveUsageHostId", () => { }); }); -describe("resolveUsageScreenHostId", () => { +describe("resolveUsageModalHostId", () => { it("shows the picked host while it stays connected, even one that cannot report usage", () => { - expect(resolveUsageScreenHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( + expect(resolveUsageModalHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( "old", ); - expect( - resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }), - ).toBe("b"); + expect(resolveUsageModalHostId({ pickedServerId: "offline", activeServerId: "b", hosts })).toBe( + "b", + ); }); it("otherwise shows the host the sidebar row reads", () => { for (const pickedServerId of [null, "a", "offline"]) { for (const activeServerId of ["b", null, "old", "offline"]) { const choice = { pickedServerId, activeServerId, hosts }; - expect(resolveUsageScreenHostId(choice)).toBe(resolveUsageHostId(choice)); + expect(resolveUsageModalHostId(choice)).toBe(resolveUsageHostId(choice)); } } }); - it("shows the first connected host when none reports usage, so the screen says to update it", () => { + it("shows the first connected host when none reports usage, so the modal says to update it", () => { expect( - resolveUsageScreenHostId({ + resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: hosts.slice(0, 2), }), ).toBe("old"); expect( - resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), + resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), ).toBeNull(); }); }); @@ -257,6 +257,22 @@ describe("settleReports", () => { expect(settleReports([refreshed, beta], [alpha, beta])).toEqual([refreshed, beta]); }); + it("keeps the displayed order when providers finish a refresh in reverse order", () => { + const refreshed = entry({ sourceId: "alpha", planLabel: "New" }); + const streamed = upsertReport(upsertReport([alpha, beta], beta), refreshed); + expect(settleReports(streamed, [beta, refreshed])).toEqual([refreshed, beta]); + }); + + it("drops missing reports and appends new ones without moving surviving reports", () => { + const gamma = entry({ sourceId: "gamma" }); + const delta = entry({ sourceId: "delta" }); + expect(settleReports([alpha, beta, gamma], [delta, gamma, alpha])).toEqual([ + alpha, + gamma, + delta, + ]); + }); + it("takes the finished list as is when nothing was shown yet", () => { expect(settleReports(undefined, [alpha, beta])).toEqual([alpha, beta]); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 2b2e4c54cf..ed8a39163a 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -82,17 +82,24 @@ export function upsertReport( } /** - * The list a finished request leaves: its reports, dropping any the host no longer has, except - * that a copy on screen fetched after the request's copy stays. + * Keep displayed reports in place: the finished list is in fetch-completion order, which can + * change on every refresh. Drop missing reports, append new ones, and keep newer fetched copies. */ export function settleReports( shown: readonly UsageReportEntry[] | undefined, finished: readonly UsageReportEntry[], ): UsageReportEntry[] { - return finished.map((report) => { - const copy = shown?.find((entry) => entry.id === report.id); - return copy ? laterFetched(copy, report) : report; - }); + const remaining = new Map(finished.map((report) => [report.id, report])); + const settled: UsageReportEntry[] = []; + for (const report of shown ?? []) { + const arriving = remaining.get(report.id); + if (!arriving) { + continue; + } + settled.push(laterFetched(report, arriving)); + remaining.delete(report.id); + } + return [...settled, ...remaining.values()]; } export interface UsageQueryState { @@ -176,11 +183,11 @@ export function resolveUsageHostId(choice: UsageHostChoice): string | null { } /** - * The host the Usage screen shows: the picked host while it is connected, even one that cannot - * report usage so the screen says to update it; else the sidebar row's host; else the first + * The host the Usage modal shows: the picked host while it is connected, even one that cannot + * report usage so the modal says to update it; else the sidebar row's host; else the first * connected host. */ -export function resolveUsageScreenHostId(choice: UsageHostChoice): string | null { +export function resolveUsageModalHostId(choice: UsageHostChoice): string | null { const connected = choice.hosts.filter((host) => host.isConnected); const picked = connected.find((host) => host.serverId === choice.pickedServerId); return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx index bf6dc787b2..48754c4e19 100644 --- a/packages/app/src/usage/refresh-button.tsx +++ b/packages/app/src/usage/refresh-button.tsx @@ -9,7 +9,10 @@ import { usageCopy } from "./copy"; const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -/** Refreshes every report on the host; spins while a refresh is in flight. */ +/** + * Refreshes every report on the host; spins while a refresh is in flight. On compact it sits in the + * header's island, so its highlight is round. + */ export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) { const compact = useIsCompactFormFactor(); const iconSize = paneContentToolbarIconSize(compact); diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index fc8f28c1c0..d90013d8bf 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -1,4 +1,3 @@ -import { router } from "expo-router"; import { createContext, Fragment, @@ -17,13 +16,9 @@ import { type PressableStateCallbackType, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; -import { buildUsageRoute } from "@/utils/host-routes"; -import { useHostUsageWithControls } from "./controls"; -import { useUsagePreferences, type UsageDisplay } from "./display"; -import { useUsageHostId, useUsageHostSelection } from "./hosts"; +import { useUsagePreferences } from "./display"; +import { useUsageHostId } from "./hosts"; import { useUsageHostReports } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import { UsageMeter } from "./meter"; @@ -35,8 +30,7 @@ import { type PinnedUsageLayout, type PinnedUsageSource, } from "./pinned"; -import type { UsageHost } from "./model"; -import { UsageBody } from "./usage-section"; +import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ function useUsageSummary(): readonly PinnedUsageSource[] { @@ -52,8 +46,8 @@ export function useHasUsageSummary(): boolean { /** * The sidebar footer's usage entry: each summary window's source icon and percent, and nothing - * while no summary window has data, since the footer's Usage icon already opens the screen. - * Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead. + * while no summary window has data, since the footer's Usage icon already opens Usage. + * Pressing it opens the Usage modal. */ export function UsageSidebarItem() { const { t } = useTranslation(); @@ -66,30 +60,25 @@ export function UsageSidebarItem() { const OpenSidebarUsageContext = createContext<(() => void) | null>(null); -/** One owner for the footer icon and summary: a compact sheet or the wide Usage screen. */ +/** + * One owner for the footer icon and summary: both open the Usage modal over the current screen. + * The modal mounts on first open and unmounts once it has finished closing, so its host's reports + * load only while it is shown. + */ export function UsageSidebarRoot({ children }: { children: ReactNode }) { - const { t } = useTranslation(); - const { display } = useUsagePreferences(); - const isCompact = useIsCompactFormFactor(); const [open, setOpen] = useState(false); - const [sheetMounted, setSheetMounted] = useState(false); + const [mounted, setMounted] = useState(false); const openUsage = useCallback(() => { - if (isCompact) { - setSheetMounted(true); - setOpen(true); - return; - } - router.push(buildUsageRoute()); - }, [isCompact]); + setMounted(true); + setOpen(true); + }, []); + const closeUsage = useCallback(() => setOpen(false), []); + const unmountUsage = useCallback(() => setMounted(false), []); return ( - - {children} - {isCompact && sheetMounted ? ( - - ) : null} - + {children} + {mounted ? : null} ); } @@ -101,57 +90,6 @@ export function useOpenSidebarUsage(): () => void { return openUsage; } -/** - * The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls - * in its title row. - */ -function UsageSheet({ title, display }: { title: string; display: UsageDisplay }) { - const { serverId, connectedHosts, select } = useUsageHostSelection(); - if (!serverId) return null; - return ( - - ); -} - -function HostUsageSheet({ - title, - serverId, - hosts, - onSelectHost, - display, -}: { - title: string; - serverId: string; - hosts: UsageHost[]; - onSelectHost: (serverId: string) => void; - display: UsageDisplay; -}) { - const hostSelection = useMemo( - () => ({ hosts, serverId, onSelect: onSelectHost }), - [hosts, onSelectHost, serverId], - ); - const { view, refresh, controls } = useHostUsageWithControls(hostSelection); - return ( - - - - - - ); -} - function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string { const windows = sources.flatMap((source) => source.windows); return `${label}: ${windows.map((window) => window.label).join(", ")}`; @@ -343,8 +281,4 @@ const styles = StyleSheet.create((theme) => ({ windowLabel: { color: theme.colors.foregroundMuted, }, - sheetBody: { - padding: theme.spacing[3], - gap: theme.spacing[3], - }, })); diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx new file mode 100644 index 0000000000..8305c3f878 --- /dev/null +++ b/packages/app/src/usage/usage-modal.tsx @@ -0,0 +1,93 @@ +import { useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { useHostUsageWithControls } from "./controls"; +import { usageCopy } from "./copy"; +import { useUsagePreferences } from "./display"; +import { useUsageHostSelection } from "./hosts"; +import type { UsageHost } from "./model"; +import { UsageBody, UsageMessage } from "./usage-section"; + +interface UsageModalProps { + visible: boolean; + onClose: () => void; + onDismiss: () => void; +} + +/** + * Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It + * shows the usage host's reports with pins, and the host filter, Refresh all, and Settings in its + * title row. + */ +export function UsageModal(props: UsageModalProps) { + const { serverId, connectedHosts, select } = useUsageHostSelection(); + if (!serverId) { + return ( + + + + ); + } + // Keyed by host so a refresh in flight on one host never shows on another's cards. + return ( + + ); +} + +function HostUsageModal({ + serverId, + hosts, + onSelectHost, + ...props +}: UsageModalProps & { + serverId: string; + hosts: UsageHost[]; + onSelectHost: (serverId: string) => void; +}) { + const { display } = useUsagePreferences(); + const hostSelection = useMemo( + () => ({ hosts, serverId, onSelect: onSelectHost }), + [hosts, onSelectHost, serverId], + ); + const { view, refresh, controls } = useHostUsageWithControls(hostSelection); + return ( + + + + + + ); +} + +function UsageModalFrame({ + visible, + onClose, + onDismiss, + actions, + children, +}: UsageModalProps & { actions?: ReactNode; children: ReactNode }) { + const { t } = useTranslation(); + const title = t(builtinSidebarNavLabelKey("usage")); + const header = useMemo(() => ({ title, actions }), [actions, title]); + return ( + + {children} + + ); +} diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx deleted file mode 100644 index 559d15095e..0000000000 --- a/packages/app/src/usage/usage-screen.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useIsFocused } from "@react-navigation/native"; -import { router } from "expo-router"; -import { useMemo, type ReactNode } from "react"; -import { View } from "react-native"; -import { PageLayout } from "@/components/page-layout"; -import { useHostUsageWithControls } from "./controls"; -import { usageCopy } from "./copy"; -import { useUsagePreferences } from "./display"; -import { useUsageHostSelection } from "./hosts"; -import type { UsageHost } from "./model"; -import { UsageBody, UsageMessage } from "./usage-section"; - -// The screen is reachable by URL, so there may be no history to go back to. -function leaveUsage(): void { - if (router.canGoBack()) { - router.back(); - return; - } - router.replace("/"); -} - -export function UsageScreen() { - const isFocused = useIsFocused(); - if (!isFocused) return {null}; - return ; -} - -function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) { - return ( - - {children} - - ); -} - -function FocusedUsageScreen() { - const { serverId, connectedHosts, select } = useUsageHostSelection(); - if (!serverId) { - return ( - - - - ); - } - return ( - - ); -} - -function HostUsage({ - serverId, - hosts, - onSelectHost, -}: { - serverId: string; - hosts: UsageHost[]; - onSelectHost: (serverId: string) => void; -}) { - const { display } = useUsagePreferences(); - const hostSelection = useMemo( - () => ({ hosts, serverId, onSelect: onSelectHost }), - [hosts, onSelectHost, serverId], - ); - const { view, refresh, controls } = useHostUsageWithControls(hostSelection); - return ( - - - - - - ); -} diff --git a/packages/app/src/utils/assistant-markdown-image-source.test.ts b/packages/app/src/utils/assistant-markdown-image-source.test.ts new file mode 100644 index 0000000000..edc02a4eb7 --- /dev/null +++ b/packages/app/src/utils/assistant-markdown-image-source.test.ts @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { localFileSourceToPath } from "@/attachments/utils"; +import { createAssistantMarkdownParser } from "./assistant-markdown-parser"; +import { resolveAssistantImageSource } from "./assistant-image-source"; + +describe("local assistant Markdown images", () => { + let workspaceRoot: string; + const imageBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ); + + beforeEach(async () => { + workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-")); + await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes); + await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it.each([ + "absolute angle", + "absolute encoded", + "relative angle", + "relative encoded", + "file URI", + "literal percent", + ])("reads the actual image for the %s destination", async (form) => { + const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png"); + const destinations: Record = { + "absolute angle": `<${absolute}>`, + "absolute encoded": encodeURI(absolute), + "relative angle": "", + "relative encoded": encodeURI("My Apps/Мои/test.png"), + "file URI": `file://${encodeURI(absolute)}`, + "literal percent": `${workspaceRoot}/literal%2520name.png`, + }; + const tokens = createAssistantMarkdownParser().parseInline( + `![test](${destinations[form]})`, + {}, + ); + const image = tokens + .flatMap((token) => token.children ?? []) + .find((token) => token.type === "image"); + expect(image).toBeDefined(); + const source = localFileSourceToPath(image!.attrGet("src")!, { decodeHref: true }); + const target = resolveAssistantImageSource({ source, workspaceRoot }); + expect(target?.kind).toBe("file_rpc"); + if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target"); + await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes); + }); +}); diff --git a/packages/app/src/utils/file-mention-autocomplete.test.ts b/packages/app/src/utils/file-mention-autocomplete.test.ts index 4f818e25e2..c74c821522 100644 --- a/packages/app/src/utils/file-mention-autocomplete.test.ts +++ b/packages/app/src/utils/file-mention-autocomplete.test.ts @@ -48,6 +48,74 @@ describe("findActiveFileMention", () => { }); expect(mention).toBeNull(); }); + + it("returns null for the issue #1364 angle-bracket email example", () => { + const text = "user <12345+user@noreply.example.com>"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for email addresses (word char before @)", () => { + const text = "alice@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for email addresses (+ immediately before @)", () => { + // Local part ends with + so the character at index-1 is '+', not a word char. + const text = "noreply+@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for plus-tagged email local parts", () => { + const text = "noreply+tag@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null when query contains angle brackets", () => { + const text = "check @foo end"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.indexOf(" end"), + }); + expect(mention).toBeNull(); + }); + + it("returns null when query ends with a closing angle bracket", () => { + const text = "check @foo>"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("still detects a file mention after an email in the same input", () => { + const text = "user <12345+user@noreply.example.com> and also @file"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toEqual({ + start: text.lastIndexOf("@"), + end: text.length, + query: "file", + }); + }); }); describe("formatQuotedFileMentionPath", () => { diff --git a/packages/app/src/utils/file-mention-autocomplete.ts b/packages/app/src/utils/file-mention-autocomplete.ts index d19424cfe4..fe09b52f03 100644 --- a/packages/app/src/utils/file-mention-autocomplete.ts +++ b/packages/app/src/utils/file-mention-autocomplete.ts @@ -15,7 +15,7 @@ interface ApplyFileMentionReplacementInput { relativePath: string; } -const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/; +const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"'<>]/; export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null { const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length)); @@ -26,6 +26,11 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe atIndex >= 0; atIndex = atIndex === 0 ? -1 : beforeCursor.lastIndexOf("@", atIndex - 1) ) { + // Skip @ that looks like part of an email address (local-part char immediately before @). + if (atIndex > 0 && /[\w+]/.test(beforeCursor[atIndex - 1]!)) { + continue; + } + const query = beforeCursor.slice(atIndex + 1); if (INVALID_MENTION_QUERY_CHARS.test(query)) { continue; diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 1dc3ff48bb..c13d6f25f9 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -428,10 +428,6 @@ export function buildSchedulesRoute() { return "/schedules" as const; } -export function buildUsageRoute() { - return "/usage" as const; -} - export function buildOpenProjectRoute() { return "/open-project" as const; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 6b008e3dc6..b05c930123 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -1,9 +1,11 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import type { SidebarProjectEntry, SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; +import { routeKeyboardShortcut } from "@/keyboard/route-shortcut"; import { buildSidebarShortcutModel, @@ -64,6 +66,46 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb } describe("buildSidebarShortcutModel", () => { + it.each([ + [9, 1, 10], + [10, -1, 9], + [12, 1, 1], + [1, -1, 12], + ] as const)( + "navigates from workspace %i by %i to workspace %i across the full visible sidebar", + (current, delta, next) => { + const workspaces = Array.from({ length: 12 }, (_, index) => + workspace({ + serverId: "s", + workspaceId: `ws-${index + 1}`, + workspaceDirectory: `/repo/w${index + 1}`, + name: `w${index + 1}`, + }), + ); + const model = buildSidebarShortcutModel({ + projects: [project("p", workspaces)], + collapsedProjectKeys: new Set(), + }); + + expect( + routeKeyboardShortcut( + { action: "workspace.navigate.relative", payload: { delta } }, + { + pathname: `/h/s/workspace/ws-${current}`, + isMobile: false, + sidebarShortcutTargets: model.shortcutTargets, + navigationActiveWorkspace: null, + commandCenterOpen: false, + shortcutsDialogOpen: false, + }, + ), + ).toEqual({ kind: "navigate-workspace", serverId: "s", workspaceId: `ws-${next}` }); + expect(model.shortcutIndexByWorkspaceKey.size).toBe(9); + expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9); + expect(model.shortcutIndexByWorkspaceKey.has("s:ws-10")).toBe(false); + }, + ); + it("builds shortcut targets in visual order and excludes collapsed projects", () => { const projects = [ project("p1", [ @@ -110,7 +152,7 @@ describe("buildSidebarShortcutModel", () => { expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined(); }); - it("limits shortcuts to 9", () => { + it("keeps all traversal targets while numbering only the first 9", () => { const workspaces = Array.from({ length: 20 }, (_, index) => workspace({ serverId: "s", @@ -126,9 +168,11 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(), }); - expect(model.shortcutTargets).toHaveLength(9); - expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" }); + expect(model.shortcutTargets).toHaveLength(20); expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" }); + expect(model.shortcutTargets[9]).toEqual({ serverId: "s", workspaceId: "ws-10" }); + expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9); + expect(model.shortcutIndexByWorkspaceKey.get("s:ws-10")).toBeUndefined(); }); it("excludes a collapsed project's workspaces regardless of project kind", () => { @@ -211,6 +255,7 @@ describe("buildStatusSidebarShortcutModel", () => { ["p1", "Project 1"], ["p2", "Project 2"], ]), + i18n.t, ), }); @@ -247,7 +292,7 @@ describe("buildStatusSidebarShortcutModel", () => { ]; const model = buildStatusSidebarShortcutModel({ - groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])), + groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]]), i18n.t), collapsedStatusGroupKeys: new Set(["needs_input"]), }); diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index 86540357e6..d08cba927c 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -70,13 +70,10 @@ export function buildSidebarShortcutSections(input: { } for (const workspace of section.workspaces) { - if (shortcutTargets.length >= maxShortcuts) { - break; - } - - const shortcutNumber = shortcutTargets.length + 1; shortcutTargets.push(createShortcutTarget(workspace)); - shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber); + if (shortcutTargets.length <= maxShortcuts) { + shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutTargets.length); + } } } diff --git a/packages/app/src/utils/workspace-directory.test.ts b/packages/app/src/utils/workspace-directory.test.ts index b40bdd9705..b3973fb7b6 100644 --- a/packages/app/src/utils/workspace-directory.test.ts +++ b/packages/app/src/utils/workspace-directory.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest"; import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory"; describe("resolveWorkspaceDirectory", () => { + it("preserves significant whitespace in a workspace folder name", () => { + expect(resolveWorkspaceDirectory({ workspaceDirectory: "/repo " })).toBe("/repo "); + expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo /" })).toBe("/repo "); + }); it("canonicalizes a workspace directory and returns null when blank", () => { expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe( "C:/repo/app", diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts index 8c778372d9..3cf3134939 100644 --- a/packages/app/src/utils/workspace-identity.ts +++ b/packages/app/src/utils/workspace-identity.ts @@ -13,11 +13,10 @@ export function normalizeWorkspaceOpaqueId(value: string | null | undefined): st } export function normalizeWorkspacePath(value: string | null | undefined): string | null { - const trimmed = trimNonEmpty(value); - if (!trimmed) { + if (typeof value !== "string" || value.trim().length === 0) { return null; } - const withUnixSeparators = trimmed.replace(/\\/g, "/"); + const withUnixSeparators = value.replace(/\\/g, "/"); if (withUnixSeparators === "/") { return withUnixSeparators; } diff --git a/packages/app/src/workspace-labels/manager-modal.tsx b/packages/app/src/workspace-labels/manager-modal.tsx index 0dc8f1c53f..a23e7e0925 100644 --- a/packages/app/src/workspace-labels/manager-modal.tsx +++ b/packages/app/src/workspace-labels/manager-modal.tsx @@ -174,9 +174,6 @@ export function WorkspaceLabelManagerModal({ onClose={onClose} header={header} footer={footer} - // Bound the compact scroller to the live snap height, so the edit view's footer stays on - // screen instead of being pushed past the bottom of the sheet. - sizeContentToCurrentSnapPoint testID="workspace-label-manager" > {draft && editing ? ( diff --git a/packages/app/src/workspace-tabs/compact-sheet.ts b/packages/app/src/workspace-tabs/compact-sheet.ts new file mode 100644 index 0000000000..947d4904c5 --- /dev/null +++ b/packages/app/src/workspace-tabs/compact-sheet.ts @@ -0,0 +1,38 @@ +import { getWorkspacePanelAttributes } from "@/panels/panel-instance-attributes"; +import { panelCompactSheetPolicy, type CompactSheetPolicy } from "@/panels/panel-manifest"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { isWorkspaceRootAgent } from "@/subagents/policies"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; + +function workspaceAgentLookup(workspaceKey: string): (agentId: string) => Agent | null { + const sessions = useSessionStore.getState().sessions; + const serverId = Object.keys(sessions) + .filter((candidate) => workspaceKey.startsWith(`${candidate}:`)) + .sort((left, right) => right.length - left.length)[0]; + const session = serverId ? sessions[serverId] : undefined; + return (agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; +} + +/** + * The single target-aware boundary deciding whether a compact reveal lands in the workspace + * sheet. An agent the client has not loaded yet counts as a root agent and stays a tab. + */ +export function resolveCompactSheetPolicy( + workspaceKey: string, + target: WorkspaceTabTarget, +): CompactSheetPolicy | null { + return panelCompactSheetPolicy(target, { + isWorkspaceRootAgent: (agentId) => { + const getAgent = workspaceAgentLookup(workspaceKey); + const agent = getAgent(agentId); + if (!agent) return true; + const parent = agent.parentAgentId ? (getAgent(agent.parentAgentId) ?? undefined) : undefined; + return isWorkspaceRootAgent(agent, parent); + }, + }); +} + +/** Whether the sheet's tab holds unsaved work, which must never be discarded with the sheet. */ +export function isSheetTabModified(workspaceKey: string, tabId: string): boolean { + return getWorkspacePanelAttributes(workspaceKey, tabId).modified; +} diff --git a/packages/app/src/workspace-tabs/layout-mode-publisher.tsx b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx new file mode 100644 index 0000000000..03df9b695a --- /dev/null +++ b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx @@ -0,0 +1,21 @@ +import { useLayoutEffect } from "react"; +import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; +import { useWorkspaceLayoutStore, type WorkspaceLayoutMode } from "@/stores/workspace-layout-store"; + +function resolveWorkspaceLayoutMode(isCompact: boolean): WorkspaceLayoutMode { + if (isCompact) return "compact"; + return supportsDesktopPaneSplits() ? "split-panes" : "single-pane"; +} + +/** + * Publishes the shell's workspace layout to the layout store, which decides where opened targets + * land. Render it before the routes: React runs an earlier sibling's layout effects before a later + * sibling's subtree, so a route sees the mode even from its first layout effect. + */ +export function WorkspaceLayoutModePublisher(): null { + const isCompact = useIsCompactFormFactor(); + useLayoutEffect(() => { + useWorkspaceLayoutStore.getState().setLayoutMode(resolveWorkspaceLayoutMode(isCompact)); + }, [isCompact]); + return null; +} diff --git a/packages/app/src/workspace-tabs/open-beside.test.ts b/packages/app/src/workspace-tabs/open-beside.test.ts new file mode 100644 index 0000000000..9e80745db8 --- /dev/null +++ b/packages/app/src/workspace-tabs/open-beside.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined), + }, +})); + +import { DEFAULT_APP_SETTINGS } from "@/hooks/use-settings"; +import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; +import { + collectAllPanes, + collectAllTabs, + findPaneById, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { + openPreferredWorkspacePreview, + openPreferredWorkspaceTarget, + openWorkspaceTargetBeside, +} from "@/workspace-tabs/open-beside"; + +const WORKSPACE_KEY = "server-1:workspace-1"; + +beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + sheetsByWorkspace: {}, + layoutByWorkspace: {}, + explorerSidebarPaneIdByWorkspace: {}, + sidePaneIdByWorkspace: {}, + }); +}); + +describe("openPreferredWorkspacePreview", () => { + it("opens a compact file preview in the sheet instead of replacing the active file tab", () => { + const store = useWorkspaceLayoutStore.getState(); + const fileTabId = store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/open.ts" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + + const tabId = openPreferredWorkspacePreview({ + workspaceKey: WORKSPACE_KEY, + serverId: "server-1", + workspaceId: "workspace-1", + explorerSidebarPaneId: "explorer", + lastMainPaneId: "main", + target: { kind: "file", path: "src/preview.ts" }, + source: "explorerFiles", + preferences: DEFAULT_APP_SETTINGS.openInSidePane, + }); + + const state = useWorkspaceLayoutStore.getState(); + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.at(-1)?.tab).toMatchObject({ + tabId, + target: { kind: "file", path: "src/preview.ts" }, + }); + const root = state.layoutByWorkspace[WORKSPACE_KEY].root; + expect(findPaneById(root, "main")?.tabIds).toEqual([fileTabId]); + expect(collectAllTabs(root).find((tab) => tab.tabId === fileTabId)?.target).toEqual({ + kind: "file", + path: "src/open.ts", + }); + }); +}); + +describe("previews under a sheet with unsaved work", () => { + const sheetTarget = { kind: "file" as const, path: "src/draft.ts" }; + let sheetTabId: string | null = null; + + afterEach(() => { + if (sheetTabId) { + setPanelInstanceAttributes( + { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId }, + { modified: false }, + ); + } + sheetTabId = null; + }); + + function previewFile(path: string): string | null { + return openPreferredWorkspacePreview({ + workspaceKey: WORKSPACE_KEY, + serverId: "server-1", + workspaceId: "workspace-1", + explorerSidebarPaneId: "explorer", + lastMainPaneId: "main", + target: { kind: "file", path }, + source: "explorerFiles", + preferences: DEFAULT_APP_SETTINGS.openInSidePane, + }); + } + + it("stacks link previews over the sheet instead of dropping its unsaved work", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + sheetTabId = store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: sheetTarget, + intent: "reveal", + }); + setPanelInstanceAttributes( + { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId ?? "" }, + { modified: true }, + ); + + previewFile("src/first.ts"); + previewFile("src/second.ts"); + + const state = useWorkspaceLayoutStore.getState(); + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.map((sheet) => sheet.tab.target)).toEqual([ + sheetTarget, + { kind: "file", path: "src/first.ts" }, + { kind: "file", path: "src/second.ts" }, + ]); + const root = state.layoutByWorkspace[WORKSPACE_KEY].root; + expect(findPaneById(root, "main")?.tabIds).toHaveLength(1); + }); +}); + +describe("opening beside", () => { + const sidePreference = { ...DEFAULT_APP_SETTINGS.openInSidePane, subagents: true }; + + function openSubagentWithSidePreference(): string | null { + useWorkspaceLayoutStore.getState().openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/open.ts" }, + intent: "reveal", + }); + return openPreferredWorkspaceTarget({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/subagent.ts" }, + source: "subagents", + preferences: sidePreference, + }); + } + + function paneIdHolding(tabId: string | null): string | undefined { + const root = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root; + return collectAllPanes(root).find((pane) => pane.tabIds.includes(tabId ?? ""))?.id; + } + + it("follows the side preference where the workspace renders split panes", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("split-panes"); + + const tabId = openSubagentWithSidePreference(); + + const sidePaneId = useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]; + expect(sidePaneId).toEqual(expect.any(String)); + expect(paneIdHolding(tabId)).toBe(sidePaneId); + }); + + it("keeps a single-pane workspace in one pane despite the side preference", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("single-pane"); + + const preferredTabId = openSubagentWithSidePreference(); + const besideTabId = openWorkspaceTargetBeside({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/beside.ts" }, + }); + + expect(useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]).toBeFalsy(); + expect(paneIdHolding(preferredTabId)).toBe("main"); + expect(paneIdHolding(besideTabId)).toBe("main"); + }); +}); diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts index b36d227c3b..0c0c9ebdf5 100644 --- a/packages/app/src/workspace-tabs/open-beside.ts +++ b/packages/app/src/workspace-tabs/open-beside.ts @@ -22,13 +22,11 @@ interface OpenWorkspaceTargetInput { } export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetInput { - isCompact: boolean; source: OpenInSidePaneSource; preferences: OpenInSidePanePreferences; } interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput { - isCompact: boolean; location: WorkspaceTargetOpenLocation; } @@ -39,6 +37,11 @@ interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTarge lastMainPaneId: string | null; } +/** A side pane exists only where the workspace renders split panes. */ +function canOpenBeside(): boolean { + return useWorkspaceLayoutStore.getState().layoutMode === "split-panes"; +} + function resolveMainPane(input: { workspaceKey: string; explorerSidebarPaneId: string | null; @@ -86,7 +89,6 @@ export function openPreferredWorkspaceTarget( input: OpenPreferredWorkspaceTargetInput, ): string | null { return openWorkspaceTargetAtLocation({ - isCompact: input.isCompact, workspaceKey: input.workspaceKey, target: input.target, location: input.preferences[input.source] ? "side" : "main", @@ -105,7 +107,7 @@ export function openWorkspaceTargetAtLocation( layout && collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)), ); - const shouldOpenBeside = !input.isCompact && input.location === "side"; + const shouldOpenBeside = canOpenBeside() && input.location === "side"; let placement: WorkspaceTabPlacement | undefined; if (shouldOpenBeside && !targetAlreadyExists) { const paneId = store.ensureSidePane(input.workspaceKey); @@ -135,7 +137,13 @@ export function openPreferredWorkspacePreview( const existing = collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target), ); - if (existing) return openPreferredWorkspaceTarget(input); + // The sheet is already a single replaceable slot, so it needs no preview tab. + if ( + existing || + store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" }) + ) { + return openPreferredWorkspaceTarget(input); + } const mainPane = resolveMainPane({ workspaceKey: input.workspaceKey, @@ -143,7 +151,7 @@ export function openPreferredWorkspacePreview( lastMainPaneId: input.lastMainPaneId, }); const destinationPaneId = - !input.isCompact && input.preferences[input.source] + canOpenBeside() && input.preferences[input.source] ? store.ensureSidePane(input.workspaceKey) : mainPane?.id; if (!destinationPaneId) return null; @@ -173,9 +181,15 @@ export function openPreferredWorkspacePreview( }); } -/** Explicit Open to Side: creates the side pane and places the target there. */ +/** + * Explicit Open to Side: creates the side pane and places the target there. Without split panes + * the target opens like any other. + */ export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): string | null { if (!input.workspaceKey) return null; + if (!canOpenBeside()) { + return openWorkspaceTargetAtLocation({ ...input, location: "main" }); + } const store = useWorkspaceLayoutStore.getState(); const paneId = store.ensureSidePane(input.workspaceKey); return store.openTab({ diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts index b91bf732c9..bb567d4bd0 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.ts @@ -46,7 +46,6 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | return null; } return openPreferredWorkspaceTarget({ - isCompact: input.isCompact, workspaceKey: input.workspaceKey, target: { kind: "working_diff" }, source: "diffs", @@ -70,7 +69,6 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): return null; } return openWorkspaceTargetAtLocation({ - isCompact: input.isCompact, workspaceKey: input.workspaceKey, target: { kind: "pull_request" }, location: input.destination, diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 784a7569ff..ee27421efd 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -3,11 +3,13 @@ // to keep the module graph importable. const Stub = () => null; const PassThrough = ({ children }: { children?: unknown }) => children; +const sharedValue = (value: unknown) => ({ get: () => value }); export default Stub; export const BottomSheetModal = Stub; export const BottomSheetModalProvider = PassThrough; export const BottomSheetBackdrop = Stub; +export const BottomSheetHandle = Stub; export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub; @@ -16,10 +18,27 @@ export function useBottomSheetModal(): never { throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests"); } +// A sheet host scope only redirects where sheets draw; with no sheet drawn there is nothing to host. +export function useBottomSheet(): never { + throw new Error("useBottomSheet is unavailable in tests"); +} +export const BottomSheetModalInternalProvider = PassThrough; +export const INITIAL_CONTAINER_LAYOUT = { + height: 0, + offset: { top: 0, bottom: 0, left: 0, right: 0 }, +}; +export function useBottomSheetModalInternal() { + return { + hostName: "stub", + containerLayoutState: sharedValue(INITIAL_CONTAINER_LAYOUT), + mountSheet: () => undefined, + unmountSheet: () => undefined, + willUnmountSheet: () => undefined, + }; +} + // The app imports the internal layout values to compute the visible sheet body. Browser unit tests // never render the native sheet, but Vite still validates this module's export surface. -const sharedValue = (value: unknown) => ({ get: () => value }); - export const KEYBOARD_STATUS = { SHOWN: "SHOWN" }; export function useBottomSheetInternal() { return { diff --git a/packages/app/vitest-environment.test.ts b/packages/app/vitest-environment.test.ts new file mode 100644 index 0000000000..95b9b9343b --- /dev/null +++ b/packages/app/vitest-environment.test.ts @@ -0,0 +1,67 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +const appDir = fileURLToPath(new URL(".", import.meta.url)); +const rootDir = path.resolve(appDir, "../.."); +const require = createRequire(import.meta.url); +const vitestEntry = path.join(path.dirname(require.resolve("vitest/package.json")), "vitest.mjs"); +const tsgoEntry = path.join( + path.dirname(require.resolve("@typescript/native-preview/package.json")), + "bin/tsgo.js", +); + +it("the test configuration typechecks with Expo's generated environment declaration", () => { + const fixtureDir = mkdtempSync(path.join(appDir, ".vitest-expo-env-")); + try { + writeFileSync(path.join(fixtureDir, "expo-env.d.ts"), '/// \n'); + writeFileSync( + path.join(fixtureDir, "tsconfig.json"), + JSON.stringify({ + extends: "../tsconfig.json", + files: ["../vitest.config.ts", "expo-env.d.ts"], + include: [], + }), + ); + const result = spawnSync( + process.execPath, + [tsgoEntry, "--noEmit", "--project", path.join(fixtureDir, "tsconfig.json")], + { cwd: appDir, encoding: "utf8", timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(0); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } +}, 40_000); + +it.each([ + { + name: "root", + cwd: rootDir, + args: ["packages/app/src/screens/workspace/use-mounted-tab-set.test.ts"], + }, + { + name: "app", + cwd: appDir, + args: ["--project", "unit", "src/screens/workspace/use-mounted-tab-set.test.ts"], + }, +])( + "$name tests can render React when the shell exports NODE_ENV=production", + ({ cwd, args }) => { + const result = spawnSync(process.execPath, [vitestEntry, "run", ...args], { + cwd, + env: { ...process.env, NODE_ENV: "production" }, + encoding: "utf8", + timeout: 30_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(0); + }, + 40_000, +); diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 521fb6dbe0..e2d67aca70 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -3,6 +3,9 @@ import { playwright } from "@vitest/browser-playwright"; import path from "path"; import fs from "fs"; +// Set this before Vite resolves dependencies and Vitest starts its workers. +Object.assign(process.env, { NODE_ENV: "test" }); + const appNodeModules = path.resolve(__dirname, "node_modules"); const rootNodeModules = path.resolve(__dirname, "../../node_modules"); const resolvePackageEntry = (packageName: string) => { @@ -22,7 +25,12 @@ export default defineConfig({ test: { name: "unit", environment: "node", - include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"], + include: [ + "src/**/*.{test,spec}.{ts,tsx}", + "native-release-version.test.ts", + "app.config.test.ts", + "vitest-environment.test.ts", + ], setupFiles: [path.resolve(__dirname, "vitest.setup.ts")], exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"], }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 08525ae169..4fdcfba018 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.11.0-beta.5", + "version": "0.11.2", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "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", diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index 676f79234d..18f4348ff7 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -46,10 +46,25 @@ describe("canonical CLI surface", () => { expect(help).toContain("--forge "); }); - it("uses background for execution and reserves detach for ownership", () => { + it("uses --no-wait for execution, --internal for visibility, and reserves detach for ownership", () => { const run = createCli().commands.find((command) => command.name() === "run"); - expect(run?.helpInformation()).toContain("--background"); - expect(run?.helpInformation()).not.toContain("--detach"); + const help = run?.helpInformation(); + expect(help).toContain("--no-wait"); + expect(help).not.toContain("--background"); + expect(help).not.toContain("--background"); + expect(help).not.toContain("--detach"); + }); + + it("hides internal agents and workspaces from listings unless --internal is passed", () => { + const cli = createCli(); + const ls = cli.commands.find((command) => command.name() === "ls"); + const workspace = cli.commands.find((command) => command.name() === "workspace"); + const workspaceLs = workspace?.commands.find((command) => command.name() === "ls"); + const workspaceCreate = workspace?.commands.find((command) => command.name() === "create"); + + expect(ls?.helpInformation()).toContain("--background"); + expect(workspaceLs?.helpInformation()).toContain("--background"); + expect(workspaceCreate?.helpInformation()).toContain("--background"); }); it("offers thinking configuration when running, updating, and scheduling agents", () => { @@ -96,3 +111,15 @@ describe("canonical CLI surface", () => { ).toContain("--id "); }); }); + +it("preserves omitted and explicit false workspace background intent", () => { + const readOptions = (args: string[]) => { + const workspace = createCli().commands.find((command) => command.name() === "workspace")!; + const create = workspace.commands.find((command) => command.name() === "create")!; + create.parseOptions(args); + return create.opts(); + }; + expect(readOptions([]).background).toBeUndefined(); + expect(readOptions(["--background"]).background).toBe(true); + expect(readOptions(["--no-background"]).background).toBe(false); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f0c0cfcf7b..dceb3f372d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; +import { createUsageCommand } from "./commands/usage/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; import { createPluginCommand } from "./commands/plugin/index.js"; import { createProjectCommand } from "./commands/project/index.js"; @@ -148,6 +149,7 @@ export function createCli(): Command { // Provider commands program.addCommand(createProviderCommand()); + program.addCommand(createUsageCommand()); program.addCommand(createPluginCommand()); // Speech model commands diff --git a/packages/cli/src/commands/agent/logs.test.ts b/packages/cli/src/commands/agent/logs.test.ts new file mode 100644 index 0000000000..e551aa3791 --- /dev/null +++ b/packages/cli/src/commands/agent/logs.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js"; + +function collect(items: readonly AgentTimelineItem[], turnId?: string): string { + let output = ""; + const writer = createFollowTranscriptWriter((chunk) => { + output += chunk; + }); + for (const item of items) { + writer.push(item, turnId); + } + writer.end(); + return output; +} + +describe("createFollowTranscriptWriter", () => { + it("matches one-shot logs when reasoning and assistant text alternate", () => { + const items: AgentTimelineItem[] = [ + { type: "reasoning", text: "Check " }, + { type: "reasoning", text: "the commands." }, + { type: "assistant_message", text: "I will list the commands.", messageId: "msg_1" }, + { type: "reasoning", text: "Then summarize." }, + { type: "assistant_message", text: "Done.", messageId: "msg_2" }, + ]; + + expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items)); + }); + + it("keeps a word that arrives split across fragments intact", () => { + const output = collect([ + { type: "assistant_message", text: "the paseo-pr", messageId: "msg_1" }, + { type: "assistant_message", text: "-cycle label\n", messageId: "msg_1" }, + ]); + + expect(output).toBe("the paseo-pr-cycle label\n"); + }); + + it("streams text as it arrives without inventing a line break", () => { + const chunks: string[] = []; + const writer = createFollowTranscriptWriter((chunk) => { + chunks.push(chunk); + }); + + writer.push({ + type: "assistant_message", + text: "first line", + messageId: "msg_1", + }); + expect(chunks.join("")).toBe("first line"); + + writer.push({ + type: "assistant_message", + text: "\nsecond", + messageId: "msg_1", + }); + expect(chunks.join("")).toBe("first line\nsecond"); + + writer.push({ + type: "assistant_message", + text: " line\n", + messageId: "msg_1", + }); + expect(chunks.join("")).toBe("first line\nsecond line"); + + writer.end(); + expect(chunks.join("")).toBe("first line\nsecond line\n"); + }); + + it("emits the same transcript the one-shot command prints for the same items", () => { + const items: AgentTimelineItem[] = [ + { + type: "assistant_message", + text: "Plan for paseo-pr", + messageId: "msg_1", + }, + { + type: "assistant_message", + text: "-cycle:\n- build\n- test\n", + messageId: "msg_1", + }, + { type: "error", message: "boom" }, + { type: "assistant_message", text: "done", messageId: "msg_2" }, + ]; + + expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items)); + }); + + it("starts a new message when the messageId changes", () => { + const output = collect([ + { type: "assistant_message", text: "first", messageId: "msg_1" }, + { type: "assistant_message", text: "second", messageId: "msg_2" }, + ]); + + expect(output).toBe("first\nsecond\n"); + }); + + it("does not merge fragments across turns", () => { + let output = ""; + const writer = createFollowTranscriptWriter((chunk) => { + output += chunk; + }); + + writer.push({ type: "assistant_message", text: "first" }, "turn_1"); + writer.push({ type: "assistant_message", text: "second" }, "turn_2"); + writer.end(); + + expect(output).toBe("first\nsecond\n"); + }); + + it("prints the thought prefix once for a merged reasoning stream", () => { + const output = collect([ + { type: "reasoning", text: "checking the" }, + { type: "reasoning", text: " lockfile\nthen the build\n" }, + ]); + + expect(output).toBe("[Thought] checking the lockfile\nthen the build\n"); + }); + + it("keeps reasoning blocks separate across hidden timeline items", () => { + let output = ""; + const writer = createFollowTranscriptWriter((chunk) => { + output += chunk; + }, "reasoning"); + writer.push({ type: "reasoning", text: "checking files" }); + writer.push({ + type: "tool_call", + callId: "tool_1", + name: "Bash", + status: "completed", + error: null, + detail: { type: "unknown", input: "ls", output: "files" }, + }); + writer.push({ type: "reasoning", text: "running tests" }); + writer.end(); + + expect(output).toBe("[Thought] checking files\n[Thought] running tests\n"); + }); + + it("keeps interior blank lines and drops trailing whitespace", () => { + const output = collect([ + { + type: "assistant_message", + text: " # Title\n\nBody\n\n ", + messageId: "msg_1", + }, + ]); + + expect(output).toBe("# Title\n\nBody\n"); + }); +}); diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index f555e27b80..ed77d4be62 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { connectToDaemon } from "../../utils/client.js"; import { waitForStop } from "../../utils/wait-for-stop.js"; -import type { CommandOptions } from "../../output/index.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; import { fetchProjectedTimelineItems, LIVE_HISTORY_FETCH_TIMEOUT_MS, @@ -32,12 +32,29 @@ export type AgentLogsResult = void; export const NO_ACTIVITY_MESSAGE = "No activity to display."; +interface FetchAgentTimelineItemsOptions { + timeoutMs?: number; + sinceTimestampMs?: number; +} + +interface FollowModeInput { + client: DaemonClient; + agentId: string; + options: AgentLogsOptions; + sinceTimestampMs: number | undefined; +} + export async function fetchAgentTimelineItems( client: DaemonClient, agentId: string, - options?: { timeoutMs?: number }, + options?: FetchAgentTimelineItemsOptions, ): Promise { - return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs }); + return fetchProjectedTimelineItems({ + client, + agentId, + timeoutMs: options?.timeoutMs, + sinceTimestampMs: options?.sinceTimestampMs, + }); } export function formatAgentActivityTranscript( @@ -53,6 +70,134 @@ export function formatAgentActivityTranscript( ); } +type StreamingTextType = "assistant_message" | "reasoning"; + +const THOUGHT_PREFIX = "[Thought] "; + +interface PendingStreamingText { + type: StreamingTextType; + turnId: string | undefined; + messageId: string | undefined; + buffer: string; + emitted: boolean; +} + +export interface FollowTranscriptWriter { + push(item: AgentTimelineItem, turnId?: string): void; + end(): void; +} + +function lastNonWhitespaceIndex(text: string): number { + for (let index = text.length - 1; index >= 0; index -= 1) { + if (!/\s/.test(text[index])) { + return index; + } + } + return -1; +} + +/** + * Follow mode receives one streaming fragment per event, while the one-shot + * transcript sees the whole timeline and merges consecutive assistant and + * reasoning fragments with exact concatenation (`projectTimelineRows`). Printing + * each fragment on its own line splits words at fragment boundaries and + * reprints the reasoning prefix, so a chain of fragments is written as one + * stream instead: text goes out as it arrives, and only the trailing whitespace + * is held back so the message ends trimmed the way the transcript does. + */ +export function createFollowTranscriptWriter( + write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk), + filter?: string, +): FollowTranscriptWriter { + let pending: PendingStreamingText | null = null; + + const emit = (chain: PendingStreamingText, text: string): void => { + if (!chain.emitted) { + chain.emitted = true; + if (chain.type === "reasoning") { + write(THOUGHT_PREFIX); + } + } + write(text); + }; + + const flushContent = (chain: PendingStreamingText): void => { + if (!chain.emitted) { + chain.buffer = chain.buffer.replace(/^\s+/, ""); + } + const lastContent = lastNonWhitespaceIndex(chain.buffer); + if (lastContent < 0) { + return; + } + emit(chain, chain.buffer.slice(0, lastContent + 1)); + chain.buffer = chain.buffer.slice(lastContent + 1); + }; + + const closePending = (): void => { + const chain = pending; + pending = null; + if (!chain) { + return; + } + const tail = chain.emitted ? chain.buffer.trimEnd() : chain.buffer.trim(); + if (tail) { + emit(chain, tail); + } + if (chain.emitted) { + write("\n"); + } + }; + + const continuesChain = (item: AgentTimelineItem, turnId: string | undefined): boolean => { + if (!pending || pending.type !== item.type || pending.turnId !== turnId) { + return false; + } + if (item.type !== "assistant_message") { + return true; + } + return item.messageId === undefined || item.messageId === pending.messageId; + }; + + const startChain = ( + item: Extract, + turnId: string | undefined, + ): PendingStreamingText => { + closePending(); + const chain: PendingStreamingText = { + type: item.type, + turnId, + messageId: item.type === "assistant_message" ? item.messageId : undefined, + buffer: "", + emitted: false, + }; + pending = chain; + return chain; + }; + + return { + push(item, turnId) { + if (!matchesFilter(item, filter)) { + closePending(); + return; + } + if (item.type === "assistant_message" || item.type === "reasoning") { + const chain = continuesChain(item, turnId) && pending ? pending : startChain(item, turnId); + chain.buffer += item.text; + flushContent(chain); + return; + } + closePending(); + const transcript = formatAgentActivityTranscript([item]); + if (transcript && transcript !== NO_ACTIVITY_MESSAGE) { + write(`${transcript}\n`); + } + }, + end() { + closePending(); + }, + }; +} + function parseTailCount(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const parsed = Number.parseInt(raw, 10); @@ -62,6 +207,19 @@ function parseTailCount(raw: string | undefined): number | undefined { return parsed; } +function parseSinceTimestamp(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const timestampMs = Date.parse(raw); + if (Number.isNaN(timestampMs)) { + throw { + code: "INVALID_TIMESTAMP", + message: `Invalid --since value: ${raw}`, + details: "Use a timestamp such as 2026-01-01T00:00:00Z.", + } satisfies CommandError; + } + return timestampMs; +} + /** * Check if a timeline item matches the filter type */ @@ -98,6 +256,7 @@ export async function runLogsCommand( process.exit(1); } + const sinceTimestampMs = parseSinceTimestamp(options.since); const client = await connectToDaemon({ target: options.daemonTarget }); try { @@ -118,12 +277,19 @@ export async function runLogsCommand( await client.close().catch(() => {}); process.exit(1); } - await runFollowMode(client, resolvedId, options); + await runFollowMode({ + client, + agentId: resolvedId, + options, + sinceTimestampMs, + }); return; } // Fetch timeline directly via cursor RPC. - let timelineItems = await fetchAgentTimelineItems(client, resolvedId); + let timelineItems = await fetchAgentTimelineItems(client, resolvedId, { + sinceTimestampMs, + }); // Apply filter if (options.filter) { @@ -159,11 +325,12 @@ export async function runLogsCommand( /** * Follow mode: stream logs in real-time until interrupted */ -async function runFollowMode( - client: DaemonClient, - agentId: string, - options: AgentLogsOptions, -): Promise { +async function runFollowMode({ + client, + agentId, + options, + sinceTimestampMs, +}: FollowModeInput): Promise { const DEFAULT_FOLLOW_TAIL = 10; const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL; @@ -172,6 +339,7 @@ async function runFollowMode( try { existingItems = await fetchAgentTimelineItems(client, agentId, { timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS, + sinceTimestampMs, }); } catch (error) { console.warn("Warning: failed to fetch existing timeline", error); @@ -194,31 +362,39 @@ async function runFollowMode( const tailLabel = tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`; + const writer = createFollowTranscriptWriter(undefined, options.filter); + const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => { if (message.type === "agent.timeline.replacement") { + writer.end(); console.log("\n[Timeline replaced; earlier output is no longer current]"); return; } if (message.type === "agent.timeline.error") { + writer.end(); console.error(`Timeline observation stopped: ${message.payload.error}`); return; } if (message.type === "agent.timeline.subscription_restored") { + writer.end(); console.log("\n[Reconnected; live output resumed. Events may have been missed.]"); return; } if (message.payload.event.type === "timeline") { - const item = message.payload.event.item; - // Apply filter - if (options.filter && !matchesFilter(item, options.filter)) { + const matchesSince = + sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs; + if (!matchesSince) { return; } - // Print each timeline item as it arrives using the curator format - const transcript = formatAgentActivityTranscript([item]); - if (transcript !== NO_ACTIVITY_MESSAGE) { - console.log(transcript); - } + const item = message.payload.event.item; + writer.push(item, message.payload.event.turnId); + } else if ( + message.payload.event.type === "turn_completed" || + message.payload.event.type === "turn_failed" || + message.payload.event.type === "turn_canceled" + ) { + writer.end(); } }); @@ -226,6 +402,7 @@ async function runFollowMode( console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); await waitForStop(); + writer.end(); unsubscribe(); await client.close(); } diff --git a/packages/cli/src/commands/agent/ls.test.ts b/packages/cli/src/commands/agent/ls.test.ts index c66b34b94d..76b1787ce3 100644 --- a/packages/cli/src/commands/agent/ls.test.ts +++ b/packages/cli/src/commands/agent/ls.test.ts @@ -1,5 +1,60 @@ import { describe, expect, it } from "vitest"; -import { buildAgentLsFetchOptions } from "./ls.js"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { buildAgentLsFetchOptions, listAgents } from "./ls.js"; + +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + +function createPagingClient() { + const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({ + id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`, + provider: "codex", + title: `agent ${n}`, + status: "closed", + archivedAt: null, + cwd: "/tmp/project", + createdAt: `2026-10-0${n}T00:00:00.000Z`, + updatedAt: `2026-10-0${n}T00:00:00.000Z`, + labels: {}, + model: null, + lastUserMessageAt: null, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + })); + + // The fake daemon pages two agents at a time, newest first by the requested key + // (updated_at by default), and agent 1 is updated right after the first page is served. + const fetchAgents = async (options?: { + sort?: { key: string }[]; + page?: { cursor?: string }; + }) => { + const key: "createdAt" | "updatedAt" = + options?.sort?.[0]?.key === "created_at" ? "createdAt" : "updatedAt"; + const cursor = options?.page?.cursor; + const ordered = daemonAgents + .filter((agent) => cursor === undefined || agent[key] < cursor) + .sort((left, right) => right[key].localeCompare(left[key])); + const page = ordered.slice(0, 2); + const hasMore = ordered.length > 2; + if (cursor === undefined) { + daemonAgents[2]!.updatedAt = "2026-10-09T00:00:00.000Z"; + } + return { + entries: page.map((agent) => ({ agent })), + pageInfo: { nextCursor: hasMore ? page[page.length - 1]![key] : null }, + }; + }; + return { fetchAgents, close: async () => {} }; +} describe("buildAgentLsFetchOptions", () => { it("fetches active agents by default", () => { @@ -23,15 +78,21 @@ describe("buildAgentLsFetchOptions", () => { }); }); + it("asks the daemon for internal agents only with --internal", () => { + expect(buildAgentLsFetchOptions({ background: true, global: true })).toEqual({ + filter: { includeBackground: true }, + }); + }); + it("fetches global non-archived agents for -g", () => { expect(buildAgentLsFetchOptions({ global: true })).toEqual({}); }); - it("keeps -a within the active scope", () => { + it("includes archived and background agents with -a", () => { expect(buildAgentLsFetchOptions({ all: true })).toEqual({ - scope: "active", filter: { includeArchived: true, + includeBackground: true, }, }); }); @@ -40,6 +101,7 @@ describe("buildAgentLsFetchOptions", () => { expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({ filter: { includeArchived: true, + includeBackground: true, }, }); }); @@ -59,3 +121,13 @@ describe("buildAgentLsFetchOptions", () => { }); }); }); + +describe("runLsCommand", () => { + it("lists agents on every page, including one updated during the listing", async () => { + const result = await listAgents({ daemonTarget, all: true, global: true }, async () => + createPagingClient(), + ); + + expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]); + }); +}); diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index bb2189c7c4..55674a2f87 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -12,8 +12,9 @@ type FetchAgentsOptions = NonNullable< export function addLsOptions(cmd: Command): Command { return cmd .description("List agents. By default excludes archived agents.") - .option("-a, --all", "Include archived agents") + .option("-a, --all", "Include archived and background agents") .option("-g, --global", "List agents across all directories") + .option("--background", "Include background workspace agents") .option( "--label ", "Filter by label (can be used multiple times)", @@ -109,6 +110,8 @@ export interface AgentLsOptions extends CommandOptions { all?: boolean; /** -g: List agents across all directories */ global?: boolean; + /** --background: Include background workspace agents */ + background?: boolean; /** Filter by specific status */ status?: string; /** Filter by specific cwd */ @@ -133,7 +136,7 @@ function parseLabelFilters(labels: string[] | undefined): Record } export function buildAgentLsFetchOptions( - options: Pick, + options: Pick, ): FetchAgentsOptions { const labelFilters = parseLabelFilters(options.label); const normalizedThinkingOptionId = options.thinking?.trim(); @@ -142,6 +145,9 @@ export function buildAgentLsFetchOptions( if (options.all) { daemonFilter.includeArchived = true; } + if (options.background || options.all) { + daemonFilter.includeBackground = true; + } if (Object.keys(labelFilters).length > 0) { daemonFilter.labels = labelFilters; } @@ -150,7 +156,7 @@ export function buildAgentLsFetchOptions( } const fetchOptions: FetchAgentsOptions = {}; - if (!options.global) { + if (!options.global && !options.all) { fetchOptions.scope = "active"; } if (Object.keys(daemonFilter).length > 0) { @@ -159,18 +165,35 @@ export function buildAgentLsFetchOptions( return fetchOptions; } +interface AgentLsClient { + fetchAgents(options?: FetchAgentsOptions): Promise<{ + entries: { agent: AgentSnapshotPayload }[]; + pageInfo: { nextCursor: string | null }; + }>; + close(): Promise; +} + /** * Agent ls command semantics: * - `paseo agent ls` → active non-archived agents * - `paseo agent ls -g` → global non-archived agents - * - `paseo agent ls -a` → active agents, including archived + * - `paseo agent ls -a` → all agents, including archived and background * - `paseo agent ls -ag` → global agents, including archived */ export async function runLsCommand( options: AgentLsOptions, _command: Command, ): Promise { - const client = await connectToDaemon({ target: options.daemonTarget }); + return listAgents(options); +} + +export async function listAgents( + options: AgentLsOptions, + connect: ( + options: Parameters[0], + ) => Promise = connectToDaemon, +): Promise { + const client = await connect({ target: options.daemonTarget }); try { const normalizedThinkingOptionId = options.thinking?.trim(); @@ -183,8 +206,19 @@ export async function runLsCommand( } const labelFilters = parseLabelFilters(options.label); - const fetchPayload = await client.fetchAgents(buildAgentLsFetchOptions(options)); - let agents = fetchPayload.entries.map((entry) => entry.agent); + const fetchOptions = buildAgentLsFetchOptions(options); + let agents: AgentSnapshotPayload[] = []; + let cursor: string | undefined; + // createdAt never changes, so agents cannot move past the cursor while the pages are read. + do { + const payload = await client.fetchAgents({ + ...fetchOptions, + sort: [{ key: "created_at", direction: "desc" }], + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + agents.push(...payload.entries.map((entry) => entry.agent)); + cursor = payload.pageInfo.nextCursor ?? undefined; + } while (cursor); // By default, exclude archived agents. `-a` includes them. if (!options.all) { diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index b4e3b20bde..5e64ccba81 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; +import { resolveCallerAgentId as resolveRunCallerAgentId } from "../../utils/caller-agent.js"; import { resolveExistingRunWorkspace, - resolveRunCallerAgentId, runRunCommand, + waitsForFinish, type AgentRunOptions, } from "./run"; @@ -21,6 +22,15 @@ function daemonWithAgents(...agentIds: string[]) { }; } +describe("run wait policy", () => { + it("waits by default and stops waiting on --no-wait or a legacy alias", () => { + expect(waitsForFinish({})).toBe(true); + expect(waitsForFinish({ wait: false })).toBe(false); + expect(waitsForFinish({ background: true })).toBe(false); + expect(waitsForFinish({ detach: true })).toBe(false); + }); +}); + describe("managed agent caller context", () => { it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => { await expect( @@ -38,17 +48,20 @@ describe("managed agent caller context", () => { ).resolves.toBeUndefined(); }); - it("fails instead of dropping the caller when the lookup loses its connection", async () => { - const disconnectedDaemon = { - async fetchAgent(): Promise { - throw new DaemonConnectionError("Connection lost before message could be sent"); - }, - }; + it.each(["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"] as const)( + "preserves %s instead of dropping the caller", + async (code) => { + const disconnectedDaemon = { + async fetchAgent(): Promise { + throw new DaemonConnectionError("Caller lookup transport failed", code); + }, + }; - await expect( - resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }), - ).rejects.toBeInstanceOf(DaemonConnectionError); - }); + await expect( + resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }), + ).rejects.toBeInstanceOf(DaemonConnectionError); + }, + ); it("omits blank caller ids", async () => { await expect( diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 82817684c2..86cca5f75c 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -4,7 +4,7 @@ import { StructuredAgentResponseError, } from "@getpaseo/server/agent-response"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; +import { resolveCallerAgentId } from "../../utils/caller-agent.js"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, @@ -27,10 +27,13 @@ export function addRunOptions(cmd: Command): Command { cmd .description("Create and start an agent with a task") .argument("", "The task/prompt for the agent") - .option("-d, --background", "Run in background") + .option("--no-wait", "Return immediately without waiting for completion") + // COMPAT(backgroundRunFlag): --background was the documented spelling of + // --no-wait. Added in v0.2.0; remove after 2027-04-01. + .addOption(new Option("-d, --background", "Legacy alias for --no-wait").hideHelp()) // COMPAT(detachRunFlag): --detach used to mean background execution, not // ownership transfer. Added in v0.2.0; remove after 2027-01-17. - .addOption(new Option("--detach", "Legacy alias for --background").hideHelp()) + .addOption(new Option("--detach", "Legacy alias for --no-wait").hideHelp()) .option("--title ", "Assign a title to the agent") .addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp()) .option( @@ -111,6 +114,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = { }; export interface AgentRunOptions extends CommandOptions { + wait?: boolean; background?: boolean; detach?: boolean; title?: string; @@ -389,19 +393,25 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche } satisfies CommandError; } + if (options.background) { + console.error("Warning: --background (-d) is deprecated. Use --no-wait instead."); + } + validateRunWorkspaceOptions(options); - if (outputSchema && runsInBackground(options)) { + if (outputSchema && !waitsForFinish(options)) { throw { code: "INVALID_OPTIONS", - message: "--output-schema cannot be used with --background", + message: "--output-schema cannot be used with --no-wait", details: "Structured output requires waiting for the agent to finish", } satisfies CommandError; } } -function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean { - return Boolean(options.background || options.detach); +export function waitsForFinish( + options: Pick<AgentRunOptions, "wait" | "background" | "detach">, +): boolean { + return options.wait !== false && !options.background && !options.detach; } function parseWaitTimeoutOption(waitTimeout: string | undefined): number { @@ -557,7 +567,10 @@ async function resolveRunWorkspace( // TODO: thread the run `prompt` as firstAgentContext so workspace-level // title/branch generation picks up the task description (U8/U6 deferred). const source = buildRunWorkspaceSource(options, cwd); - const result = await client.createWorkspace({ source }); + const result = await client.createWorkspace({ + source, + ...(callerAgentId ? { callerAgentId } : {}), + }); if (!result.workspace) { throw { @@ -611,7 +624,7 @@ export async function runRunCommand( const env = parseRunEnv(options.env); const requestEnv = Object.keys(env).length > 0 ? env : undefined; - const callerAgentId = await resolveRunCallerAgentId(client); + const callerAgentId = await resolveCallerAgentId(client); const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId); const workspaceId = workspace.id; const runCwd = workspace.cwd; @@ -707,8 +720,8 @@ export async function runRunCommand( labels: Object.keys(labels).length > 0 ? labels : undefined, }); - // Default run behavior is foreground: wait for completion unless background execution is set. - if (!runsInBackground(options)) { + // Default run behavior is foreground: wait for completion unless --no-wait is set. + if (waitsForFinish(options)) { const state = await client.waitForFinish(agent.id, waitTimeoutMs); await client.close(); @@ -744,29 +757,3 @@ export async function runRunCommand( throw error; } } - -export interface RunCallerLookupClient { - fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>; -} - -// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run -// sent to another daemon (--host or --home) has no caller there, so it runs as -// a top-level agent instead of failing on an unknown caller. -export async function resolveRunCallerAgentId( - client: RunCallerLookupClient, - env: { PASEO_AGENT_ID?: string } = process.env, -): Promise<string | undefined> { - const agentId = env.PASEO_AGENT_ID?.trim(); - if (!agentId) { - return undefined; - } - const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => { - // A daemon without this agent answers with an error. A lost or timed-out - // connection is not an answer, so it must not drop the caller. - if (error instanceof DaemonConnectionError) { - throw error; - } - return null; - }); - return caller?.agent.id === agentId ? agentId : undefined; -} diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index aa9b5a24cc..69e0c36d9d 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -192,7 +192,10 @@ export async function runSendCommand( options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined; // Send the message - await client.sendAgentMessage(agentIdArg, promptInput, { images }); + await client.sendAgentMessage(agentIdArg, promptInput, { + images, + sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined, + }); // If --no-wait, return immediately if (options.wait === false) { diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index cb42c0b0a5..fd7e845ecd 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])( stdio: "ignore", }); const exited = new Promise((resolve) => child!.once("exit", resolve)); + // The lock file exists before its contents identify the supervisor. + let supervisorPid: number | undefined; await expect - .poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 }) - .toBe(true); - const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")); + .poll( + async () => { + supervisorPid = (await readDaemonInstance(home))?.pid; + return supervisorPid; + }, + { timeout: 10_000 }, + ) + .toBeTypeOf("number"); await f.ok(["status", "--home", home]); child.kill("SIGINT"); await exited; - expect(() => process.kill(lock.pid, 0)).toThrow(); + expect(() => process.kill(supervisorPid!, 0)).toThrow(); expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped"); } finally { child?.kill("SIGTERM"); diff --git a/packages/cli/src/commands/permit/allow.ts b/packages/cli/src/commands/permit/allow.ts index 755bfaf4d7..71bee3f73c 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -1,5 +1,8 @@ import type { Command } from "commander"; -import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, +} from "@getpaseo/protocol/agent-types"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; @@ -55,106 +58,136 @@ export interface PermitAllowOptions extends CommandOptions { host?: string; } -export async function runAllowCommand( - agentIdOrPrefix: string, - reqId: string | undefined, - options: PermitAllowOptions, - _command: Command, -): Promise<PermitAllowResult> { - // No validation needed - if no reqId provided, allow all by default +export interface PermitClient { + fetchAgent(options: { agentId: string }): Promise<{ + agent: { id: string; pendingPermissions?: AgentPermissionRequest[] }; + } | null>; + respondToPermissionAndWait( + agentId: string, + requestId: string, + response: AgentPermissionResponse, + ): Promise<unknown>; + close(): Promise<void>; +} - // Parse input JSON if provided - let updatedInput: Record<string, unknown> | undefined; - if (options.input) { - try { - updatedInput = JSON.parse(options.input); - } catch (err) { - const error: CommandError = { - code: "INVALID_JSON", - message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`, - details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'', - }; - throw error; - } - } +export type PermitClientConnector = ( + options: Parameters<typeof connectToDaemon>[0], +) => Promise<PermitClient>; - const client = await connectToDaemon({ target: options.daemonTarget }); +export function createAllowCommand(connect: PermitClientConnector = connectToDaemon) { + return async function runAllowCommand( + agentIdOrPrefix: string, + reqId: string | undefined, + options: PermitAllowOptions, + _command: Command, + ): Promise<PermitAllowResult> { + // No validation needed - if no reqId provided, allow all by default - try { - const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); - if (!fetchResult) { - await client.close(); - const error: CommandError = { - code: "AGENT_NOT_FOUND", - message: `Agent not found: ${agentIdOrPrefix}`, - details: 'Use "paseo ls" to list available agents', - }; - throw error; - } - const agent = fetchResult.agent; - const resolvedAgentId = agent.id; - - // Get pending permissions for this agent - const pendingPermissions = agent.pendingPermissions || []; - if (pendingPermissions.length === 0) { - await client.close(); - const error: CommandError = { - code: "NO_PENDING_PERMISSIONS", - message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, - }; - throw error; - } - - // Determine which permissions to allow - let permissionsToAllow: AgentPermissionRequest[]; - if (!reqId || options.all) { - // Default: allow all pending permissions if no req_id specified - // --all flag is kept as an explicit alias for clarity - permissionsToAllow = pendingPermissions; - } else { - // Find permission by ID prefix - const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId)); - if (!permission) { - await client.close(); + // Parse input JSON if provided + let updatedInput: Record<string, unknown> | undefined; + if (options.input) { + try { + updatedInput = JSON.parse(options.input); + } catch (err) { const error: CommandError = { - code: "PERMISSION_NOT_FOUND", - message: `Permission request not found: ${reqId}`, - details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + code: "INVALID_JSON", + message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`, + details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'', }; throw error; } - permissionsToAllow = [permission]; } - // Allow permissions - const results: PermissionResponseItem[] = await Promise.all( - permissionsToAllow.map(async (permission) => { - await client.respondToPermission(resolvedAgentId, permission.id, { - behavior: "allow", - ...(updatedInput ? { updatedInput } : {}), - }); - return toPermissionResponseItem(resolvedAgentId, permission, "allowed"); - }), - ); + const client = await connect({ target: options.daemonTarget }); - await client.close(); + try { + const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); + if (!fetchResult) { + await client.close(); + const error: CommandError = { + code: "AGENT_NOT_FOUND", + message: `Agent not found: ${agentIdOrPrefix}`, + details: 'Use "paseo ls" to list available agents', + }; + throw error; + } + const agent = fetchResult.agent; + const resolvedAgentId = agent.id; - return { - type: "list", - data: results, - schema: permitResponseSchema, - }; - } catch (err) { - await client.close().catch(() => {}); - // Re-throw CommandErrors - if (err && typeof err === "object" && "code" in err) { - throw err; + // Get pending permissions for this agent + const pendingPermissions = agent.pendingPermissions || []; + if (pendingPermissions.length === 0) { + await client.close(); + const error: CommandError = { + code: "NO_PENDING_PERMISSIONS", + message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, + }; + throw error; + } + + // Determine which permissions to allow + let permissionsToAllow: AgentPermissionRequest[]; + if (!reqId || options.all) { + // Default: allow all pending permissions if no req_id specified + // --all flag is kept as an explicit alias for clarity + permissionsToAllow = pendingPermissions; + } else { + // Find permission by ID prefix + const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId)); + if (!permission) { + await client.close(); + const error: CommandError = { + code: "PERMISSION_NOT_FOUND", + message: `Permission request not found: ${reqId}`, + details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + }; + throw error; + } + permissionsToAllow = [permission]; + } + + // Allow permissions + const results: PermissionResponseItem[] = await Promise.all( + permissionsToAllow.map(async (permission) => { + try { + await client.respondToPermissionAndWait(resolvedAgentId, permission.id, { + behavior: "allow", + ...(updatedInput ? { updatedInput } : {}), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "PERMISSION_RESPONSE_UNCONFIRMED", + message: `Permission response unconfirmed for request ${permission.id}: ${message}`, + details: 'Use "paseo permit ls" to check whether the request is still pending', + }; + throw error; + } + return toPermissionResponseItem(resolvedAgentId, permission, "allowed"); + }), + ); + + await client.close(); + + return { + type: "list", + data: results, + schema: permitResponseSchema, + }; + } catch (err) { + await client.close().catch(() => {}); + // Re-throw CommandErrors + if (err && typeof err === "object" && "code" in err) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "ALLOW_PERMISSION_FAILED", + message: `Failed to allow permission: ${message}`, + }; + throw error; } - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "ALLOW_PERMISSION_FAILED", - message: `Failed to allow permission: ${message}`, - }; - throw error; - } + }; } + +export const runAllowCommand = createAllowCommand(); diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index fe6c619e32..69a4a35a4d 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -6,6 +6,7 @@ import { permitResponseSchema, toPermissionResponseItem, type PermissionResponseItem, + type PermitClientConnector, } from "./allow.js"; export type PermitDenyResult = ListResult<PermissionResponseItem>; @@ -17,98 +18,114 @@ export interface PermitDenyOptions extends CommandOptions { host?: string; } -export async function runDenyCommand( - agentIdOrPrefix: string, - reqId: string | undefined, - options: PermitDenyOptions, - _command: Command, -): Promise<PermitDenyResult> { - // Validate arguments - if (!options.all && !reqId) { - const error: CommandError = { - code: "MISSING_ARGUMENT", - message: "Request ID is required unless --all is specified", - details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all", - }; - throw error; - } - - const client = await connectToDaemon({ target: options.daemonTarget }); - - try { - const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); - if (!fetchResult) { - await client.close(); +export function createDenyCommand(connect: PermitClientConnector = connectToDaemon) { + return async function runDenyCommand( + agentIdOrPrefix: string, + reqId: string | undefined, + options: PermitDenyOptions, + _command: Command, + ): Promise<PermitDenyResult> { + // Validate arguments + if (!options.all && !reqId) { const error: CommandError = { - code: "AGENT_NOT_FOUND", - message: `Agent not found: ${agentIdOrPrefix}`, - details: 'Use "paseo ls" to list available agents', - }; - throw error; - } - const agent = fetchResult.agent; - const resolvedAgentId = agent.id; - - // Get pending permissions for this agent - const pendingPermissions = agent.pendingPermissions || []; - if (pendingPermissions.length === 0) { - await client.close(); - const error: CommandError = { - code: "NO_PENDING_PERMISSIONS", - message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, + code: "MISSING_ARGUMENT", + message: "Request ID is required unless --all is specified", + details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all", }; throw error; } - // Determine which permissions to deny - let permissionsToDeny: AgentPermissionRequest[]; - if (options.all) { - permissionsToDeny = pendingPermissions; - } else { - // Find permission by ID prefix - const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!)); - if (!permission) { + const client = await connect({ target: options.daemonTarget }); + + try { + const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); + if (!fetchResult) { await client.close(); const error: CommandError = { - code: "PERMISSION_NOT_FOUND", - message: `Permission request not found: ${reqId}`, - details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + code: "AGENT_NOT_FOUND", + message: `Agent not found: ${agentIdOrPrefix}`, + details: 'Use "paseo ls" to list available agents', }; throw error; } - permissionsToDeny = [permission]; + const agent = fetchResult.agent; + const resolvedAgentId = agent.id; + + // Get pending permissions for this agent + const pendingPermissions = agent.pendingPermissions || []; + if (pendingPermissions.length === 0) { + await client.close(); + const error: CommandError = { + code: "NO_PENDING_PERMISSIONS", + message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, + }; + throw error; + } + + // Determine which permissions to deny + let permissionsToDeny: AgentPermissionRequest[]; + if (options.all) { + permissionsToDeny = pendingPermissions; + } else { + // Find permission by ID prefix + const permission = pendingPermissions.find( + (p) => p.id === reqId || p.id.startsWith(reqId!), + ); + if (!permission) { + await client.close(); + const error: CommandError = { + code: "PERMISSION_NOT_FOUND", + message: `Permission request not found: ${reqId}`, + details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + }; + throw error; + } + permissionsToDeny = [permission]; + } + + // Deny permissions + const results: PermissionResponseItem[] = await Promise.all( + permissionsToDeny.map(async (permission) => { + try { + await client.respondToPermissionAndWait(resolvedAgentId, permission.id, { + behavior: "deny", + ...(options.message ? { message: options.message } : {}), + ...(options.interrupt ? { interrupt: true } : {}), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "PERMISSION_RESPONSE_UNCONFIRMED", + message: `Permission response unconfirmed for request ${permission.id}: ${message}`, + details: 'Use "paseo permit ls" to check whether the request is still pending', + }; + throw error; + } + return toPermissionResponseItem(resolvedAgentId, permission, "denied"); + }), + ); + + await client.close(); + + return { + type: "list", + data: results, + schema: permitResponseSchema, + }; + } catch (err) { + await client.close().catch(() => {}); + // Re-throw CommandErrors + if (err && typeof err === "object" && "code" in err) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "DENY_PERMISSION_FAILED", + message: `Failed to deny permission: ${message}`, + }; + throw error; } - - // Deny permissions - const results: PermissionResponseItem[] = await Promise.all( - permissionsToDeny.map(async (permission) => { - await client.respondToPermission(resolvedAgentId, permission.id, { - behavior: "deny", - ...(options.message ? { message: options.message } : {}), - ...(options.interrupt ? { interrupt: true } : {}), - }); - return toPermissionResponseItem(resolvedAgentId, permission, "denied"); - }), - ); - - await client.close(); - - return { - type: "list", - data: results, - schema: permitResponseSchema, - }; - } catch (err) { - await client.close().catch(() => {}); - // Re-throw CommandErrors - if (err && typeof err === "object" && "code" in err) { - throw err; - } - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DENY_PERMISSION_FAILED", - message: `Failed to deny permission: ${message}`, - }; - throw error; - } + }; } + +export const runDenyCommand = createDenyCommand(); diff --git a/packages/cli/src/commands/permit/permit-response.test.ts b/packages/cli/src/commands/permit/permit-response.test.ts new file mode 100644 index 0000000000..5328b6ecb8 --- /dev/null +++ b/packages/cli/src/commands/permit/permit-response.test.ts @@ -0,0 +1,177 @@ +import { setImmediate as waitForCommandContinuations } from "node:timers/promises"; +import { Command } from "commander"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, +} from "@getpaseo/protocol/agent-types"; +import { describe, expect, it } from "vitest"; +import { createAllowCommand, type PermitClient } from "./allow.js"; +import { createDenyCommand } from "./deny.js"; + +const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f"; +const requests: AgentPermissionRequest[] = [ + { + id: "permission-write", + provider: "codex", + name: "CodexBash", + kind: "tool", + }, + { id: "permission-edit", provider: "codex", name: "CodexBash", kind: "tool" }, +]; + +class PermissionClient implements PermitClient { + readonly pending = [...requests]; + readonly responses = new Map<string, AgentPermissionResponse>(); + closed = false; + private notifyReceived = () => {}; + private readonly confirmations = new Map< + string, + { + resolve: () => void; + reject: (error: Error) => void; + } + >(); + + async fetchAgent() { + return { agent: { id: agentId, pendingPermissions: this.pending } }; + } + + async respondToPermission( + _agentId: string, + requestId: string, + response: AgentPermissionResponse, + ) { + this.responses.set(requestId, response); + this.notifyReceived(); + } + + async respondToPermissionAndWait( + responseAgentId: string, + requestId: string, + response: AgentPermissionResponse, + ): Promise<void> { + const confirmation = new Promise<void>((resolve, reject) => { + this.confirmations.set(requestId, { resolve, reject }); + }); + await this.respondToPermission(responseAgentId, requestId, response); + await confirmation; + } + + async close() { + this.closed = true; + } + + async waitForResponses(count: number) { + while (this.responses.size < count) { + await new Promise<void>((resolve) => { + this.notifyReceived = resolve; + }); + } + } + + confirm(requestId: string) { + const confirmation = this.confirmations.get(requestId); + if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`); + this.pending.splice( + this.pending.findIndex((request) => request.id === requestId), + 1, + ); + confirmation.resolve(); + } + + fail(requestId: string, error: Error) { + const confirmation = this.confirmations.get(requestId); + if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`); + confirmation.reject(error); + } +} + +const options = { + daemonTarget: { kind: "endpoint" as const, host: "test.invalid" }, +}; +const actions = [ + { name: "allow", create: createAllowCommand, result: "allowed" }, + { name: "deny", create: createDenyCommand, result: "denied" }, +]; +for (const action of actions) { + describe(`permit ${action.name}`, () => { + it("returns success and closes only after confirmation", async () => { + const client = new PermissionClient(); + let completed = false; + const command = action.create(async () => client)( + agentId, + requests[0].id, + options, + new Command(), + ); + void command.then( + () => (completed = true), + () => false, + ); + await client.waitForResponses(1); + await waitForCommandContinuations(); + expect(completed).toBe(false); + expect(client.closed).toBe(false); + expect(client.pending).toEqual(requests); + client.confirm(requests[0].id); + const result = await command; + expect(result.data).toEqual([ + { + requestId: requests[0].id, + agentId, + agentShortId: agentId.slice(0, 7), + name: "CodexBash", + result: action.result, + }, + ]); + expect(client.closed).toBe(true); + expect(client.pending).toEqual([requests[1]]); + }); + + it("waits for every confirmation with --all", async () => { + const client = new PermissionClient(); + let completed = false; + const command = action.create(async () => client)( + agentId, + undefined, + { ...options, all: true }, + new Command(), + ); + void command.then( + () => (completed = true), + () => false, + ); + await client.waitForResponses(2); + client.confirm(requests[0].id); + // Wait beyond the first reply's promise continuations while withholding the second. + await waitForCommandContinuations(); + expect(completed).toBe(false); + expect(client.closed).toBe(false); + client.confirm(requests[1].id); + const result = await command; + expect(result.data.map((item) => item.result)).toEqual([action.result, action.result]); + expect(client.closed).toBe(true); + expect(client.pending).toEqual([]); + }); + + it("rejects an unconfirmed response without returning success", async () => { + const client = new PermissionClient(); + const command = action.create(async () => client)( + agentId, + requests[0].id, + options, + new Command(), + ); + const failure = expect(command).rejects.toMatchObject({ + code: "PERMISSION_RESPONSE_UNCONFIRMED", + message: expect.stringContaining(requests[0].id), + details: 'Use "paseo permit ls" to check whether the request is still pending', + }); + await client.waitForResponses(1); + client.fail(requests[0].id, new Error("Timeout waiting for message (15000ms)")); + await failure; + expect(client.closed).toBe(true); + expect(client.pending).toEqual(requests); + }); + }); +} diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index bad3bc0436..8ea6de9dfb 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -255,12 +255,12 @@ describe("plugin management commands", () => { const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const command = createPluginCommand(); - await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], { + await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], { from: "user", }); expect(installPluginSource).toHaveBeenCalledWith({ - source: "owner/monorepo:plugins/review", + source: "git:owner/monorepo:plugins/review", }); stderr.mockRestore(); }); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 993c690f68..86dc5889b8 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -1,9 +1,12 @@ +import path from "node:path"; +import { expandUserPath } from "../../classify.js"; import { createInterface } from "node:readline/promises"; import { reviewPluginUpdates, type UpdateOutcome } from "./update.js"; import { Command } from "commander"; import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages"; import { formatPluginSourceReference, + parsePluginSourceReference, formatPluginIdentity, } from "@getpaseo/protocol/plugin-source-reference"; import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; @@ -125,7 +128,18 @@ export async function runPluginInstallCommand( process.stderr.write( "Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n", ); - const sourceReference = formatPluginSourceReference(source, options.path); + const reference = parsePluginSourceReference(source); + let resolvedSource = source; + if (reference.kind === "directory") { + const directory = expandUserPath(reference.source); + // An absolute path can target a daemon on a different operating system. + const absoluteDirectory = + path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory) + ? directory + : path.resolve(directory); + resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath); + } + const sourceReference = formatPluginSourceReference(resolvedSource, options.path); const data = await withPluginSourceClient(options.daemonTarget, (client) => client.installPluginSource({ source: sourceReference, @@ -215,10 +229,12 @@ export function createPluginCommand(): Command { plugin .command("install") .alias("add") - .description("Trust and install a plugin from a directory, Git repository, or npm package") + .description( + "Trust and install a plugin from a registry, directory, Git repository, or npm package", + ) .argument( "<source>", - "Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path", + "Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source", ) .option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref <ref>", "Git branch, tag, or commit") diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index df0c13295b..a3350ecb8c 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; @@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js"; const directories: string[] = []; const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc"); async function typecheckPlugin(directory: string): Promise<void> { @@ -28,6 +29,47 @@ afterEach(async () => { }); describe("plugin scaffold", () => { + it("creates an author overview beside the manifest", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "hello-plugin"); + await scaffoldPluginDirectory(directory); + + const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8"); + const comments = overview.match(/<!--[\s\S]*?-->/g) ?? []; + expect(comments).toHaveLength(1); + expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe(""); + expect(overview).toContain("required to list"); + expect(overview).toContain("README"); + expect(overview).toContain("choosing a plugin in Paseo"); + expect(overview).toContain("Length follows complexity"); + expect(overview).toContain("1. Describe what your plugin is and does"); + expect(overview).toContain("2. Explain how it works only when it is not obvious"); + expect(overview).toContain("3. Explain setup when needed"); + expect(overview).toContain("4. Explain capabilities and settings"); + expect(overview).toContain("permissions"); + expect(overview).toContain("known limits"); + expect(overview).not.toContain("This plugin entry was imported"); + const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + expect(packageJson.files).toContain("OVERVIEW.md"); + expect(await readdir(directory)).toContain("paseo-plugin.json"); + }); + + it("includes the author overview in the packed npm artifact", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "packed-plugin"); + await scaffoldPluginDirectory(directory); + + const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", { + cwd: directory, + }); + const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[]; + expect(artifact.files.map((file) => file.path)).toEqual( + expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]), + ); + }); + it( "creates a standalone split-runtime project that typechecks", { timeout: 15_000 }, @@ -51,6 +93,8 @@ describe("plugin scaffold", () => { expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( { id: "hello-plugin", + $comment: expect.stringContaining('"icon": "assets/icon.png"'), + media: [], requirements: { paseo: `>=${cliPackageJson.version}` }, }, ); @@ -60,6 +104,7 @@ describe("plugin scaffold", () => { version: "0.0.0", files: [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index 448aa956fa..b1eb53a6ef 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -3,6 +3,34 @@ import path from "node:path"; import { PluginIdSchema } from "@getpaseo/protocol/messages"; import { resolveCliVersion } from "../../version.js"; +const OVERVIEW = `<!-- +OVERVIEW.md is required to list your plugin in the registry. Keep it beside +paseo-plugin.json in your repository at the pinned commit and in your npm package. +Replace this comment with useful facts before publishing. + +This overview is for people choosing a plugin in Paseo, on the plugin page under +its install command. README assumes a GitHub audience and includes technical +details, installation instructions, and badges. Long, AI-generated READMEs leave +people reading past that material to understand what the plugin does. + +Write these parts in order, including only what helps someone decide to install: + +1. Describe what your plugin is and does in plain terms first. +2. Explain how it works only when it is not obvious. +3. Explain setup when needed: settings, accounts, tokens, providers, tools, or + other plugins. Include applicable daemon version and operating system + requirements. Setup guidance is allowed; installation instructions are not. +4. Explain capabilities and settings worth understanding, what each option does, + what the plugin reads or sends and where, permissions, and known limits. + +Length follows complexity; a theme needs one paragraph. Choose headings only +when they help. Use sentence case and plain factual language, with no em dashes. +Omit installation commands, badges, changelog, contributing or license sections, +marketing, and unsupported claims. Avoid implementation filler such as empty +cleanup functions, theme-token field lists, or lists of absent features. +--> +`; + const TSCONFIG = { compilerOptions: { target: "ES2020", @@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory( version: "0.0.0", files: [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", @@ -177,8 +206,19 @@ export async function scaffoldPluginDirectory( const files = new Map<string, string>([ [ "paseo-plugin.json", - `${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`, + `${JSON.stringify( + { + id, + $comment: + 'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.', + media: [], + requirements: { paseo: `>=${version}` }, + }, + null, + 2, + )}\n`, ], + ["OVERVIEW.md", OVERVIEW], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], ["index.client.tsx", CLIENT_ENTRY], diff --git a/packages/cli/src/commands/usage/index.test.ts b/packages/cli/src/commands/usage/index.test.ts new file mode 100644 index 0000000000..9c2e3e8bbf --- /dev/null +++ b/packages/cli/src/commands/usage/index.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from "vitest"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { render } from "../../output/index.js"; +import { createCli } from "../../cli.js"; +import { runUsageCommand } from "./index.js"; + +const options = { daemonTarget: { kind: "endpoint" as const, host: "example.test:1234" } }; +const reports: UsageReportEntry[] = [ + { + id: "plugin.source:work.account", + sourceId: "plugin.source", + sourceLabel: "Plugin Source", + account: { label: "Work" }, + fetchedAt: "2026-10-09T12:00:00Z", + icon: "<svg />", + report: { + status: "available", + planLabel: "Pro", + windows: [ + { + id: "5h", + label: "Five hours", + usedPct: 12.5, + remainingPct: 87.5, + resetsAt: "2026-10-09T17:00:00Z", + runsOutAt: "2026-10-09T16:00:00Z", + shortfallPct: 4.25, + }, + { id: "week", label: "Weekly", usedPct: null, remainingPct: 0 }, + { id: "extra", label: "Extra", usedPct: 0 }, + ], + balances: [ + { + id: "credit", + label: "Credit", + used: 0, + remaining: 2.5, + limit: 10, + unit: "usd", + resetsAt: "2026-11-01T00:00:00Z", + }, + { id: "requests", label: "Requests", remaining: null, unit: "requests" }, + ], + details: [{ id: "tier", label: "Tier", value: "Enterprise" }], + }, + }, + { + id: "plugin.source:personal", + sourceId: "plugin.source", + sourceLabel: "Plugin Source", + account: {}, + fetchedAt: "2026-10-09T12:00:00Z", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex login" }, + }, + loginErrors: [ + { + harness: "Claude Code", + report: { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-10-08T00:00:00Z", refreshedBy: "claude" }, + }, + }, + { + harness: "Codex", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex login" }, + }, + }, + { harness: "Other", report: { status: "error", error: "Login service failed" } }, + ], + }, + { + id: "other:broken", + sourceId: "other", + sourceLabel: "Other", + account: {}, + fetchedAt: "2026-10-09T12:00:00Z", + report: { status: "error", error: "Source failed" }, + }, + { + id: "other:noquota", + sourceId: "other", + sourceLabel: "Other", + account: {}, + fetchedAt: "2026-10-09T12:00:00Z", + report: { + status: "unavailable", + problem: { kind: "no_quota", detail: "No quota for this plan" }, + }, + }, +]; + +function host(entries = reports, supported = true) { + const requests: Array<Parameters<NonNullable<Parameters<typeof runUsageCommand>[2]>>[0]> = []; + const queries: Array< + { agentId?: string; forceRefresh?: boolean; reportIds?: string[] } | undefined + > = []; + let closed = 0; + return { + requests, + queries, + get closed() { + return closed; + }, + connect: async (input: (typeof requests)[number]) => { + requests.push(input); + return { + getLastServerInfoMessage: () => ({ features: { usageSources: supported } }), + listUsageReports: async (query?: (typeof queries)[number]) => { + queries.push(query); + const selected = query?.reportIds + ? entries.filter((entry) => query.reportIds!.includes(entry.id)) + : entries; + return { requestId: "request", reports: selected }; + }, + close: async () => { + closed++; + }, + }; + }, + }; +} + +it("preserves complete JSON entries, numeric values and quiet full IDs", async () => { + const daemon = host(); + const result = await runUsageCommand(options, { kind: "list" }, daemon.connect); + expect(JSON.parse(render(result, { format: "json" }))).toEqual(reports); + expect(render(result, { quiet: true })).toBe(reports.map((entry) => entry.id).join("\n")); + expect(daemon.requests).toEqual([{ target: options.daemonTarget }]); + expect(daemon.closed).toBe(1); +}); + +it("lists all windows, balances, missing values, failures and remedies", async () => { + const result = await runUsageCommand(options, { kind: "list" }, host().connect); + const text = render(result, { noColor: true }); + for (const expected of [ + "REPORT ID", + "SOURCE", + "ACCOUNT", + "STATUS", + "USAGE", + "FETCHED", + "Five hours: 12.5% used, 87.5% remaining", + "Weekly: - used, 0% remaining", + "Extra: 0% used, - remaining", + "Credit: 0 used, 2.5 remaining, 10 limit (usd)", + "Requests: - used, - remaining, - limit (requests)", + "Login expired at 2026-10-08T00:00:00Z. Run claude", + "Login rejected (HTTP 401). Run codex login", + "Other: Login service failed", + "Source failed", + "No quota for this plan", + ]) + expect(text).toContain(expected); + expect(render(result, { noHeaders: true })).not.toContain("REPORT ID"); +}); + +it("inspects exact opaque IDs and includes resets, forecasts, details and login failures", async () => { + const daemon = host(); + const result = await runUsageCommand( + options, + { kind: "inspect", id: reports[0]!.id }, + daemon.connect, + ); + expect(JSON.parse(render(result, { format: "json" }))).toEqual(reports[0]); + const text = render(result); + for (const expected of [ + "Source ID: plugin.source", + "Plan: Pro", + "Resets: 2026-10-09T17:00:00Z", + "Runs out: 2026-10-09T16:00:00Z", + "Shortfall: 4.25%", + "Resets: -", + "Resets: 2026-11-01T00:00:00Z", + "Tier: Enterprise", + ]) + expect(text).toContain(expected); + const failure = await runUsageCommand( + options, + { kind: "inspect", id: reports[1]!.id }, + daemon.connect, + ); + expect(render(failure)).toContain("Login failures:\n Claude Code: Login expired at"); + expect(render(failure)).toContain(" Other: Login service failed"); +}); + +it("discovers before inspecting, then refreshes only the selected report", async () => { + const daemon = host(); + await runUsageCommand( + { ...options, refresh: true }, + { kind: "inspect", id: reports[0]!.id }, + daemon.connect, + ); + expect(daemon.queries).toEqual([undefined, { reportIds: [reports[0]!.id], forceRefresh: true }]); +}); + +it("rejects agent selection combined with inspect before connecting", async () => { + const daemon = host(); + await expect( + runUsageCommand( + { ...options, agent: "agent-A" }, + { kind: "inspect", id: reports[0]!.id }, + daemon.connect, + ), + ).rejects.toMatchObject({ code: "INVALID_OPTIONS" }); + expect(daemon.requests).toEqual([]); +}); + +it("passes agent account selection and refresh without converting it to token consumption", async () => { + const daemon = host(); + await runUsageCommand( + { ...options, agent: "agent-123", refresh: true }, + { kind: "list" }, + daemon.connect, + ); + expect(daemon.queries).toEqual([{ agentId: "agent-123", forceRefresh: true }]); +}); + +it("returns an empty array in JSON and explains empty discovery in human output", async () => { + const result = await runUsageCommand(options, { kind: "list" }, host([]).connect); + expect(render(result, { format: "json" })).toBe("[]"); + expect(render(result)).toBe("No usage reports returned"); + expect(render(result, { quiet: true })).toBe(""); +}); + +it("rejects unknown IDs and prefixes and closes the client", async () => { + const daemon = host(); + await expect( + runUsageCommand(options, { kind: "inspect", id: "plugin.source:work" }, daemon.connect), + ).rejects.toEqual({ + code: "REPORT_NOT_FOUND", + message: "Unknown usage report: plugin.source:work", + }); + expect(daemon.closed).toBe(1); +}); + +it("gates unsupported hosts before any request and closes the connection", async () => { + const daemon = host(reports, false); + await expect(runUsageCommand(options, { kind: "list" }, daemon.connect)).rejects.toMatchObject({ + code: "UNSUPPORTED_HOST", + }); + expect(daemon.queries).toEqual([]); + expect(daemon.closed).toBe(1); +}); + +it("propagates connection and request failures", async () => { + await expect( + runUsageCommand(options, { kind: "list" }, async () => { + throw new Error("Connection failed"); + }), + ).rejects.toThrow("Connection failed"); + let closed = false; + const connect = async () => ({ + getLastServerInfoMessage: () => ({ features: { usageSources: true } }), + listUsageReports: async () => { + throw new Error("Request failed"); + }, + close: async () => { + closed = true; + }, + }); + await expect(runUsageCommand(options, { kind: "list" }, connect)).rejects.toThrow( + "Request failed", + ); + expect(closed).toBe(true); +}); + +describe("usage CLI registration", () => { + it("registers default, ls and inspect with the accepted selectors", () => { + const usage = createCli().commands.find((command) => command.name() === "usage")!; + expect(usage.commands.map((command) => command.name())).toEqual(["ls", "inspect"]); + expect(usage.helpInformation()).toContain("--agent <id>"); + expect(usage.commands[0]!.helpInformation()).toContain("--refresh"); + expect(usage.commands[1]!.helpInformation()).toContain("<report-id>"); + }); +}); diff --git a/packages/cli/src/commands/usage/index.ts b/packages/cli/src/commands/usage/index.ts new file mode 100644 index 0000000000..ab96fc659c --- /dev/null +++ b/packages/cli/src/commands/usage/index.ts @@ -0,0 +1,95 @@ +import { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { connectToDaemon, type ConnectOptions } from "../../utils/client.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { withOutput, type CommandOptions, type CommandResult } from "../../output/index.js"; +import { usageSchema } from "./presentation.js"; + +interface UsageOptions extends CommandOptions { + agent?: string; + refresh?: boolean; +} + +type UsageSelection = { kind: "list" } | { kind: "inspect"; id: string }; +interface UsageClient { + getLastServerInfoMessage(): { features?: { usageSources?: boolean } } | null; + listUsageReports: DaemonClient["listUsageReports"]; + close: DaemonClient["close"]; +} +type ConnectUsage = (options: ConnectOptions) => Promise<UsageClient>; + +export async function runUsageCommand( + options: UsageOptions, + selection: UsageSelection, + connect: ConnectUsage = connectToDaemon, +): Promise<CommandResult<UsageReportEntry>> { + const conflictingSelectors = selection.kind === "inspect" && options.agent !== undefined; + if (conflictingSelectors) { + throw { + code: "INVALID_OPTIONS", + message: + "--agent cannot be combined with usage inspect. Use usage ls --agent <id> for agent account usage, or usage inspect <report-id> for an exact report.", + }; + } + const client = await connect({ target: options.daemonTarget }); + try { + if (client.getLastServerInfoMessage()?.features?.usageSources !== true) { + throw { code: "UNSUPPORTED_HOST", message: "Update the host to use usage reports." }; + } + if (selection.kind === "list") { + const { reports } = await client.listUsageReports({ + agentId: options.agent, + forceRefresh: options.refresh, + }); + return { type: "list", data: reports, schema: usageSchema }; + } + // Report-ID queries only select known accounts. Discover before selecting, including on cold hosts. + const { reports } = await client.listUsageReports(); + let entry = reports.find((report) => report.id === selection.id); + if (entry && options.refresh) { + const refreshed = await client.listUsageReports({ + reportIds: [selection.id], + forceRefresh: true, + }); + entry = refreshed.reports.find((report) => report.id === selection.id); + } + if (!entry) { + throw { code: "REPORT_NOT_FOUND", message: `Unknown usage report: ${selection.id}` }; + } + return { type: "single", data: entry, schema: usageSchema }; + } finally { + await client.close().catch(() => {}); + } +} + +function addListOptions(command: Command): Command { + return addJsonAndDaemonHostOptions(command) + .option( + "--agent <id>", + "Show account quota associated with this agent (not agent token consumption)", + ) + .option("--refresh", "Refresh usage instead of using the host cache"); +} + +export function createUsageCommand(): Command { + const usage = addListOptions(new Command("usage").description("Show account usage reports")); + const list = (options: CommandOptions, _command: Command) => + runUsageCommand(options, { kind: "list" }); + usage.action(withOutput(list)); + addListOptions(usage.command("ls").description("List account quotas and balances")).action( + withOutput(list), + ); + addJsonAndDaemonHostOptions( + usage + .command("inspect") + .description("Show a complete account usage report") + .argument("<report-id>", "Exact opaque Report ID") + .option("--refresh", "Refresh the selected report instead of using the host cache"), + ).action( + withOutput((id: string, options: CommandOptions, _command: Command) => + runUsageCommand(options, { kind: "inspect", id }), + ), + ); + return usage; +} diff --git a/packages/cli/src/commands/usage/presentation.ts b/packages/cli/src/commands/usage/presentation.ts new file mode 100644 index 0000000000..ecc4aba405 --- /dev/null +++ b/packages/cli/src/commands/usage/presentation.ts @@ -0,0 +1,137 @@ +import type { + UsageReportEntry, + UsageReport, + UsageProblem, + ProviderUsageWindow, + ProviderUsageBalance, +} from "@getpaseo/protocol/messages"; +import { renderTable, type OutputSchema } from "../../output/index.js"; + +function value(input: string | number | null | undefined): string { + return input == null ? "-" : String(input); +} + +function percent(input: number | null | undefined): string { + return input == null ? "-" : `${input}%`; +} + +function problemText(problem: UsageProblem): string { + if (problem.kind === "no_quota") return problem.detail; + const remedy = problem.refreshedBy + ? `Run ${problem.refreshedBy} to refresh login.` + : "Sign in again."; + if (problem.kind === "expired") return `Login expired at ${problem.expiresAt}. ${remedy}`; + return `Login rejected (HTTP ${problem.status}). ${remedy}`; +} + +function failureText(report: UsageReport): string { + if (report.status === "error") return report.error; + if (report.status === "unavailable") return problemText(report.problem); + return "-"; +} + +function windowText(window: ProviderUsageWindow): string { + return `${window.label}: ${percent(window.usedPct)} used, ${percent(window.remainingPct)} remaining`; +} + +function balanceText(balance: ProviderUsageBalance): string { + return `${balance.label}: ${value(balance.used)} used, ${value(balance.remaining)} remaining, ${value(balance.limit)} limit (${balance.unit})`; +} + +function summary(entry: UsageReportEntry): string[] { + const { report } = entry; + if (report.status !== "available") { + const lines = [failureText(report)]; + for (const login of entry.loginErrors ?? []) + lines.push(`${login.harness}: ${failureText(login.report)}`); + return lines; + } + const lines = report.windows.map(windowText); + lines.push(...(report.balances ?? []).map(balanceText)); + return lines.length ? lines : ["-"]; +} + +interface SummaryRow { + id: string; + source: string; + account: string; + status: string; + usage: string; + fetched: string; +} + +const summarySchema: OutputSchema<SummaryRow> = { + idField: "id", + columns: [ + { header: "REPORT ID", field: "id" }, + { header: "SOURCE", field: "source" }, + { header: "ACCOUNT", field: "account" }, + { header: "STATUS", field: "status" }, + { header: "USAGE", field: "usage" }, + { header: "FETCHED", field: "fetched" }, + ], +}; + +function inspect(entry: UsageReportEntry): string { + const lines = [ + `Report ID: ${entry.id}`, + `Source: ${entry.sourceLabel}`, + `Source ID: ${entry.sourceId}`, + `Account: ${value(entry.account.label)}`, + `Status: ${entry.report.status}`, + `Fetched: ${value(entry.fetchedAt)}`, + ]; + const { report } = entry; + if (report.status === "available") { + lines.push(`Plan: ${value(report.planLabel)}`, "Windows:"); + if (!report.windows.length) lines.push(" -"); + for (const window of report.windows) { + lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`); + if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`); + if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`); + } + lines.push("Balances:"); + if (!report.balances?.length) lines.push(" -"); + for (const balance of report.balances ?? []) { + lines.push( + ` ${balanceText(balance)} [${balance.id}]`, + ` Resets: ${value(balance.resetsAt)}`, + ); + } + lines.push("Details:"); + if (!report.details?.length) lines.push(" -"); + for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`); + } else { + lines.push(`Reason: ${failureText(report)}`); + } + if (entry.loginErrors?.length) { + lines.push("Login failures:"); + for (const login of entry.loginErrors) + lines.push(` ${login.harness}: ${failureText(login.report)}`); + } + return lines.join("\n"); +} + +export const usageSchema: OutputSchema<UsageReportEntry> = { + idField: "id", + columns: [], + renderHuman(result, options) { + if (result.type === "single") return inspect(result.data); + if (!result.data.length) return "No usage reports returned"; + const rows: SummaryRow[] = []; + for (const entry of result.data) { + for (const [index, usage] of summary(entry).entries()) { + const first = index === 0; + rows.push({ + id: first ? entry.id : "", + source: first ? entry.sourceLabel : "", + account: first ? value(entry.account.label) : "", + status: first ? entry.report.status : "", + usage, + fetched: first ? value(entry.fetchedAt) : "", + }); + } + } + return renderTable<SummaryRow>({ type: "list", data: rows, schema: summarySchema }, options); + }, +}; diff --git a/packages/cli/src/commands/workspace/create.ts b/packages/cli/src/commands/workspace/create.ts index 5ce49278ef..04d9e10ef0 100644 --- a/packages/cli/src/commands/workspace/create.ts +++ b/packages/cli/src/commands/workspace/create.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { resolveCallerAgentId } from "../../utils/caller-agent.js"; import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; @@ -15,6 +16,7 @@ export interface WorkspaceCreateOptions extends CommandOptions { branch?: string; prNumber?: string; forge?: string; + background?: boolean; } interface WorktreeSourceBase { @@ -144,9 +146,12 @@ export async function runCreateCommand( }); try { + const callerAgentId = await resolveCallerAgentId(client); const payload = await client.createWorkspace({ source: buildWorkspaceSource(options), ...(options.title ? { title: options.title } : {}), + ...(options.background !== undefined ? { background: options.background } : {}), + ...(callerAgentId ? { callerAgentId } : {}), }); if (!payload.workspace) { throw new Error(payload.error ?? "Workspace creation failed"); diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts index 19d640d252..7d15ef26f3 100644 --- a/packages/cli/src/commands/workspace/index.ts +++ b/packages/cli/src/commands/workspace/index.ts @@ -34,12 +34,17 @@ export function createWorkspaceCommand(): Command { .option("--base <ref>", "Base ref (--mode branch-off)") .option("--branch <name>", "Existing branch (--mode checkout-branch)") .option("--pr-number <n>", "Pull request or change request number (--mode checkout-pr)") - .option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)"), + .option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)") + .option("--background", "Create a background workspace") + .option("--no-background", "Create a visible workspace instead of inheriting background"), ).action(withOutput(runCreateCommand)); - addJsonAndDaemonHostOptions(workspace.command("ls").description("List active workspaces")).action( - withOutput(runLsCommand), - ); + addJsonAndDaemonHostOptions( + workspace + .command("ls") + .description("List active workspaces") + .option("--background", "Include background workspaces"), + ).action(withOutput(runLsCommand)); addJsonAndDaemonHostOptions( workspace diff --git a/packages/cli/src/commands/workspace/ls.ts b/packages/cli/src/commands/workspace/ls.ts index b9821e061a..680bb06e1c 100644 --- a/packages/cli/src/commands/workspace/ls.ts +++ b/packages/cli/src/commands/workspace/ls.ts @@ -1,10 +1,22 @@ import type { Command } from "commander"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, ListResult } from "../../output/index.js"; -import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; +import { + toWorkspaceRow, + workspaceSchema, + workspaceWithBackgroundSchema, + type WorkspaceRow, +} from "./shared.js"; + +export interface WorkspaceLsOptions { + host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; + /** --background: Include background workspaces */ + background?: boolean; +} export async function runLsCommand( - options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, + options: WorkspaceLsOptions, _command: Command, ): Promise<ListResult<WorkspaceRow>> { const host = getDaemonHost({ target: options.daemonTarget }); @@ -20,12 +32,17 @@ export async function runLsCommand( let cursor: string | undefined; do { const payload = await client.fetchWorkspaces({ + ...(options.background ? { filter: { includeBackground: true } } : {}), page: { limit: 200, ...(cursor ? { cursor } : {}) }, }); workspaces.push(...payload.entries.map(toWorkspaceRow)); cursor = payload.pageInfo.nextCursor ?? undefined; } while (cursor); - return { type: "list", data: workspaces, schema: workspaceSchema }; + return { + type: "list", + data: workspaces, + schema: options.background ? workspaceWithBackgroundSchema : workspaceSchema, + }; } finally { await client.close().catch(() => undefined); } diff --git a/packages/cli/src/commands/workspace/shared.ts b/packages/cli/src/commands/workspace/shared.ts index cd800c9f10..db047e57b3 100644 --- a/packages/cli/src/commands/workspace/shared.ts +++ b/packages/cli/src/commands/workspace/shared.ts @@ -7,6 +7,7 @@ export interface WorkspaceRow { name: string; isolation: "local" | "worktree"; cwd: string; + background: boolean; } export const workspaceSchema: OutputSchema<WorkspaceRow> = { @@ -20,6 +21,15 @@ export const workspaceSchema: OutputSchema<WorkspaceRow> = { ], }; +/** `--background` listings add a column so hidden workspaces stand out. */ +export const workspaceWithBackgroundSchema: OutputSchema<WorkspaceRow> = { + ...workspaceSchema, + columns: [ + ...workspaceSchema.columns, + { header: "BACKGROUND", field: (row) => (row.background ? "yes" : ""), width: 8 }, + ], +}; + export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): WorkspaceRow { return { workspaceId: workspace.id, @@ -27,5 +37,6 @@ export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): Workspace name: workspace.name, isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local", cwd: workspace.workspaceDirectory, + background: workspace.background === true, }; } diff --git a/packages/cli/src/commands/worktree/archive.ts b/packages/cli/src/commands/worktree/archive.ts index eaf3857322..5d2bc60f33 100644 --- a/packages/cli/src/commands/worktree/archive.ts +++ b/packages/cli/src/commands/worktree/archive.ts @@ -2,6 +2,7 @@ import path from "path"; import type { Command } from "commander"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { resolveRepositoryDirectory } from "./repository-directory.js"; import type { CommandOptions, SingleResult, @@ -31,6 +32,7 @@ export const archiveSchema: OutputSchema<WorktreeArchiveResult> = { export interface WorktreeArchiveOptions extends CommandOptions { host?: string; + cwd?: string; } export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>; @@ -49,6 +51,7 @@ export async function runArchiveCommandWithDeps( deps: { connectToDaemon: typeof connectToDaemon }, ): Promise<WorktreeArchiveCommandResult> { const host = getDaemonHost({ target: options.daemonTarget }); + const cwd = resolveRepositoryDirectory(options); // Validate arguments if (!nameArg || nameArg.trim().length === 0) { @@ -76,7 +79,7 @@ export async function runArchiveCommandWithDeps( try { // Get the list of worktrees first to resolve the name - const listResponse = await client.getPaseoWorktreeList({}); + const listResponse = await client.getPaseoWorktreeList({ cwd }); if (listResponse.error) { const error: CommandError = { diff --git a/packages/cli/src/commands/worktree/index.ts b/packages/cli/src/commands/worktree/index.ts index f35f001a38..37405e2630 100644 --- a/packages/cli/src/commands/worktree/index.ts +++ b/packages/cli/src/commands/worktree/index.ts @@ -9,7 +9,10 @@ export function createWorktreeCommand(): Command { const worktree = new Command("worktree").description("Manage Paseo-managed git worktrees"); addJsonAndDaemonHostOptions( - worktree.command("ls").description("List Paseo-managed git worktrees"), + worktree + .command("ls") + .description("List Paseo-managed git worktrees") + .option("--cwd <path>", "Repository directory (default: current)"), ).action(withOutput(runLsCommand)); addJsonAndDaemonHostOptions( @@ -31,7 +34,8 @@ export function createWorktreeCommand(): Command { worktree .command("archive") .description("Archive a worktree (removes worktree and associated branch)") - .argument("<name>", "Worktree name or branch name"), + .argument("<name>", "Worktree name or branch name") + .option("--cwd <path>", "Repository directory (default: current)"), ).action(withOutput(runArchiveCommand)); return worktree; diff --git a/packages/cli/src/commands/worktree/ls.ts b/packages/cli/src/commands/worktree/ls.ts index cd388b2b08..43940131ce 100644 --- a/packages/cli/src/commands/worktree/ls.ts +++ b/packages/cli/src/commands/worktree/ls.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { basename } from "node:path"; import { connectToDaemon } from "../../utils/client.js"; +import { resolveRepositoryDirectory } from "./repository-directory.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Worktree list item for display */ @@ -40,12 +41,14 @@ export type WorktreeLsResult = ListResult<WorktreeListItem>; export interface WorktreeLsOptions extends CommandOptions { host?: string; + cwd?: string; } export async function runLsCommand( options: WorktreeLsOptions, _command: Command, ): Promise<WorktreeLsResult> { + const cwd = resolveRepositoryDirectory(options); const client = await connectToDaemon({ target: options.daemonTarget }); try { @@ -53,7 +56,7 @@ export async function runLsCommand( const agents = agentsPayload.entries.map((entry) => entry.agent); // Get worktree list from daemon - const response = await client.getPaseoWorktreeList({}); + const response = await client.getPaseoWorktreeList({ cwd }); await client.close(); diff --git a/packages/cli/src/commands/worktree/repository-directory.ts b/packages/cli/src/commands/worktree/repository-directory.ts new file mode 100644 index 0000000000..3c241703c0 --- /dev/null +++ b/packages/cli/src/commands/worktree/repository-directory.ts @@ -0,0 +1,13 @@ +import { resolve } from "node:path"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; + +export function resolveRepositoryDirectory(options: { + cwd?: string; + daemonTarget: DaemonTarget; +}): string { + const cwd = options.cwd ?? process.cwd(); + if (options.daemonTarget.kind === "instance") { + return resolve(cwd); + } + return cwd; +} diff --git a/packages/cli/src/utils/caller-agent.ts b/packages/cli/src/utils/caller-agent.ts new file mode 100644 index 0000000000..3bc2ee6223 --- /dev/null +++ b/packages/cli/src/utils/caller-agent.ts @@ -0,0 +1,21 @@ +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; + +interface CallerLookupClient { + fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>; +} + +// PASEO_AGENT_ID belongs to the daemon that launched this shell. Commands +// targeting another daemon (--host or --home) have no caller on that daemon. +export async function resolveCallerAgentId( + client: CallerLookupClient, + env: { PASEO_AGENT_ID?: string } = process.env, +): Promise<string | undefined> { + const agentId = env.PASEO_AGENT_ID?.trim(); + if (!agentId) return undefined; + const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => { + // A missing agent is a daemon answer; a lost or timed-out connection is not. + if (error instanceof DaemonConnectionError) throw error; + return null; + }); + return caller?.agent.id === agentId ? agentId : undefined; +} diff --git a/packages/cli/src/utils/timeline.ts b/packages/cli/src/utils/timeline.ts index 7e84757da0..90b67c9f8e 100644 --- a/packages/cli/src/utils/timeline.ts +++ b/packages/cli/src/utils/timeline.ts @@ -7,6 +7,7 @@ interface FetchProjectedTimelineItemsInput { client: DaemonClient; agentId: string; timeoutMs?: number; + sinceTimestampMs?: number; } export async function fetchProjectedTimelineItems( @@ -18,5 +19,10 @@ export async function fetchProjectedTimelineItems( projection: "projected", timeout: input.timeoutMs, }); - return timeline.entries.map((entry) => entry.item); + let entries = timeline.entries; + const sinceTimestampMs = input.sinceTimestampMs; + if (sinceTimestampMs !== undefined) { + entries = entries.filter((entry) => Date.parse(entry.timestamp) >= sinceTimestampMs); + } + return entries.map((entry) => entry.item); } diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index 5c56cadfbf..6948e4c008 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -53,8 +53,12 @@ try { console.log("Test 1: run --help shows options"); const result = await $`npx paseo run --help`.nothrow(); assert.strictEqual(result.exitCode, 0, "run --help should exit 0"); - assert(result.stdout.includes("-d"), "help should mention -d flag"); - assert(result.stdout.includes("--background"), "help should mention --background flag"); + assert(result.stdout.includes("--no-wait"), "help should mention --no-wait flag"); + assert( + !result.stdout.includes("--internal"), + "help should not mention removed --internal flag", + ); + assert(!result.stdout.includes("--background"), "help should hide legacy --background syntax"); assert(!result.stdout.includes("--detach"), "help should hide legacy --detach syntax"); assert(result.stdout.includes("--title"), "help should mention --title option"); assert(result.stdout.includes("--provider"), "help should mention --provider option"); @@ -178,18 +182,18 @@ try { console.log("✓ run --output-schema flag is accepted\n"); } - // Test 10: run --output-schema cannot be used with --background + // Test 10: run --output-schema cannot be used with --no-wait { - console.log("Test 10: run --output-schema cannot be used with --background"); + console.log("Test 10: run --output-schema cannot be used with --no-wait"); const result = - await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --background --output-schema ${schemaPath} "test prompt"`.nothrow(); - assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema"); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --no-wait --output-schema ${schemaPath} "test prompt"`.nothrow(); + assert.notStrictEqual(result.exitCode, 0, "should fail with --no-wait and --output-schema"); const output = result.stdout + result.stderr; assert( - output.includes("--output-schema cannot be used with --background"), + output.includes("--output-schema cannot be used with --no-wait"), "error should explain background incompatibility", ); - console.log("✓ run --output-schema cannot be used with --background\n"); + console.log("✓ run --output-schema cannot be used with --no-wait\n"); } // Test 11: -q (quiet) flag is accepted with run diff --git a/packages/cli/tests/14-worktree.test.ts b/packages/cli/tests/14-worktree.test.ts index e0c4d7f9b5..027cfbaa2b 100644 --- a/packages/cli/tests/14-worktree.test.ts +++ b/packages/cli/tests/14-worktree.test.ts @@ -21,7 +21,10 @@ import assert from "node:assert"; import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; -import { mkdtemp, rm } from "fs/promises"; +import { mkdtemp, rm, mkdir } from "fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; import { tmpdir } from "os"; import { join } from "path"; @@ -50,6 +53,7 @@ try { const result = await $`npx paseo worktree ls --help`.nothrow(); assert.strictEqual(result.exitCode, 0, "worktree ls --help should exit 0"); assert(result.stdout.includes("--host"), "help should mention --host option"); + assert(result.stdout.includes("--cwd"), "help should mention --cwd option"); console.log("✓ worktree ls --help shows options\n"); } @@ -86,6 +90,7 @@ try { const result = await $`npx paseo worktree archive --help`.nothrow(); assert.strictEqual(result.exitCode, 0, "worktree archive --help should exit 0"); assert(result.stdout.includes("--host"), "help should mention --host option"); + assert(result.stdout.includes("--cwd"), "help should mention --cwd option"); assert(result.stdout.includes("<name>"), "help should mention required name argument"); console.log("✓ worktree archive --help shows options\n"); } @@ -167,4 +172,66 @@ try { await rm(paseoHome, { recursive: true, force: true }); } +// Exercise repository selection through the real CLI and daemon. An empty +// request used to fail before Git could inspect even a valid repository. +const daemon = await startTestDaemon(); +const git = promisify(execFile); +try { + const repo = join(daemon.workDir, "repository"); + await mkdir(repo); + await git("git", ["init", repo]); + await git("git", [ + "-C", + repo, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "Initial commit", + ]); + + for (const args of [ + ["worktree", "ls", "--json"], + ["worktree", "ls", "--cwd", repo, "--json"], + ["worktree", "ls", "--cwd", "./repository", "--json"], + ["worktree", "ls", "--host", `127.0.0.1:${daemon.port}`, "--cwd", ".", "--json"], + ]) { + const explicit = args.includes("--cwd"); + const result = await runPaseoCli(daemon, args, { + cwd: explicit ? daemon.workDir : repo, + env: { FORCE_COLOR: undefined }, + }); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), []); + } + + for (const args of [ + ["worktree", "archive", "missing", "--json"], + ["worktree", "archive", "missing", "--cwd", repo, "--json"], + ["worktree", "archive", "missing", "--cwd", "./repository", "--json"], + [ + "worktree", + "archive", + "missing", + "--host", + `127.0.0.1:${daemon.port}`, + "--cwd", + ".", + "--json", + ], + ]) { + const result = await runPaseoCli(daemon, args, { + cwd: args.includes("--cwd") ? daemon.workDir : repo, + env: { FORCE_COLOR: undefined }, + }); + assert.notStrictEqual(result.exitCode, 0); + assert.strictEqual(JSON.parse(result.stderr).error.code, "WORKTREE_NOT_FOUND", result.stderr); + } +} finally { + await daemon.stop(); +} + console.log("=== All worktree tests passed ==="); diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index bc1183de81..3a4f33031c 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -122,10 +122,15 @@ const EXPECTED_CLAUDE_MODELS = [ model: "Opus 4.6", descriptionFragment: "Most capable", }, + { + id: "claude-haiku-5-5", + model: "Haiku 5.5", + descriptionFragment: "Fastest", + }, { id: "claude-haiku-4-5", model: "Haiku 4.5", - descriptionFragment: "Fastest", + descriptionFragment: "Previous release", }, ] as const; @@ -359,10 +364,6 @@ try { ids.every((id) => id.startsWith("gpt-")), "all codex model IDs should be from the gpt family", ); - assert( - ids.some((id) => id.includes("codex")), - "codex model list should include at least one codex-optimized model", - ); assert( data.every((m) => m.model && m.id && m.description), "every codex model should have model, id, and description fields", @@ -435,8 +436,8 @@ try { "--quiet should print the same ordered model IDs returned by --json", ); assert( - claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"), - "captured --json output should include the current Claude everyday model id", + claudeModelsFromJson.some((m) => m.id === "claude-haiku-5-5"), + "captured --json output should include the current Claude quick-answer model id", ); console.log("✓ provider models --quiet outputs model IDs only\n"); } diff --git a/packages/cli/tests/38-workspace-rename.test.ts b/packages/cli/tests/38-workspace-rename.test.ts index 50d3b4ce82..9fed12aa4c 100644 --- a/packages/cli/tests/38-workspace-rename.test.ts +++ b/packages/cli/tests/38-workspace-rename.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import { basename } from "node:path"; +import { createPaseoClient } from "@getpaseo/client"; import { createE2ETestContext } from "./helpers/test-daemon.ts"; console.log("=== Workspace Rename Command ===\n"); @@ -51,8 +52,61 @@ try { )?.name, basename(ctx.workDir), ); + + await verifyWorkspaceCallerContext(); } finally { await ctx.stop(); } console.log("=== Workspace Rename Command Tests Passed ==="); + +async function verifyWorkspaceCallerContext() { + const sdk = createPaseoClient({ url: `${ctx.wsUrl}/ws`, reconnect: { enabled: false } }); + try { + await sdk.connect(); + const parentWorkspace = await sdk.workspaces.create({ + source: { kind: "directory", path: ctx.workDir }, + background: true, + }); + // Creation without a prompt stays idle; this visibility test runs no provider turn. + const parent = await parentWorkspace.agents.create({ config: { provider: "claude/haiku" } }); + const cases = [ + { caller: parent.id, background: undefined, expected: true }, + { caller: parent.id, background: false, expected: false }, + { caller: parent.id, background: true, expected: true }, + { caller: "foreign-or-missing-agent", background: undefined, expected: false }, + { caller: "foreign-or-missing-agent", background: true, expected: true }, + { caller: "foreign-or-missing-agent", background: false, expected: false }, + { caller: "", background: undefined, expected: false }, + { caller: "", background: true, expected: true }, + ]; + for (const entry of cases) { + const result = await ctx.paseo( + [ + "workspace", + "create", + "--host", + `127.0.0.1:${ctx.port}`, + "--isolation", + "local", + "--path", + ctx.workDir, + ...(entry.background === undefined + ? [] + : [entry.background ? "--background" : "--no-background"]), + "--json", + ], + { env: { PASEO_AGENT_ID: entry.caller } }, + ); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.strictEqual( + JSON.parse(result.stdout).background, + entry.expected, + JSON.stringify(entry), + ); + } + console.log("✓ workspace create caller inheritance, foreign/blank defaults, and overrides"); + } finally { + await sdk.close(); + } +} diff --git a/packages/cli/tests/44-usage.test.ts b/packages/cli/tests/44-usage.test.ts new file mode 100644 index 0000000000..2db20e2824 --- /dev/null +++ b/packages/cli/tests/44-usage.test.ts @@ -0,0 +1,152 @@ +#!/usr/bin/env npx tsx +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; +import { connectToDaemon } from "../src/utils/client.ts"; +import { resolveCliVersion } from "../src/version.ts"; + +const context = await createE2ETestContext(); +try { + const directory = join(context.workDir, "usage-plugin"); + await mkdir(directory); + await writeFile( + join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "cli-usage", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile( + join(directory, "index.server.ts"), + ` +import { z } from 'zod'; +export default function contribute(server) { + const counts = new Map(); + server.registerUsageSource({ + id: 'cli-usage', label: 'CLI usage fixture', input: z.object({ key: z.string() }), + discover: async () => [ + { key: 'work.account', label: 'Work', input: { key: 'work' } }, + { key: 'personal', input: { key: 'personal' } }, + { key: 'broken', harness: 'Harness A', input: { key: 'rejected' } }, + { key: 'broken', harness: 'Harness B', input: { key: 'expired' } }, + { key: 'error', input: { key: 'error' } }, + ], + fetch: async ({key}) => { + counts.set(key, (counts.get(key) ?? 0) + 1); + if (key === 'rejected') return {status: 'unavailable', problem: {kind: 'rejected', status: 401, refreshedBy: 'codex login'}}; + if (key === 'expired') return {status: 'unavailable', problem: {kind: 'expired', expiresAt: '2026-10-08T00:00:00Z', refreshedBy: 'claude'}}; + if (key === 'error') throw new Error('Quota service failed'); + return { status: 'available', planLabel: 'Pro', windows: [ + { id: 'session', label: 'Session', usedPct: counts.get(key), remainingPct: 99, resetsAt: '2026-10-09T17:00:00Z', runsOutAt: '2026-10-09T16:00:00Z', shortfallPct: 4.25 }, + { id: 'weekly', label: 'Weekly', usedPct: null, remainingPct: 0 }, + ], balances: [ + { id: 'credits', label: 'Credits', remaining: 2.5, unit: 'credits' }, + { id: 'tokens', label: 'Tokens', used: 0, limit: 100, unit: 'tokens' }, + ], details: [{ id: 'tier', label: 'Tier', value: 'Enterprise' }] }; + }, + }); + return () => {}; +}`, + ); + const client = await connectToDaemon({ target: { kind: "instance", home: context.paseoHome } }); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory, "cli-usage"); + } finally { + await client.close(); + } + async function ok(args: string[]) { + const result = await context.paseo(args); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stderr, ""); + return result.stdout; + } + // The first usage request is inspect: no earlier usage discovery has populated known IDs. + const cold = JSON.parse(await ok(["usage", "inspect", "cli-usage:work.account", "--json"])); + assert.equal(cold.id, "cli-usage:work.account"); + assert.equal(cold.report.windows[0].usedPct, 1); + for (const args of [ + ["usage", "--agent", "agent-A", "inspect", cold.id, "--json"], + ["usage", "inspect", cold.id, "--agent", "agent-A", "--json"], + ]) { + const conflicting = await context.paseo(args); + assert.equal(conflicting.exitCode, 1, `Expected selector rejection: ${args.join(" ")}`); + assert.equal(conflicting.stdout, ""); + assert.deepEqual(JSON.parse(conflicting.stderr).error, { + code: "INVALID_OPTIONS", + message: + "--agent cannot be combined with usage inspect. Use usage ls --agent <id> for agent account usage, or usage inspect <report-id> for an exact report.", + }); + } + + const list = JSON.parse(await ok(["usage", "ls", "--json"])); + const defaultList = JSON.parse(await ok(["usage", "--json"])); + assert.deepEqual(defaultList, list); + assert.deepEqual(list.map((entry: { id: string }) => entry.id).sort(), [ + "cli-usage:broken", + "cli-usage:error", + "cli-usage:personal", + "cli-usage:work.account", + ]); + const work = list.find((entry: { id: string }) => entry.id === cold.id); + assert.deepEqual(work, cold); + const table = await ok(["usage", "ls", "--no-color"]); + for (const text of [ + "REPORT ID", + "Credits: - used, 2.5 remaining, - limit (credits)", + "Tokens: 0 used, - remaining, 100 limit (tokens)", + "Weekly: - used, 0% remaining", + "Harness A: Login rejected (HTTP 401). Run codex login", + "Harness B: Login expired at", + "Quota service failed", + ]) + assert.ok(table.includes(text), text); + const quiet = await ok(["usage", "ls", "--quiet"]); + assert.deepEqual( + quiet.trim().split("\n").sort(), + list.map((entry: { id: string }) => entry.id).sort(), + ); + assert.ok(!(await ok(["usage", "ls", "--no-headers"])).includes("REPORT ID")); + const detail = await ok(["usage", "inspect", cold.id]); + for (const text of [ + "Plan: Pro", + "Resets: 2026-10-09T17:00:00Z", + "Runs out: 2026-10-09T16:00:00Z", + "Shortfall: 4.25%", + "Tier: Enterprise", + ]) + assert.ok(detail.includes(text), text); + const refreshed = JSON.parse(await ok(["usage", "inspect", cold.id, "--refresh", "--json"])); + assert.equal(refreshed.report.windows[0].usedPct, 2); + const personal = JSON.parse(await ok(["usage", "inspect", "cli-usage:personal", "--json"])); + assert.equal(personal.report.windows[0].usedPct, 1); + const allRefreshed = JSON.parse(await ok(["usage", "ls", "--refresh", "--json"])); + assert.equal( + allRefreshed.find((entry: { id: string }) => entry.id === cold.id).report.windows[0].usedPct, + 3, + ); + for (const id of ["cli-usage:work", "missing"]) { + const unknown = await context.paseo(["usage", "inspect", id, "--json"]); + assert.equal(unknown.exitCode, 1); + assert.equal(unknown.stdout, ""); + assert.equal(JSON.parse(unknown.stderr).error.code, "REPORT_NOT_FOUND"); + } + const agentFailure = await context.paseo(["usage", "ls", "--agent", "missing-agent", "--json"]); + assert.equal(agentFailure.exitCode, 1); + assert.equal(agentFailure.stdout, ""); + assert.match(JSON.parse(agentFailure.stderr).error.message, /Unknown agent/); + const remove = await context.paseo(["plugin", "remove", "cli-usage", "--json"]); + assert.equal(remove.exitCode, 0, remove.stderr); + assert.equal((await ok(["usage", "ls", "--json"])).trim(), "[]"); + assert.equal((await ok(["usage"])).trim(), "No usage reports returned"); + const connection = await context.paseo(["usage", "ls", "--host", "127.0.0.1:1", "--json"]); + assert.equal(connection.exitCode, 1); + assert.equal(connection.stdout, ""); + assert.equal(JSON.parse(connection.stderr).error.code, "DAEMON_UNREACHABLE"); + console.log( + "Usage CLI: cold inspect, default/ls, complete JSON, mixed statuses, all windows/balances, inspect details, refresh, IDs, errors and empty discovery passed.", + ); +} finally { + await context.stop(); +} diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index ce509af8e1..3e71b6b608 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -8,11 +8,13 @@ import { } from "../../../../scripts/test-support/npm-registry.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { once } from "node:events"; +import { WebSocket, WebSocketServer } from "ws"; import { connectToDaemon } from "../../src/utils/client.ts"; import { createE2ETestContext } from "../helpers/test-daemon.ts"; @@ -45,6 +47,81 @@ async function main(): Promise<void> { ); await writeFile(path.join(directory, "index.server.ts"), pluginSource); + // Observe real CLI requests while forwarding them to the isolated daemon. + const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const sources: string[] = []; + proxy.on("connection", (socket, request) => { + const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, { + headers: { authorization: request.headers.authorization ?? "" }, + }); + const ready = once(upstream, "open"); + socket.on("message", async (data, isBinary) => { + const message = JSON.parse(data.toString()); + if ( + message.type === "session" && + message.message.type === "plugin.source.install.request" + ) { + sources.push(message.message.source); + } + await ready; + upstream.send(data, { binary: isBinary }); + }); + upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); + socket.on("close", () => upstream.close()); + upstream.on("close", () => socket.close()); + }); + await once(proxy, "listening"); + const address = proxy.address(); + assert.ok(address && typeof address !== "string"); + const sibling = path.join(context.workDir, "x"); + const caller = path.join(context.workDir, "caller"); + await mkdir(caller); + await mkdir(path.join(sibling, "sub"), { recursive: true }); + for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) { + await writeFile( + path.join(target, "paseo-plugin.json"), + JSON.stringify({ + id: "relative-cli", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile(path.join(target, "index.server.ts"), pluginSource); + } + assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd. + try { + for (const [source, expectedSource, expectedDirectory, extraArgs] of [ + [".", caller, caller, []], + ["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []], + ["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]], + ["~", context.paseoHome, context.paseoHome, []], + ["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []], + ] as const) { + sources.length = 0; + const result = await context.paseo( + ["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [expectedSource]); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).path, expectedDirectory); + const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]); + assert.equal(removed.exitCode, 0, removed.stderr); + } + // An absolute path belongs to the daemon's platform, which can differ from the CLI's. + const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`; + sources.length = 0; + const missing = await context.paseo( + ["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [windowsSource]); + assert.equal(missing.exitCode, 1); + assert.match(missing.stderr, /Plugin directory does not exist/); + } finally { + for (const socket of proxy.clients) socket.close(); + await promisify(proxy.close.bind(proxy))(); + } + const install = await context.paseo(["plugin", "install", directory, "--json"]); assert.equal(install.exitCode, 0, install.stderr); assert.equal(JSON.parse(install.stdout).id, "cli-e2e"); diff --git a/packages/client/package.json b/packages/client/package.json index 05e4b208d4..937190e0d0 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.11.0-beta.5", + "version": "0.11.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "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": { diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 1bc175bf1b..e25779a7bb 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -1,3 +1,4 @@ +import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { createClientChannel, type EncryptedChannel, @@ -8,7 +9,11 @@ import type { DaemonTransportFactory, TransportLogger, } from "./daemon-client-transport-types.js"; -import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js"; +import { + extractRelayMessage, + normalizeTransportPayload, + messageTooLargeError, +} from "./daemon-client-transport-utils.js"; type OpenHandler = () => void; type CloseHandler = (event?: unknown) => void; @@ -137,7 +142,13 @@ export function createEncryptedTransport( if (!channel) { throw new Error("Encrypted channel not ready"); } - void channel.send(normalizeTransportPayload(data)).catch((error) => { + const payload = normalizeTransportPayload(data); + // Reject synchronously so the requesting operation fails without closing the transport. + const wireBytes = channel.outboundWireByteLength(payload); + if (wireBytes > MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) { + throw messageTooLargeError(wireBytes, MAX_RELAY_WEBSOCKET_MESSAGE_BYTES); + } + void channel.send(payload).catch((error) => { emitError(error); }); }, diff --git a/packages/client/src/daemon-client-transport-utils.ts b/packages/client/src/daemon-client-transport-utils.ts index 3b39cf5114..cb767c1d87 100644 --- a/packages/client/src/daemon-client-transport-utils.ts +++ b/packages/client/src/daemon-client-transport-utils.ts @@ -133,3 +133,10 @@ export function encodeUtf8String(value: string): Uint8Array { } return out; } + +export function messageTooLargeError(size: number, limit: number): Error { + const toMiB = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1); + return new Error( + `Message is too large to send (${toMiB(size)} MiB). The limit is ${toMiB(limit)} MiB, so send fewer or smaller attachments.`, + ); +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index d7ab645241..c02e1720cb 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2801,6 +2801,102 @@ test("readFile drops an old daemon's over-budget binary chunks and reports the r await expect(responsePromise).rejects.toThrow("File is too large to display"); }); +async function connectFileClient(clientId: string) { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId, + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + return { mock, client }; +} + +function sendBinaryFile( + mock: ReturnType<typeof createMockTransport>, + requestId: string, + announcedSize: number, + chunks: string[], +) { + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: "application/octet-stream", + size: announcedSize, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + }), + ); + for (const chunk of chunks) { + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: new TextEncoder().encode(chunk), + }), + ); + } +} + +test("readFile with timeout 0 outlives the default request timeout", async () => { + useHeartbeatClock(); + const { mock, client } = await connectFileClient("clsk_file_no_timeout"); + + const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0); + await vi.advanceTimersByTimeAsync(60_001); + + sendBinaryFile(mock, "req-slow", 5, ["hello"]); + mock.triggerMessage( + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }), + ); + + const result = await responsePromise; + expect(result.size).toBe(5); + expect(new TextDecoder().decode(result.bytes)).toBe("hello"); +}); + +test("readFile rejects a binary transfer that ends before the announced size", async () => { + const { mock, client } = await connectFileClient("clsk_file_short"); + + const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short"); + sendBinaryFile(mock, "req-short", 10, ["hello"]); + mock.triggerMessage( + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }), + ); + + await expect(responsePromise).rejects.toThrow( + "File transfer incomplete: expected 10 bytes, received 5.", + ); +}); + +test("readFile rejects a binary transfer that exceeds the announced size", async () => { + const { mock, client } = await connectFileClient("clsk_file_long"); + + const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long"); + const settled = responsePromise.then( + () => null, + (error: unknown) => error, + ); + sendBinaryFile(mock, "req-long", 4, ["hello!"]); + + expect(() => + mock.triggerMessage( + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }), + ), + ).not.toThrow(); + expect(await settled).toMatchObject({ + message: "File transfer incomplete: expected 4 bytes, received 6.", + }); +}); + test("uploadFile sends metadata request and file bytes as binary chunks", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -3100,6 +3196,162 @@ test("sends worktree target and autoArchive in create_agent_request", async () = await expect(createPromise).rejects.toThrow("worktree auto archive sentinel"); }); +test("sends internal in create_agent_request when the host advertises internal agents", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ features: { backgroundWorkspaces: true } }); + await connectPromise; + + const createPromise = client.createAgent({ + provider: "codex", + cwd: "/tmp/project", + background: true, + }); + + expect(mock.sent).toHaveLength(1); + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual( + expect.objectContaining({ + type: "create_agent_request", + config: { provider: "codex", cwd: "/tmp/project" }, + background: true, + }), + ); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.requestId, + error: "internal agent sentinel", + }, + }), + ); + + await expect(createPromise).rejects.toThrow("internal agent sentinel"); +}); + +test("rejects an internal create before sending when the host predates internal agents", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ features: {} }); + await connectPromise; + + await expect( + client.createAgent({ provider: "codex", cwd: "/tmp/project", background: true }), + ).rejects.toThrow("Update the host to use background workspaces."); + expect(mock.sent).toHaveLength(0); +}); + +test("sends internal workspace creation and internal listings only to hosts that support them", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + // workspaceMultiplicity keeps the fetch on the real workspace directory RPC. + mock.triggerOpen({ + features: { workspaceMultiplicity: true, backgroundWorkspaces: true }, + }); + await connectPromise; + + void client + .createWorkspace({ source: { kind: "directory", path: "/tmp/project" }, background: true }) + .catch(() => undefined); + void client.fetchWorkspaces({ filter: { includeBackground: true } }).catch(() => undefined); + void client.fetchAgents({ filter: { includeBackground: true } }).catch(() => undefined); + + expect(mock.sent.map((frame) => parseSentFrame(frame))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "workspace.create.request", background: true }), + expect.objectContaining({ + type: "fetch_workspaces_request", + filter: { includeBackground: true }, + }), + expect.objectContaining({ + type: "fetch_agents_request", + filter: { includeBackground: true }, + }), + ]), + ); +}); + +test("rejects internal workspace creation and internal listings before sending on older hosts", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ features: {} }); + await connectPromise; + + await expect( + client.createWorkspace({ + source: { kind: "directory", path: "/tmp/project" }, + background: true, + }), + ).rejects.toThrow("Update the host to use background workspaces."); + await expect(client.fetchWorkspaces({ filter: { includeBackground: true } })).rejects.toThrow( + "Update the host to use background workspaces.", + ); + expect(mock.sent).toHaveLength(0); + + // Agent listings gate on the older internalAgents feature. + const olderMock = createMockTransport(); + const olderClient = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => olderMock.transport, + }); + clients.push(olderClient); + const olderConnect = olderClient.connect(); + olderMock.triggerOpen({ features: {} }); + await olderConnect; + await expect(olderClient.fetchAgents({ filter: { includeBackground: true } })).rejects.toThrow( + "Update the host to use background workspaces.", + ); + expect(olderMock.sent).toHaveLength(0); +}); + test("sends structured attachments with create_agent_request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -7325,3 +7577,75 @@ test("usage request timeout detaches its update listener", async () => { vi.useRealTimers(); } }); + +test.each([false, true])( + "agent provenance requires host support=%s while human sends remain compatible", + async (supported) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "provenance", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: { agentMessageProvenance: supported } }); + await connecting; + if (!supported) { + await expect( + client.sendAgentMessage("recipient", "review", { sourceAgentId: "server::sender" }), + ).rejects.toThrow("Update the Paseo host"); + expect(transport.sent).toHaveLength(0); + } + const send = client.sendAgentMessage("recipient", "review", { + messageId: "delivery", + ...(supported ? { sourceAgentId: "server::sender" } : {}), + }); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "send_agent_message_request", + text: "review", + messageId: "delivery", + }); + expect(request.sourceAgentId).toBe(supported ? "server::sender" : undefined); + transport.triggerMessage( + wrapSessionMessage({ + type: "send_agent_message_response", + payload: { + requestId: request.requestId, + agentId: "recipient", + accepted: true, + error: null, + }, + }), + ); + await send; + }, +); + +test("measures an outbound message in UTF-8 bytes against the daemon's message limit", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "message_limit_unit_test", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + const mib = 1024 * 1024; + + // "é" is one UTF-16 unit but two UTF-8 bytes, so this text is ~98 MiB on the wire. + void client.sendMessage("agent-1", "é".repeat(49 * mib)).catch(() => undefined); + expect(mock.sent).toHaveLength(1); + + // ~60M characters is under the 100 MiB limit; ~120 MiB of UTF-8 is over it. + await expect(client.sendMessage("agent-1", "é".repeat(60 * mib))).rejects.toThrow( + "Message is too large to send (120.0 MiB). The limit is 100.0 MiB", + ); + expect(mock.sent).toHaveLength(1); + expect(client.getConnectionState().status).toBe("connected"); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 858c61e5b3..1aa0d3e995 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,4 @@ +import { messageTooLargeError } from "./daemon-client-transport-utils.js"; import { legacyUsageIcon } from "./legacy-usage-icons.js"; import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; @@ -13,6 +14,7 @@ import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; +import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, @@ -424,6 +426,7 @@ export interface DaemonClientTrace { } export interface SendMessageOptions { + sourceAgentId?: string; messageId?: string; /** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */ activeTurnBehavior?: ActiveTurnBehavior; @@ -459,6 +462,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { git?: GitSetupOptions; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; + background?: CreateAgentRequestMessage["background"]; // COMPAT(createAgentWorktree): low-level old callers may still send the // create-agent worktree field. Added in v0.2.0; remove after 2027-01-17. worktreeName?: string; @@ -469,6 +473,9 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { export interface CreateWorkspaceRequestOptions { source: WorkspaceCreateRequest["source"]; title?: string; + /** Workspace discovery visibility; contents retain normal durability. */ + background?: boolean; + callerAgentId?: string; idempotencyKey?: string; workspaceId?: string; agent?: Omit< @@ -1127,6 +1134,19 @@ function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number return frame.byteLength; } +function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): void { + if (typeof frame !== "string") { + if (frame.byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) + throw messageTooLargeError(frame.byteLength, MAX_WEBSOCKET_MESSAGE_BYTES); + return; + } + // UTF-8 needs at most three bytes per UTF-16 unit, so only a string near the limit is measured. + if (frame.length * 3 <= MAX_WEBSOCKET_MESSAGE_BYTES) return; + const size = new TextEncoder().encode(frame).byteLength; + if (size > MAX_WEBSOCKET_MESSAGE_BYTES) + throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES); +} + function describeInboundTransportFrame( frame: unknown, rawBytes: Uint8Array | null, @@ -1195,6 +1215,25 @@ export function supportsUsageReports(features: ServerInfoStatusPayload["features return features?.usageSources === true || features?.providerUsageList === true; } +function buildFetchAgentsRequest( + options: FetchAgentsOptions | undefined, + requestId: string, +): SessionInboundMessage { + return SessionInboundMessageSchema.parse({ + type: "fetch_agents_request", + requestId, + ...(options?.scope ? { scope: options.scope } : {}), + ...(options?.filter ? { filter: options.filter } : {}), + ...(options?.sort ? { sort: options.sort } : {}), + ...(options?.page ? { page: options.page } : {}), + ...(options?.subscribe ? { subscribe: options.subscribe } : {}), + ...(options?.sync ? { sync: options.sync } : {}), + ...(options?.includeBackground !== undefined + ? { includeBackground: options.includeBackground } + : {}), + }); +} + export class DaemonClient { private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({ active: (message) => this.owned.owns(message), @@ -1743,6 +1782,8 @@ export class DaemonClient { if (!this.transport) { throw new DaemonConnectionError("Transport not connected"); } + // The daemon closes the connection on an oversized message, and creations resubmit on reconnect. + assertTransportFrameFits(frame); const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", { kind: typeof frame === "string" ? "text" : "binary", size: String(getTransportFrameSize(frame)), @@ -2341,6 +2382,8 @@ export class DaemonClient { observeAgents( options: Omit<FetchAgentsOptions, "subscribe"> = {}, ): OwnedSubscription<FetchAgentsPayload> { + if (options.includeBackground || options.filter?.includeBackground) + this.requireBackgroundWorkspaces(); const { signal, requestId, timeout, ...query } = options; return this.observe( "fetch_agents_response", @@ -2352,6 +2395,8 @@ export class DaemonClient { observeWorkspaces( options: Omit<FetchWorkspacesOptions, "subscribe"> = {}, ): OwnedSubscription<FetchWorkspacesPayload> { + if (options.includeBackground || options.filter?.includeBackground) + this.requireBackgroundWorkspaces(); const { signal, requestId, ...query } = options; return this.observe( "fetch_workspaces_response", @@ -2367,6 +2412,8 @@ export class DaemonClient { async fetchAgents( options?: FetchAgentsOptions, ): Promise<FetchAgentsPayload & { subscription?: OwnedSubscription<FetchAgentsPayload> }> { + if (options?.includeBackground || options?.filter?.includeBackground) + this.requireBackgroundWorkspaces(); if (options?.subscribe) { if (options.subscribe.subscriptionId !== undefined) throw new Error("Subscription IDs are assigned by the host"); @@ -2374,16 +2421,7 @@ export class DaemonClient { return { ...(await subscription.ready), subscription }; } const resolvedRequestId = this.createRequestId(options?.requestId); - const message = SessionInboundMessageSchema.parse({ - type: "fetch_agents_request", - requestId: resolvedRequestId, - ...(options?.scope ? { scope: options.scope } : {}), - ...(options?.filter ? { filter: options.filter } : {}), - ...(options?.sort ? { sort: options.sort } : {}), - ...(options?.page ? { page: options.page } : {}), - ...(options?.subscribe ? { subscribe: options.subscribe } : {}), - ...(options?.sync ? { sync: options.sync } : {}), - }); + const message = buildFetchAgentsRequest(options, resolvedRequestId); return this.sendRequest({ requestId: resolvedRequestId, message, @@ -2402,6 +2440,7 @@ export class DaemonClient { } async fetchAgentHistory(options?: FetchAgentHistoryOptions): Promise<FetchAgentHistoryPayload> { + if (options?.filter?.includeBackground) this.requireBackgroundWorkspaces(); const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_history_request", @@ -2465,6 +2504,8 @@ export class DaemonClient { ): Promise< FetchWorkspacesPayload & { subscription?: OwnedSubscription<FetchWorkspacesPayload> } > { + if (options?.includeBackground || options?.filter?.includeBackground) + this.requireBackgroundWorkspaces(); if (options?.subscribe) { if (options.subscribe.subscriptionId !== undefined) throw new Error("Subscription IDs are assigned by the host"); @@ -2474,12 +2515,8 @@ export class DaemonClient { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_workspaces_request", + ...options, requestId: resolvedRequestId, - ...(options?.filter ? { filter: options.filter } : {}), - ...(options?.sort ? { sort: options.sort } : {}), - ...(options?.page ? { page: options.page } : {}), - ...(options?.subscribe ? { subscribe: options.subscribe } : {}), - ...(options?.sync ? { sync: options.sync } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -2852,7 +2889,19 @@ export class DaemonClient { legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId), }); + // Background workspaces are gated once at the transport boundary. + private requireBackgroundWorkspaces(): void { + if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) { + throw new Error("Update the host to use background workspaces."); + } + } + + supportsBackgroundWorkspaces(): boolean { + return this.lastServerInfoMessage?.features?.backgroundWorkspaces === true; + } + async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> { + if (options.background !== undefined) this.requireBackgroundWorkspaces(); const result = await this.creations.createAgent({ ...options, config: resolveAgentConfig(options), @@ -2885,6 +2934,7 @@ export class DaemonClient { ...(options.git ? { git: options.git } : {}), ...(options.worktree ? { worktree: options.worktree } : {}), ...(options.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), + ...(options.background !== undefined ? { background: options.background } : {}), ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}), ...(options.labels && Object.keys(options.labels).length > 0 ? { labels: options.labels } @@ -3447,6 +3497,12 @@ export class DaemonClient { text: string, options?: SendMessageOptions, ): Promise<void> { + if ( + options?.sourceAgentId && + this.lastServerInfoMessage?.features?.agentMessageProvenance !== true + ) { + throw new Error("Update the Paseo host to send messages with agent provenance."); + } const requestId = this.createRequestId(); const messageId = options?.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ @@ -3454,6 +3510,7 @@ export class DaemonClient { requestId, agentId, text, + sourceAgentId: options?.sourceAgentId, ...(messageId ? { messageId } : {}), ...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}), ...(options?.images ? { images: options.images } : {}), @@ -4552,6 +4609,9 @@ export class DaemonClient { requestId?: string, ): Promise<WorkspaceCreatePayload> { const resolvedRequestId = this.createRequestId(requestId ?? input.requestId); + if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces(); + if (input.agent?.background !== undefined) + throw new Error("Configure background on the workspace, not its initial agent."); const result = await this.creations.createWorkspace({ ...input, requestId: resolvedRequestId, @@ -4583,6 +4643,8 @@ export class DaemonClient { ? { idempotencyKey: input.idempotencyKey } : {}), ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.background !== undefined ? { background: input.background } : {}), + ...(input.callerAgentId ? { callerAgentId: input.callerAgentId } : {}), ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext } : {}), @@ -4696,6 +4758,7 @@ export class DaemonClient { requestId?: string, acceptBinary = false, maxBytes?: number, + timeout?: number, ): Promise<FileExplorerPayload> { return this.sendCorrelatedSessionRequest({ requestId, @@ -4708,6 +4771,7 @@ export class DaemonClient { ...(maxBytes ? { maxBytes } : {}), }, responseType: "file_explorer_response", + timeout, }); } @@ -4726,11 +4790,17 @@ export class DaemonClient { return payload.directory; } + /** + * `timeout` bounds the whole transfer, not each frame. `0` disables the timer (as + * `workspace.create` does) for files that legitimately take longer than the default over a + * slow link; a dead connection still rejects on disconnect. + */ async readFile( cwd: string, path: string, requestId?: string, maxBytes?: number, + timeout?: number, ): Promise<FileReadResult> { const resolvedRequestId = this.createRequestId(requestId); this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes }); @@ -4742,6 +4812,7 @@ export class DaemonClient { resolvedRequestId, true, maxBytes, + timeout, ); if (payload.error) { throw new Error(payload.error); @@ -6478,6 +6549,18 @@ export class DaemonClient { return; } + const received = transfer.chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + if (received !== transfer.size) { + this.activeBinaryFileTransfers.delete(frame.requestId); + this.rejectWaitersForRequestId( + frame.requestId, + new Error( + `File transfer incomplete: expected ${transfer.size} bytes, received ${received}.`, + ), + ); + return; + } + const bytes = concatByteChunks(transfer.chunks, transfer.size); this.activeBinaryFileTransfers.delete(frame.requestId); this.completedBinaryFileReads.set(frame.requestId, { @@ -6932,6 +7015,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon attachments: _attachments, worktree: _worktree, autoArchive: _autoArchive, + background: _background, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 0f60e52673..ce6d4a5156 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -691,6 +691,61 @@ test("plugin-shaped PR workspace create and agent create use the existing daemon await client.close(); }); +test("agents.create forwards internal on the request, outside the agent config", async () => { + const { client, ws } = await connectClient({ + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + backgroundWorkspaces: true, + }); + + const createPromise = client.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/repo/sdk", + title: "Summary helper", + background: true, + autoArchive: true, + }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ + type: "create_agent_request", + config: { provider: "codex", model: "gpt-5.4", cwd: "/repo/sdk", title: "Summary helper" }, + background: true, + autoArchive: true, + }); + expect(request.config).not.toHaveProperty("internal"); + ws.message( + sessionMessage({ + type: "status", + payload: { + status: "agent_created", + requestId: request.requestId, + agentId: "agent_helper", + agent: createAgent({ id: "agent_helper" }), + }, + }), + ); + + const agent = await createPromise; + expect(agent.id).toBe("agent_helper"); + await client.close(); +}); + +test("agents.create with internal fails before sending on a host without internal agents", async () => { + const { client, ws } = await connectClient(); + const sentBefore = ws.sent.length; + + await expect( + client.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/repo/sdk", + background: true, + }), + ).rejects.toThrow("Update the host to use background workspaces."); + expect(ws.sent).toHaveLength(sentBefore); + await client.close(); +}); + test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => { const { client, ws } = await connectClient(); const createdAgent = createAgent(); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ae868c5974..058ec158ef 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -257,6 +257,8 @@ export interface PaseoAgentCreateOptions { git?: CreateAgentRequestMessage["git"]; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; + /** Visibility intent for a NEW workspace. Existing targets reject this option. */ + background?: CreateAgentRequestMessage["background"]; requestId?: string; labels?: Record<string, string>; } @@ -780,9 +782,12 @@ function createWorkspaceHandleFactory( const refresh = async (options?: { requestId?: string }) => { let cursor: string | undefined; let requestId = options?.requestId; + // A ref addresses one workspace by id, so a background one resolves too. + const filter = daemonClient.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}; do { const result = await daemonClient.fetchWorkspaces({ requestId, + filter, page: { limit: 200, ...(cursor ? { cursor } : {}) }, }); const match = result.entries.find((entry) => entry.id === id); @@ -1046,7 +1051,7 @@ async function waitForProvidersReady( try { await observation.ready; - signal.throwIfAborted(); + if (signal.aborted) throw new Error("Paseo API is disposed"); return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => { let settled = false; let requestId: string | null = null; diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index f657bfb2b6..5501ff8ba4 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -1574,9 +1574,8 @@ async function automationDrag(guest, sourceRef, targetRef) { } async function automationType(guest, refEntry, text) { - const send = await attachAutomationDebugger(guest); await automationClick(guest, refEntry); - await send("Input.insertText", { text }); + await guest.insertText(text); } function sendContainedEnter(guest) { diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 6647af7b31..fb0958aad1 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -272,7 +272,7 @@ async function waitForGuestActiveElement(client, browserId, elementId) { return false; } -async function createCallerAgent(daemonPort) { +async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) { const transport = new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), ); @@ -282,12 +282,11 @@ async function createCallerAgent(daemonPort) { name: "create_agent", args: { relationship: { kind: "detached" }, - workspace: { kind: "existing", workspaceId: workspaceIds[0] }, + workspace: { kind: "existing", workspaceId }, title: "Browser desktop browser E2E caller", provider: "mock/ten-second-stream", settings: { modeId: "load-test" }, initialPrompt: "Remain available while the browser bridge regression runs.", - background: true, }, }); const result = response.structuredContent; @@ -297,7 +296,7 @@ async function createCallerAgent(daemonPort) { ); assert(typeof result.agentId === "string", "create_agent returned no caller agent id"); assert( - result.workspaceId === workspaceIds[0], + result.workspaceId === workspaceId, `MCP caller attached to unexpected workspace ${result.workspaceId}`, ); return result.agentId; @@ -562,12 +561,66 @@ async function verifyHiddenBrowserScreenshots({ await expectAnimation(); } +async function verifyBackgroundTypingStaysInGuest({ page, client, serverId, browserId, agent }) { + await page + .getByTestId(`workspace-deck-entry-${serverId}:${agent.workspaceId}`) + .getByTestId(`workspace-tab-agent_${agent.agentId}`) + .click({ timeout: timeoutMs }); + const composer = page + .getByRole("textbox", { name: "Message agent..." }) + .filter({ visible: true }) + .first(); + const draft = "draft the user is typing"; + await composer.click(); + await composer.pressSequentially(draft); + assert( + await composer.evaluate((element) => element === document.activeElement), + "Composer did not keep focus while the user typed", + ); + + const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId }); + const ref = snapshot.snapshot.match(/textbox "Typing target" \[ref=(@e\d+)\]/)?.[1]; + assert(ref, `browser_snapshot did not expose the typing target: ${snapshot.snapshot}`); + await callBrowserTool(client, "browser_type", { browserId, ref, text: "agent text" }); + await callBrowserTool(client, "browser_keypress", { browserId, ref, key: "!" }); + + // The user keeps typing in the composer while the agent types into the tab's focused field. + await composer.click(); + await composer.press("End"); + assert( + await composer.evaluate((element) => element === document.activeElement), + "Composer did not take focus back from the browser tab", + ); + await callBrowserTool(client, "browser_type", { browserId, text: " more" }); + await callBrowserTool(client, "browser_keypress", { browserId, key: "?" }); + + const guestValue = JSON.parse( + ( + await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => document.getElementById('typing-target').value", + }) + ).resultJson, + ); + const composerValue = await composer.inputValue(); + assert( + composerValue === draft, + `browser_type and browser_keypress in a background tab changed the focused composer: ${JSON.stringify(composerValue)}`, + ); + assert( + guestValue === "agent text! more?", + `browser_type and browser_keypress did not reach the browser tab: ${JSON.stringify(guestValue)}`, + ); + await composer.fill(""); +} + async function runRegression({ page, client, serverId, targetUrl, callerAgentId, + typingAgent, artifactDir, inspectorPort, }) { @@ -857,6 +910,13 @@ async function runRegression({ text: "Clicked", timeoutMs: 5_000, }); + await verifyBackgroundTypingStaysInGuest({ + page, + client, + serverId, + browserId, + agent: typingAgent, + }); await originalWorkspaceRow.click(); await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click(); @@ -1157,6 +1217,10 @@ async function main() { await runAppearanceFontSizeRegression(page); const callerAgentId = await createCallerAgent(daemonPort); + const typingAgent = { + workspaceId: workspaceIds.at(-1), + agentId: await createCallerAgent(daemonPort, workspaceIds.at(-1)), + }; const transport = new StreamableHTTPClientTransport( new URL( `http://127.0.0.1:${daemonPort}/mcp/agents?callerAgentId=${encodeURIComponent(callerAgentId)}`, @@ -1170,6 +1234,7 @@ async function main() { targetUrl: target.url, inspectorPort, callerAgentId, + typingAgent, artifactDir, }); const pluginLinks = await checkPluginLinks(); diff --git a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts index 1d0b829f67..b7063b4565 100644 --- a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts +++ b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts @@ -1,3 +1,5 @@ +import { clickNewChat, gotoWorkspace } from "../../app/e2e/support/helpers/launcher"; +import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings"; import type { Page } from "@playwright/test"; import { test, expect } from "../../app/e2e/support/fixtures"; import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; @@ -213,3 +215,49 @@ for (const { platform, keys, label, firstCombo, secondCombo } of [ await expectInterruptShortcut(page, label); }); } + +async function rebindTabNumbers(page: Page) { + await captureShortcut(page, "Jump to tab", "workspace-tab-jump-index", "Control+1"); + await page.getByRole("button", { name: "Done", exact: true }).click(); +} + +async function expectTabNumberRange(page: Page) { + const row = page.getByText("Jump to tab", { exact: true }).locator(".."); + await expect(row.getByText("⌃1-9", { exact: true })).toBeVisible(); +} + +async function openTwoDraftTabs(page: Page, workspaceId: string) { + await installDesktopBridge(page); + await gotoWorkspace(page, workspaceId); + await clickNewChat(page); + await clickNewChat(page); + await expect( + page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }), + ).toHaveCount(2); + await openSettings(page); + await openSettingsSection(page, "shortcuts"); +} + +async function expectTabNumberNavigation(page: Page) { + const tabs = page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }); + await page.keyboard.press("Control+1"); + await expect(tabs.nth(0)).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("Control+2"); + await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true"); +} + +test("rebinding Jump to tab preserves the number group after reload", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "tab-number-rebind-" }); + await openTwoDraftTabs(page, workspace.workspaceId); + await rebindTabNumbers(page); + await clickSettingsBackToWorkspace(page); + await expectTabNumberNavigation(page); + await page.reload(); + await expectTabNumberNavigation(page); + await openSettings(page); + await openSettingsSection(page, "shortcuts"); + await expectTabNumberRange(page); +}); diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index b1b299494a..f1310e287f 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -848,6 +848,11 @@ async function assertSandboxState({ browser, page, expectedSandbox, stdout, stde function assertLinuxDesktopIdentity(appPath) { if (process.platform === "linux") { + assert.deepEqual( + fs.readFileSync(path.join(appPath, "resources", "icon.png")), + fs.readFileSync(path.join(__dirname, "..", "assets", "icon.png")), + "Packaged Linux windows must have the Paseo icon at their runtime lookup path", + ); const metadata = JSON.parse( extractFile(path.join(appPath, "resources", "app.asar"), "package.json").toString(), ); diff --git a/packages/desktop/e2e/window-startup.e2e.mjs b/packages/desktop/e2e/window-startup.e2e.mjs new file mode 100644 index 0000000000..e6654c09c2 --- /dev/null +++ b/packages/desktop/e2e/window-startup.e2e.mjs @@ -0,0 +1,48 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { _electron as electron, expect } from "playwright/test"; + +const root = await mkdtemp(path.join(tmpdir(), "paseo-window-startup-")); +const modulePath = fileURLToPath(new URL("../dist/window/show-when-ready.js", import.meta.url)); + +async function launchWindowWithoutHiddenFrames() { + const main = path.join(root, "main.cjs"); + await writeFile( + main, + `const { app, BrowserWindow } = require("electron"); + const { showWindowWhenReady } = require(${JSON.stringify(modulePath)}); + app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))}); + app.whenReady().then(async () => { + const win = global.mainWindow = new BrowserWindow({ show: false, paintWhenInitiallyHidden: false }); + showWindowWhenReady(win, { showOnLoad: true }); + await win.loadURL("data:text/html,<h1>Desktop ready</h1>"); + global.visibleAfterLoad = win.isVisible(); + });`, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key !== "ELECTRON_RUN_AS_NODE"), + ); + const displayArgs = process.platform === "linux" ? ["--ozone-platform=x11", "--disable-gpu"] : []; + return electron.launch({ + args: ["--no-sandbox", ...displayArgs, main], + env, + }); +} + +async function expectLoadedDesktopIsShown(desktop) { + await expect.poll(() => desktop.evaluate(() => global.visibleAfterLoad)).toBe(true); + const page = await desktop.firstWindow(); + await expect(page.getByRole("heading", { name: "Desktop ready" })).toBeVisible(); +} + +let desktop; +try { + desktop = await launchWindowWithoutHiddenFrames(); + await expectLoadedDesktopIsShown(desktop); + console.log("Loaded desktop is visible without hidden compositor frames"); +} finally { + await desktop?.close(); + await rm(root, { recursive: true, force: true }); +} diff --git a/packages/desktop/e2e/workspace-restart.spec.ts b/packages/desktop/e2e/workspace-restart.spec.ts index 4ee1655242..67bc8408ea 100644 --- a/packages/desktop/e2e/workspace-restart.spec.ts +++ b/packages/desktop/e2e/workspace-restart.spec.ts @@ -127,7 +127,7 @@ function inspectChatDragExclusions(scroll: Element) { const headerRect = header.getBoundingClientRect(); const headerY = headerRect.top + headerRect.height / 2; const content = scroll.firstElementChild; - const focusScope = scroll.closest("[tabindex]"); + const focusScope = scroll.closest("[data-window-content]")?.parentElement?.closest("[tabindex]"); if (!content || !focusScope) throw new Error("Expected chat content and its focus scope"); const contentRect = content.getBoundingClientRect(); const chatX = (contentRect.left + contentRect.right) / 2; diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 6ab5a9b691..8634d1f058 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -65,6 +65,8 @@ linux: extraResources: - from: bin/paseo to: bin/paseo + - from: assets/icon.png + to: icon.png target: - AppImage - deb diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9819f719c4..e6e2dbd567 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.11.0-beta.5", + "version": "0.11.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", @@ -25,7 +25,8 @@ "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", "test": "install-electron && vitest run --exclude \"e2e/**\"", "typecheck": "tsgo --noEmit -p tsconfig.json", - "test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs" + "test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs", + "test:e2e:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs" }, "dependencies": { "@getpaseo/cli": "*", diff --git a/packages/desktop/src/diagnostics/console-streams.test.ts b/packages/desktop/src/diagnostics/console-streams.test.ts new file mode 100644 index 0000000000..14ad337b7e --- /dev/null +++ b/packages/desktop/src/diagnostics/console-streams.test.ts @@ -0,0 +1,88 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const modulePath = fileURLToPath(new URL("./console-streams.ts", import.meta.url)); + +type Output = "stdout" | "stderr"; + +async function writeToConsole(input: { closed?: Output; invalidStdout?: boolean } = {}) { + const child = spawn( + process.execPath, + [ + "--import", + "tsx", + "-e", + ` + const { protectConsoleStreams } = require(${JSON.stringify(modulePath)}); + protectConsoleStreams(); + process.send("ready"); + process.once("message", () => { + if (${JSON.stringify(input.invalidStdout)}) require("node:fs").closeSync(1); + console.info("desktop info"); + console.warn("desktop warning"); + setImmediate(() => { process.send("alive"); process.disconnect(); }); + }); + `, + ], + { + cwd: fileURLToPath(new URL(".", import.meta.url)), + stdio: ["ignore", "pipe", "pipe", "ipc"], + env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "FORCE_COLOR")), + }, + ); + let stdout = ""; + let stderr = ""; + let alive = false; + child.stdout.setEncoding("utf8").on("data", (text: string) => { + stdout += text; + }); + child.stderr.setEncoding("utf8").on("data", (text: string) => { + stderr += text; + }); + child.on("message", (message) => { + if (message === "ready") { + if (input.closed) child[input.closed].destroy(); + child.send("write"); + } + if (message === "alive") alive = true; + }); + const code = await new Promise<number | null>((resolve, reject) => { + child.once("close", resolve); + child.once("error", reject); + }); + return { code, stdout, stderr, alive }; +} + +describe("desktop console streams", () => { + for (const stream of ["stdout", "stderr"] as const) { + it(`keeps running when the launcher closes ${stream}`, async () => { + const result = await writeToConsole({ closed: stream }); + expect(result.code, result.stderr).toBe(0); + expect(result.alive).toBe(true); + expect(result[stream === "stdout" ? "stderr" : "stdout"]).toBe( + stream === "stdout" ? "desktop warning\n" : "desktop info\n", + ); + }); + } + + // Windows stdout uses a native handle rather than the POSIX fd closed here. + it.skipIf(process.platform === "win32")( + "does not suppress a console failure other than a closed pipe", + async () => { + const result = await writeToConsole({ invalidStdout: true }); + expect(result.code).toBe(1); + expect(result.alive).toBe(false); + expect(result.stderr).toContain("EBADF"); + }, + ); + + it("preserves logging while the console has readers", async () => { + expect(await writeToConsole()).toEqual({ + code: 0, + stdout: "desktop info\n", + stderr: "desktop warning\n", + alive: true, + }); + }); +}); diff --git a/packages/desktop/src/diagnostics/console-streams.ts b/packages/desktop/src/diagnostics/console-streams.ts new file mode 100644 index 0000000000..686505bb40 --- /dev/null +++ b/packages/desktop/src/diagnostics/console-streams.ts @@ -0,0 +1,11 @@ +function ignoreClosedConsole(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + +/** Keep desktop logging independent of the program that launched its console. */ +export function protectConsoleStreams(): void { + process.stdout.on("error", ignoreClosedConsole); + process.stderr.on("error", ignoreClosedConsole); +} diff --git a/packages/desktop/src/features/browser-automation/ipc.test.ts b/packages/desktop/src/features/browser-automation/ipc.test.ts index ee7143adc6..427e86e1b1 100644 --- a/packages/desktop/src/features/browser-automation/ipc.test.ts +++ b/packages/desktop/src/features/browser-automation/ipc.test.ts @@ -190,6 +190,8 @@ class FakeWebContents { this.inputEvents.push(event); } + public async insertText(_text: string): Promise<void> {} + public on(event: "console-message", listener: ConsoleMessageListener): void { expect(event).toBe("console-message"); this.consoleMessageListener = listener; diff --git a/packages/desktop/src/features/browser-automation/ipc.ts b/packages/desktop/src/features/browser-automation/ipc.ts index 42c38d2e2c..18e5b84544 100644 --- a/packages/desktop/src/features/browser-automation/ipc.ts +++ b/packages/desktop/src/features/browser-automation/ipc.ts @@ -111,6 +111,7 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter { getBackgroundThrottling(): boolean; setBackgroundThrottling(allowed: boolean): void; sendInputEvent(event: IsolatedKeyboardInputEvent): void; + insertText(text: string): Promise<void>; } export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents { @@ -143,6 +144,7 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon } }, sendInputEvent: (event) => contents.sendInputEvent(event), + insertText: (text) => contents.insertText(text), getConsoleMessages: () => consoleMessagesByContentsId.get(contentsId) ?? [], captureDialogs: (task) => dialogMonitor.capture(task), sendDebugCommand: (command: string, params?: Record<string, unknown>) => diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index dee624a3f7..6bc054c6a5 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -41,6 +41,7 @@ class FakeTab implements TabContents { public readonly actions: string[] = []; public readonly capturedViewports: AbortSignal[] = []; public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = []; + public readonly insertedText: string[] = []; public readonly inputEvents: IsolatedKeyboardInputEvent[] = []; private readonly captureStartWaiters: Array<() => void> = []; private readonly deferredCaptures: Array<(image: TabImage) => void> = []; @@ -231,6 +232,10 @@ class FakeTab implements TabContents { this.inputEvents.push(event); } + public async insertText(text: string): Promise<void> { + this.insertedText.push(text); + } + public waitForCaptureStart(count: number): Promise<void> { if (this.capturedViewports.length >= count) { return Promise.resolve(); @@ -1104,17 +1109,31 @@ describe("executeAutomationCommand", () => { ok: true, result: { command: "type", browserId: BROWSER_A, ref: "@e1", x: 40, y: 30 }, }); - expect(browser.tab.debugCommands.at(-1)).toEqual({ - command: "Input.insertText", - params: { text: "Ada" }, - }); - expect(browser.tab.debugCommands.slice(0, 3).map((entry) => entry.command)).toEqual([ + expect(browser.tab.insertedText).toEqual(["Ada"]); + expect(browser.tab.debugCommands.map((entry) => entry.command)).toEqual([ "Input.dispatchMouseEvent", "Input.dispatchMouseEvent", "Input.dispatchMouseEvent", ]); }); + test("type inserts text through the tab, not the window-wide debugger input", async () => { + const browser = new BrowserAutomationHarness(); + + const action = await browser.execute({ + command: "type", + args: { browserId: BROWSER_A, text: "Ada" }, + }); + + expect(action).toEqual({ + requestId: "req-type", + ok: true, + result: { command: "type", browserId: BROWSER_A }, + }); + expect(browser.tab.insertedText).toEqual(["Ada"]); + expect(browser.tab.debugCommands).toEqual([]); + }); + test("keypress dispatches a trusted key to the focused element when ref is omitted", async () => { const browser = new BrowserAutomationHarness(); diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index ca6247e543..fe07adecd3 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -17,7 +17,6 @@ import { dispatchTrustedHover, dispatchTrustedKey, dispatchTrustedScroll, - dispatchTrustedText, type ClickInputOptions, type IsolatedKeyboardInputEvent, } from "./trusted-input.js"; @@ -39,6 +38,9 @@ export interface TabContents { invalidate(): void; withFrameProduction<T>(capture: () => Promise<T>): Promise<T>; sendInputEvent(event: IsolatedKeyboardInputEvent): void; + // Commits text into this tab's focused element. CDP Input.insertText would + // commit into whichever element has focus in the Paseo window instead. + insertText(text: string): Promise<void>; getConsoleMessages?(): BrowserAutomationConsoleLogEntry[]; captureDialogs?<T>( task: () => Promise<T>, @@ -1076,7 +1078,7 @@ async function executeType( } await dispatchTrustedClick(cdpSender(target.contents), actionable.target.point); } - await dispatchTrustedText(cdpSender(target.contents), text); + await target.contents.insertText(text); return { requestId, ok: true, diff --git a/packages/desktop/src/features/browser-automation/trusted-input.ts b/packages/desktop/src/features/browser-automation/trusted-input.ts index 671c5dbda1..8473263a1c 100644 --- a/packages/desktop/src/features/browser-automation/trusted-input.ts +++ b/packages/desktop/src/features/browser-automation/trusted-input.ts @@ -154,13 +154,6 @@ export async function dispatchTrustedScroll( }); } -export async function dispatchTrustedText(send: CdpCommandSender, text: string): Promise<void> { - if (text.length === 0) { - return; - } - await send("Input.insertText", { text }); -} - export function dispatchTrustedKey(send: KeyboardInputSender, key: string): void { const keyCode = ELECTRON_KEY_CODE_ALIASES[key] ?? key; let character: string | null = null; diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 63ab83281a..072f96a26d 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -1,3 +1,6 @@ +import { protectConsoleStreams } from "./diagnostics/console-streams.js"; +protectConsoleStreams(); + process.emitWarning = (() => {}) as typeof process.emitWarning; import log from "electron-log/main"; @@ -43,6 +46,7 @@ import { buildStandardContextMenuItems, } from "./window/window-manager.js"; import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js"; +import { showWindowWhenReady } from "./window/show-when-ready.js"; import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js"; import { registerDialogHandlers } from "./features/dialogs.js"; import { @@ -773,8 +777,8 @@ async function createWindow( registerBrowserWebviewNavigationGuards(contents); }); - mainWindow.once("ready-to-show", () => { - mainWindow.show(); + showWindowWhenReady(mainWindow, { + showOnLoad: process.platform === "linux", }); if (!app.isPackaged) { diff --git a/packages/desktop/src/window/show-when-ready.test.ts b/packages/desktop/src/window/show-when-ready.test.ts new file mode 100644 index 0000000000..756ad24cc2 --- /dev/null +++ b/packages/desktop/src/window/show-when-ready.test.ts @@ -0,0 +1,57 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { showWindowWhenReady } from "./show-when-ready.js"; + +class Window extends EventEmitter { + webContents = new EventEmitter(); + shows = 0; + destroyed = false; + show() { + this.shows += 1; + } + isDestroyed() { + return this.destroyed; + } + close() { + this.destroyed = true; + this.emit("closed"); + } +} + +describe("showWindowWhenReady", () => { + it("shows a loaded Linux window even when no initial frame was painted", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: true }); + expect(win.shows).toBe(0); + win.webContents.emit("did-finish-load"); + expect(win.shows).toBe(1); + win.emit("ready-to-show"); + expect(win.shows).toBe(1); + }); + + it("shows on the first frame without showing again after load", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: true }); + win.emit("ready-to-show"); + win.webContents.emit("did-finish-load"); + expect(win.shows).toBe(1); + }); + + it("preserves waiting for the initial frame when show on load is disabled", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: false }); + win.webContents.emit("did-finish-load"); + expect(win.shows).toBe(0); + win.emit("ready-to-show"); + expect(win.shows).toBe(1); + }); + + it("keeps a closed window closed when readiness arrives later", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: true }); + win.close(); + win.webContents.emit("did-finish-load"); + win.emit("ready-to-show"); + expect(win.shows).toBe(0); + }); +}); diff --git a/packages/desktop/src/window/show-when-ready.ts b/packages/desktop/src/window/show-when-ready.ts new file mode 100644 index 0000000000..e49c8518af --- /dev/null +++ b/packages/desktop/src/window/show-when-ready.ts @@ -0,0 +1,25 @@ +interface ReadyToShowWindow { + once(event: "ready-to-show", listener: () => void): unknown; + webContents: { + once(event: "did-finish-load", listener: () => void): unknown; + }; + show(): void; + isDestroyed(): boolean; +} + +export function showWindowWhenReady( + win: ReadyToShowWindow, + options: { showOnLoad: boolean }, +): void { + let shown = false; + const show = () => { + if (shown || win.isDestroyed()) return; + shown = true; + win.show(); + }; + + win.once("ready-to-show", show); + // On Linux a hidden window can wait for compositor frames until it is shown. + // Loading the document is sufficient to reveal it without depending on a frame. + if (options.showOnLoad) win.webContents.once("did-finish-load", show); +} diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 934b386778..1e6bd41230 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.11.0-beta.5", + "version": "0.11.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index a2b5b5b81e..02e9e28daa 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.11.0-beta.5", + "version": "0.11.2", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts index 2349854d85..a943e6dd4a 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -144,6 +144,125 @@ const title = "Hello"; ); }); + it("highlights Astro markup containing only an empty expression", () => { + const code = "<p>{}</p>"; + + const tokens = highlightCode(code, "Empty.astro").flat(); + + expect(tokens.map((token) => token.text).join("")).toBe(code); + expect(tokens).toContainEqual({ text: "p", style: "tag" }); + }); + + it.each([ + "<p>{title} {} {title}</p>", + '<p class="{} {title}">{title}</p>', + "<p class={} title={title}>{title}</p>", + ])("highlights Astro containing an empty expression: %s", (markup) => { + const code = `---\nconst title = "Hello";\n---\n${markup}\n<style>.page { color: red; }</style>`; + + const lines = highlightCode(code, "Empty.astro"); + + expect(lines).toHaveLength(code.split("\n").length); + for (const [index, tokens] of lines.entries()) { + expect(tokens.map((token) => token.text).join("")).toBe(code.split("\n")[index]); + } + expect(lines.flat()).toContainEqual({ text: "const", style: "keyword" }); + expect(lines.flat()).toContainEqual({ text: "p", style: "tag" }); + expect(lines.flat()).toContainEqual({ text: "color", style: "property" }); + expect(lines.flat()).toContainEqual({ text: "title", style: "variable" }); + }); + + it("keeps an Astro expression open through nested template strings", () => { + const code = "<p>{`a ${`}`} b`}</p>"; + + const tokens = highlightCode(code, "Nested.astro").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "`}`", style: "string" }, + { text: " b`", style: "string" }, + ]), + ); + }); + + it("highlights Vue SFCs across script, template, and style", () => { + const code = [ + '<script lang="ts">', + " let count: number = 1;", + "</script>", + "", + "<template>", + ' <p v-if="count > 0" :class="badge">{{ { count: 1 } }}</p>', + " <span>{{ 'a}}b' }} {{ { inner: 2 }}}</span>", + "</template>", + "", + "<style>", + " .badge {", + " color: red;", + " }", + "</style>", + ].join("\n"); + + const tokens = highlightCode(code, "Counter.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "let", style: "keyword" }, + { text: "number", style: "type" }, + { text: "template", style: "tag" }, + { text: "v-if", style: "attribute" }, + { text: "count", style: "definition" }, + { text: "1", style: "number" }, + { text: "'a}}b'", style: "string" }, + { text: "inner", style: "definition" }, + { text: "2", style: "number" }, + { text: "badge", style: "class" }, + { text: "color", style: "property" }, + ]), + ); + }); + + it("highlights a Vue SFC that contains an empty interpolation", () => { + const code = ["<template>", " <p>{{}} {{ count }}</p>", "</template>"].join("\n"); + + const tokens = highlightCode(code, "Empty.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "template", style: "tag" }, + { text: "count", style: "variable" }, + ]), + ); + }); + + it("highlights quoted Vue directive values as code", () => { + const code = "<template><p v-if=\"count > 0\" :class='badge'>x</p></template>"; + + const tokens = highlightCode(code, "Directive.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "count", style: "variable" }, + { text: "0", style: "number" }, + { text: "badge", style: "variable" }, + ]), + ); + expect(tokens).not.toContainEqual({ text: '"count > 0"', style: "string" }); + }); + + it("keeps a Vue interpolation open through nested template strings", () => { + const code = "<template><p>{{ `a ${`}}`} b` }}</p></template>"; + + const tokens = highlightCode(code, "Nested.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "`}}`", style: "string" }, + { text: " b`", style: "string" }, + ]), + ); + }); + it("highlights TSX code with correct dialect", () => { const code = 'const el = <div className="test">hello</div>;'; const result = highlightCode(code, "test.tsx"); diff --git a/packages/highlight/src/__tests__/parsers.test.ts b/packages/highlight/src/__tests__/parsers.test.ts index 1d0d0ebcdd..b24872eb94 100644 --- a/packages/highlight/src/__tests__/parsers.test.ts +++ b/packages/highlight/src/__tests__/parsers.test.ts @@ -25,6 +25,7 @@ describe("isLanguageSupported", () => { expect(isLanguageSupported("test.ex")).toBe(true); expect(isLanguageSupported("Counter.svelte")).toBe(true); expect(isLanguageSupported("Page.astro")).toBe(true); + expect(isLanguageSupported("App.vue")).toBe(true); }); it("returns false for unsupported file extensions", () => { @@ -67,6 +68,7 @@ describe("getSupportedExtensions", () => { expect(extensions).toContain("json"); expect(extensions).toContain("svelte"); expect(extensions).toContain("astro"); + expect(extensions).toContain("vue"); }); }); diff --git a/packages/highlight/src/astro/parser.ts b/packages/highlight/src/astro/parser.ts index 444235488f..cef0748b5a 100644 --- a/packages/highlight/src/astro/parser.ts +++ b/packages/highlight/src/astro/parser.ts @@ -14,6 +14,7 @@ import { import { parser as cssParser } from "@lezer/css"; import { parser as htmlParser } from "@lezer/html"; import { parser as jsParser } from "@lezer/javascript"; +import { findClosingBrace } from "../js-scanner.js"; interface Range { from: number; @@ -23,88 +24,11 @@ interface Range { const jsxParser = jsParser.configure({ dialect: "ts jsx" }); const typescriptParser = jsParser.configure({ dialect: "ts" }); -function isSpace(code: number): boolean { - return code === 32 || code === 9 || code === 10 || code === 13; -} - -function isRegexStart(text: string, position: number): boolean { - let previous = position - 1; - while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; - if (previous < 0) return true; - - const character = text[previous]; - if (/[)\]}<"'`\d]/.test(character)) return false; - const code = text.charCodeAt(previous); - if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; - if (!/[A-Za-z_$]/.test(character)) return true; - - let start = previous; - while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; - const keyword = text.slice(start + 1, previous + 1); - return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( - keyword, - ); -} - -function skipQuotedText(text: string, opening: number): number { - const quote = text.charCodeAt(opening); - for (let position = opening + 1; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 92) position++; - else if (code === quote) return position; - } - return text.length - 1; -} - -function skipLineComment(text: string, opening: number): number { - const newline = text.indexOf("\n", opening + 2); - return newline >= 0 ? newline : text.length - 1; -} - -function skipBlockComment(text: string, opening: number): number { - const closing = text.indexOf("*/", opening + 2); - return closing >= 0 ? closing + 1 : text.length - 1; -} - -function skipRegex(text: string, opening: number): number { - let isInCharacterClass = false; - for (let position = opening + 1; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 10 || code === 13) return position; - if (code === 92) position++; - else if (isInCharacterClass && code === 93) isInCharacterClass = false; - else if (!isInCharacterClass && code === 91) isInCharacterClass = true; - else if (!isInCharacterClass && code === 47) return position; - } - return text.length - 1; -} - -function findClosingBrace(text: string, opening: number): number { - let depth = 0; - for (let position = opening; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 47 && text.charCodeAt(position + 1) === 47) { - position = skipLineComment(text, position); - } else if (code === 47 && text.charCodeAt(position + 1) === 42) { - position = skipBlockComment(text, position); - } else if (code === 47 && isRegexStart(text, position)) { - position = skipRegex(text, position); - } else if (code === 34 || code === 39 || code === 96) { - position = skipQuotedText(text, position); - } else if (code === 123) { - depth++; - } else if (code === 125 && --depth === 0) { - return position; - } - } - return -1; -} - function findExpressions(text: string): Range[] { const ranges: Range[] = []; for (let position = 0; position < text.length; position++) { if (text.charCodeAt(position) !== 123) continue; - const closing = findClosingBrace(text, position); + const closing = findClosingBrace(text, position + 1); if (closing < 0) break; ranges.push({ from: position, to: closing }); position = closing; @@ -121,10 +45,12 @@ function maskExpressions(text: string): string { } function expressionOverlays(node: SyntaxNodeRef, input: Input): Range[] | null { - const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({ - from: node.from + from + 1, - to: node.from + to, - })); + const overlays = findExpressions(input.read(node.from, node.to)) + .filter(({ from, to }) => to > from + 1) + .map(({ from, to }) => ({ + from: node.from + from + 1, + to: node.from + to, + })); return overlays.length > 0 ? overlays : null; } diff --git a/packages/highlight/src/js-scanner.ts b/packages/highlight/src/js-scanner.ts new file mode 100644 index 0000000000..fb7f70ed77 --- /dev/null +++ b/packages/highlight/src/js-scanner.ts @@ -0,0 +1,100 @@ +// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}` +// interpolations) for the brace that closes it, without parsing the code. + +function isSpace(code: number): boolean { + return code === 32 || code === 9 || code === 10 || code === 13; +} + +function isRegexStart(text: string, position: number): boolean { + let previous = position - 1; + while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; + if (previous < 0) return true; + + const character = text[previous]; + if (/[)\]}<"'`\d]/.test(character)) return false; + const code = text.charCodeAt(previous); + if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; + if (!/[A-Za-z_$]/.test(character)) return true; + + let start = previous; + while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; + const keyword = text.slice(start + 1, previous + 1); + return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( + keyword, + ); +} + +// Template strings nested deeper than this are treated as running to the end. +const MAX_TEMPLATE_NESTING = 32; + +function skipQuotedText(text: string, opening: number, nesting = 0): number { + const quote = text.charCodeAt(opening); + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 92) position++; + else if (code === quote) return position; + else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) { + position = skipTemplateSubstitution(text, position + 2, nesting); + } + } + return text.length - 1; +} + +function skipTemplateSubstitution(text: string, start: number, nesting: number): number { + if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1; + const closing = findClosingBrace(text, start, () => true, nesting + 1); + return closing >= 0 ? closing : text.length - 1; +} + +function skipLineComment(text: string, opening: number): number { + const newline = text.indexOf("\n", opening + 2); + return newline >= 0 ? newline : text.length - 1; +} + +function skipBlockComment(text: string, opening: number): number { + const closing = text.indexOf("*/", opening + 2); + return closing >= 0 ? closing + 1 : text.length - 1; +} + +function skipRegex(text: string, opening: number): number { + let isInCharacterClass = false; + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 10 || code === 13) return position; + if (code === 92) position++; + else if (isInCharacterClass && code === 93) isInCharacterClass = false; + else if (!isInCharacterClass && code === 91) isInCharacterClass = true; + else if (!isInCharacterClass && code === 47) return position; + } + return text.length - 1; +} + +// Finds the first `}` outside nested braces, strings, comments, and regexes +// for which `isEnd` holds. +export function findClosingBrace( + text: string, + start: number, + isEnd: (position: number) => boolean = () => true, + nesting = 0, +): number { + let depth = 0; + for (let position = start; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 47 && text.charCodeAt(position + 1) === 47) { + position = skipLineComment(text, position); + } else if (code === 47 && text.charCodeAt(position + 1) === 42) { + position = skipBlockComment(text, position); + } else if (code === 47 && isRegexStart(text, position)) { + position = skipRegex(text, position); + } else if (code === 34 || code === 39 || code === 96) { + position = skipQuotedText(text, position, nesting); + } else if (code === 123) { + depth++; + } else if (code === 125 && depth === 0) { + if (isEnd(position)) return position; + } else if (code === 125) { + depth--; + } + } + return -1; +} diff --git a/packages/highlight/src/parsers.ts b/packages/highlight/src/parsers.ts index 9ccb3129be..06c3129733 100644 --- a/packages/highlight/src/parsers.ts +++ b/packages/highlight/src/parsers.ts @@ -18,6 +18,7 @@ import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; import { csharpLanguage } from "./csharp/language.js"; import { astroParser } from "./astro/parser.js"; +import { vueParser } from "./vue/parser.js"; import { nixLanguage } from "./nix/language.js"; import { parser as svelteBaseParser } from "./svelte/parser.js"; import { configureNesting, defaultNesting } from "./svelte/nesting.js"; @@ -56,6 +57,8 @@ const languagesByExtension: Record<string, Language> = { svelte: language(svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) })), // Astro astro: language(astroParser), + // Vue + vue: language(vueParser), // XML xml: language(xmlParser), // Java diff --git a/packages/highlight/src/vue/parser.ts b/packages/highlight/src/vue/parser.ts new file mode 100644 index 0000000000..5c54825b61 --- /dev/null +++ b/packages/highlight/src/vue/parser.ts @@ -0,0 +1,136 @@ +import { + type Input, + type NestedParse, + type SyntaxNode, + type SyntaxNodeRef, + parseMixed, +} from "@lezer/common"; +import { parser as cssParser } from "@lezer/css"; +import { parser as htmlParser } from "@lezer/html"; +import { parser as jsParser } from "@lezer/javascript"; +import { findClosingBrace } from "../js-scanner.js"; + +interface Range { + from: number; + to: number; +} + +const typescriptParser = jsParser.configure({ dialect: "ts" }); +const expressionParser = jsParser.configure({ top: "SingleExpression" }); + +function findInterpolationEnd(text: string, start: number): number { + return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125); +} + +function findExpressions(text: string): Range[] { + const ranges: Range[] = []; + for (let position = 0; position < text.length; position++) { + if (text.charCodeAt(position) !== 123) continue; + if (text.charCodeAt(position + 1) !== 123) continue; + const closing = findInterpolationEnd(text, position + 2); + if (closing < 0) break; + // Lezer rejects empty inner ranges, so `{{}}` gets no nested parse. + if (closing > position + 2) ranges.push({ from: position + 2, to: closing }); + position = closing + 1; + } + return ranges; +} + +function findDirectiveExpression(value: string): Range | null { + const quote = value[0]; + const isQuoted = (quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote); + const inner = isQuoted ? value.slice(1, -1) : value; + const trimmed = inner.trim(); + if (!trimmed) return null; + const from = (isQuoted ? 1 : 0) + inner.indexOf(trimmed); + return { from, to: from + trimmed.length }; +} + +function getOpenTagAttributes(node: SyntaxNode, input: Input): Record<string, string> { + const attributes: Record<string, string> = Object.create(null); + const openTag = node.getChild("OpenTag"); + if (!openTag) return attributes; + + for (const attribute of openTag.getChildren("Attribute")) { + const name = attribute.getChild("AttributeName"); + if (!name) continue; + const value = + attribute.getChild("AttributeValue") || attribute.getChild("UnquotedAttributeValue"); + const key = input.read(name.from, name.to).toLowerCase(); + attributes[key] = value ? input.read(value.from, value.to).replace(/^["']|["']$/g, "") : ""; + } + return attributes; +} + +function isDirectiveAttribute(attribute: string): boolean { + return ( + attribute.startsWith("v-") || + attribute.startsWith(":") || + attribute.startsWith("@") || + attribute.startsWith(".") + ); +} + +function directiveOverlay( + node: SyntaxNodeRef, + parent: SyntaxNode, + input: Input, +): NestedParse | null { + const attributeName = parent.getChild("AttributeName"); + if (!attributeName) return null; + if (!isDirectiveAttribute(input.read(attributeName.from, attributeName.to).toLowerCase())) { + return null; + } + + const range = findDirectiveExpression(input.read(node.from, node.to)); + if (!range) return null; + return { + parser: expressionParser, + overlay: [{ from: node.from + range.from, to: node.from + range.to }], + }; +} + +function textOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({ + from: node.from + from, + to: node.from + to, + })); + return overlays.length > 0 ? { parser: expressionParser, overlay: overlays } : null; +} + +function templateOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (parent?.name === "Attribute") return directiveOverlay(node, parent, input); + if (node.name !== "Text") return null; + return textOverlay(node, input); +} + +function scriptLanguage(attributes: Record<string, string>): NestedParse | null { + if (attributes.src) return null; + + const language = (attributes.lang || attributes.type || "").toLowerCase(); + if (language.includes("tsx")) return { parser: jsParser.configure({ dialect: "ts jsx" }) }; + if (language.includes("typescript") || language === "ts") return { parser: typescriptParser }; + if (language.includes("jsx")) return { parser: jsParser.configure({ dialect: "jsx" }) }; + return { parser: jsParser }; +} + +function scriptOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (!parent) return null; + return scriptLanguage(getOpenTagAttributes(parent, input)); +} + +function nestedLanguage(node: SyntaxNodeRef, input: Input): NestedParse | null { + const name = node.name; + if (name === "Text" || name === "UnquotedAttributeValue" || name === "AttributeValue") { + return templateOverlay(node, input); + } + if (name === "StyleText") return { parser: cssParser }; + if (name === "ScriptText") return scriptOverlay(node, input); + return null; +} + +export const vueParser = htmlParser.configure({ + wrap: parseMixed(nestedLanguage), +}); diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 306a636512..2797c32211 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.11.0-beta.5", + "version": "0.11.2", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -70,8 +70,8 @@ "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", @@ -81,8 +81,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" diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index fc78d2a8c4..05403594cf 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -359,6 +359,8 @@ class AcpRuntime { private emit: (event: ProviderEvent) => void; private readonly messages = new Map<string, string>(); private readonly toolCalls = new Map<string, AcpToolCallSnapshot>(); + // Running tool rows published by transformers; the turn's terminal closes any left open. + private readonly vendorToolCalls = new Map<string, ToolCallTimelineItem>(); private readonly pendingCompactions = new Set<string>(); private readonly permissions = new Map< string, @@ -545,8 +547,13 @@ class AcpRuntime { return sessionCapabilities; } - async discover(cwd = process.cwd()): Promise<ProviderCatalog> { - const response = await this.call(this.connection.newSession({ cwd, mcpServers: [] })); + async discover(cwd?: string): Promise<ProviderCatalog> { + if (cwd === undefined && this.options.options.acpOptions?.workspaceOnlyCatalog) { + return { models: [], modes: [] }; + } + const response = await this.call( + this.connection.newSession({ cwd: cwd ?? process.cwd(), mcpServers: [] }), + ); this.nativeSessionId = response.sessionId; this.modes = response.modes; this.configOptions = response.configOptions ?? []; @@ -595,10 +602,13 @@ class AcpRuntime { turnId, state: "started", }); + // The SDK dispatches notifications without awaiting them, so the prompt response can resolve + // while earlier session updates are still queued. Apply them before the turn ends. const settled = this.call( this.connection.prompt({ sessionId: this.nativeSessionId, prompt }), ).then( - (response): void => { + async (response): Promise<void> => { + await this.drainNotifications(); const state = response.stopReason === "cancelled" ? "canceled" : "completed"; this.fallbackChunkIds.clear(); this.terminalizeTransientItems(state); @@ -610,7 +620,8 @@ class AcpRuntime { }); return undefined; }, - (error): void => { + async (error): Promise<void> => { + await this.drainNotifications(); this.fallbackChunkIds.clear(); this.terminalizeTransientItems("failed"); this.emit({ @@ -1027,9 +1038,17 @@ class AcpRuntime { this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, - item: toolTimelineItem(completed), + item: terminalToolItem(toolTimelineItem(snapshot), state), }); } + for (const item of this.vendorToolCalls.values()) { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: terminalToolItem(item, state), + }); + } + this.vendorToolCalls.clear(); for (const id of this.pendingCompactions) { this.emit({ type: "timeline.item", @@ -1076,6 +1095,10 @@ class AcpRuntime { if (this.configTransaction) this.stagedTransformerConfig = update.config; else this.emitConfigSnapshot(update.config); } else if (update.type === "timeline") { + if (update.item.type === "tool_call") { + if (update.item.status === "running") this.vendorToolCalls.set(update.item.id, update.item); + else this.vendorToolCalls.delete(update.item.id); + } this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, @@ -1383,7 +1406,9 @@ function configValues( return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); } -function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { +type ToolCallTimelineItem = Extract<ProviderTimelineItem, { type: "tool_call" }>; + +function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem { const input = jsonRecord(snapshot.input); const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; const detail = isEdit @@ -1411,6 +1436,18 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { return { ...base, status, error: null }; } +// A row the turn left open ends with the turn. The wire contract requires a non-null error on a +// failed tool call, so a canceled turn cancels its rows instead of failing them. +function terminalToolItem( + item: ToolCallTimelineItem, + state: "completed" | "failed" | "canceled", +): ToolCallTimelineItem { + if (state === "failed") { + return { ...item, status: "failed", error: item.error ?? { message: "Turn failed" } }; + } + return { ...item, status: state, error: null }; +} + function jsonValue(value: unknown) { return z.json().parse(value); } diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index a62b2f2c48..40c8019439 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -272,6 +272,39 @@ lines.on("line", (line) => { } }); + it("still reports initialization failures for workspace-only global discovery", async () => { + let unavailable = false; + const { connector } = connectorHarness({ + handleRequest(instance, request) { + if (unavailable && request.method === "initialize") { + instance.reject(request, "Bridge unavailable"); + return true; + } + return false; + }, + }); + const registration = runAcpProvider({ + id: "workspace-acp", + label: "Workspace ACP", + connector, + acpOptions: { workspaceOnlyCatalog: true }, + }); + const connection = await registration.connect({ versions: [1], capabilities: [] }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + try { + unavailable = true; + await connection.send({ type: "catalog", requestId: "global" }); + expect(await waitForEvent(events, (event) => event.type === "request.failed")).toMatchObject({ + requestId: "global", + error: { message: expect.stringContaining("Bridge unavailable") }, + }); + expect(events.some((event) => event.type === "catalog")).toBe(false); + } finally { + await connection.close(); + } + }); + it("rejects connection cleanly when the ACP executable does not exist", async () => { const registration = runAcpProvider({ id: "missing-acp", @@ -619,6 +652,146 @@ lines.on("line", (line) => { await connection.close(); }); + it.each([ + ["end_turn", "completed", "completed"], + ["cancelled", "canceled", "canceled"], + ] as const)( + "closes a transformer's running tool row when the turn ends with %s", + async (stopReason, turnState, rowStatus) => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("vendor/phase", { id: "phase-1", label: "Preparing" }); + instance.respond(message as AcpRequestMessage, { stopReason }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "vendor-row-acp", + label: "Vendor row ACP", + connector: harness.connector, + transformers: [ + { + notification(notification) { + if (notification.method !== "vendor/phase") return null; + return { + type: "timeline", + item: { + type: "tool_call", + id: "phase-1", + callId: "phase-1", + name: "Vendor phase", + detail: { type: "plain_text", label: "Preparing" }, + status: "running", + error: null, + }, + }; + }, + }, + ], + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === turnState, + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "phase-1" ? [event.item] : [], + ); + expect(rows.map((row) => row.type === "tool_call" && row.status)).toEqual([ + "running", + rowStatus, + ]); + const closed = events.findIndex( + (event) => + event.type === "timeline.item" && + event.item.id === "phase-1" && + event.item.type === "tool_call" && + event.item.status === rowStatus, + ); + const terminal = events.findIndex( + (event) => event.type === "session.turn" && event.state === turnState, + ); + expect(closed).toBeLessThan(terminal); + // The wire contract only accepts a failed tool call with a non-null error. + expect(rows.at(-1)).toMatchObject({ error: null }); + await connection.close(); + }, + ); + + it("cancels an in-progress ACP tool call with the canceled turn instead of failing it without an error", async () => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("session/update", { + sessionId: "connector-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Run tests", + kind: "execute", + status: "in_progress", + }, + }); + instance.respond(message as AcpRequestMessage, { stopReason: "cancelled" }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "cancel-tool-acp", + label: "Cancel tool ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === "canceled", + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "tool-1" ? [event.item] : [], + ); + expect(rows.at(-1)).toMatchObject({ type: "tool_call", status: "canceled", error: null }); + await connection.close(); + }); + it("serializes configuration mutations", async () => { let releaseMutation: (() => void) | null = null; const configOptions = [ diff --git a/packages/plugin/src/server/acp.ts b/packages/plugin/src/server/acp.ts index 533453f15e..826bc23235 100644 --- a/packages/plugin/src/server/acp.ts +++ b/packages/plugin/src/server/acp.ts @@ -44,6 +44,8 @@ export type AcpConnector = () => AcpStream | Promise<AcpStream>; export interface AcpOptions { protocol?: "auto" | "v1"; startupTimeoutMs?: number; + /** Global discovery initializes ACP but returns an empty catalog without opening a session. */ + workspaceOnlyCatalog?: boolean; waitForInitialCommands?: boolean; initialCommandsTimeoutMs?: number; } diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index 33b49ab447..1646e8473b 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -26,6 +26,8 @@ export const PROVIDER_CAPABILITIES = [ export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; export interface ProviderRegistration { + /** Activate only while agents.providers contains this provider ID, independently of enabled. */ + requiresConfiguration?: boolean; /** Equal keys share discovery within this provider. Include effective configuration and execution environment. */ getCatalogCacheKey?(options: ProviderCatalogOptions): Promise<string | undefined>; /** Default executable and arguments; the daemon resolves overrides before status/connect. */ diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b969849cb1..8ce97a1025 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.11.0-beta.5", + "version": "0.11.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/agent-message-display.ts b/packages/protocol/src/agent-message-display.ts new file mode 100644 index 0000000000..c95f9e8206 --- /dev/null +++ b/packages/protocol/src/agent-message-display.ts @@ -0,0 +1,25 @@ +import type { AgentMessage } from "./agent-message.js"; +import type { ToolCallDetail } from "./agent-types.js"; + +/** Presentation shared by clients; never used to encode provider prompts. */ +export function buildAgentMessageDisplay(message: AgentMessage): { + displayName: string; + detail: ToolCallDetail; +} { + const sender = message.sender.title ?? message.sender.id; + const headings: Record<AgentMessage["event"], string> = { + message: `Message from ${sender}`, + finished: `${sender} finished`, + errored: `${sender} errored`, + "permission-required": `${sender} needs permission`, + closed: `${sender} closed`, + }; + return { + displayName: headings[message.event], + detail: { + type: "plain_text", + icon: "bot", + text: `From agent: ${message.sender.id}\n\n${message.text}`, + }, + }; +} diff --git a/packages/protocol/src/agent-message.ts b/packages/protocol/src/agent-message.ts new file mode 100644 index 0000000000..5caf63656d --- /dev/null +++ b/packages/protocol/src/agent-message.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +/** Semantic provenance, independent of provider envelopes and timeline presentation. */ +export const AgentMessageSchema = z.object({ + event: z.enum(["message", "finished", "errored", "permission-required", "closed"]), + sender: z.object({ + id: z.string().min(1), + title: z.string().min(1).optional(), + }), + text: z.string(), +}); + +export type AgentMessage = z.infer<typeof AgentMessageSchema>; diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 5c0fce9608..c7fffe8ce9 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "./agent-message.js"; import type { AgentAttachment } from "./messages.js"; export type AgentProvider = string; @@ -311,6 +312,7 @@ export type ToolCallDetail = }; interface ToolCallBase { + agentMessage?: AgentMessage; [key: string]: unknown; type: "tool_call"; callId: string; diff --git a/packages/protocol/src/messages.checkout-pr-schema.test.ts b/packages/protocol/src/messages.checkout-pr-schema.test.ts index cf15e47962..3966d6eb71 100644 --- a/packages/protocol/src/messages.checkout-pr-schema.test.ts +++ b/packages/protocol/src/messages.checkout-pr-schema.test.ts @@ -812,3 +812,14 @@ describe("checkout PR schemas", () => { }); }); }); + +test.each([undefined, false, true])( + "accepts optional Hoplite capability (%s)", + (hopliteProvider) => { + const features = hopliteProvider === undefined ? {} : { hopliteProvider }; + expect( + ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "test", features }) + .features, + ).toEqual(features); + }, +); diff --git a/packages/protocol/src/messages.create-agent-internal.test.ts b/packages/protocol/src/messages.create-agent-internal.test.ts new file mode 100644 index 0000000000..5d4e75649f --- /dev/null +++ b/packages/protocol/src/messages.create-agent-internal.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import { SessionInboundMessageSchema } from "./messages.js"; + +describe("create request internal field", () => { + test("accepts internal on create_agent_request", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "create-agent-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + }); + + expect(parsed).toEqual({ + type: "create_agent_request", + requestId: "create-agent-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + labels: {}, + }); + }); + + test("accepts internal on agent.create.request", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "agent.create.request", + requestId: "agent-create-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + }); + + expect(parsed).toEqual({ + type: "agent.create.request", + requestId: "agent-create-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + labels: {}, + }); + }); +}); diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index 5c62e90669..f266b65988 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => { expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); }, ); + it("preserves manifest metadata in plugin list responses", () => { + const message = { + type: "plugin.list.response", + payload: { + requestId: "metadata", + plugins: [ + { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + path: "/plugins/review", + enabled: false, + status: "disabled", + }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }); + it("parses plugin timeline append messages and advertises the capability", () => { expect( SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 3394c8e15f..ca3ab86135 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + AgentTimelineItemPayloadSchema, FileExplorerRequestSchema, PaseoWorktreeArchiveRequestSchema, parseServerInfoStatusPayload, @@ -520,3 +521,30 @@ describe("viewed timeline subscription messages", () => { }); }); }); + +test("agent messages are an optional semantic payload on existing tool rows", () => { + const legacy = { + type: "tool_call", + callId: "delivery", + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Done" }, + }; + const agentMessage = { + event: "finished", + sender: { id: "server::worker", title: "Reviewer" }, + text: "Done", + }; + expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy); + expect(AgentTimelineItemPayloadSchema.parse({ ...legacy, agentMessage })).toEqual({ + ...legacy, + agentMessage, + }); + expect( + AgentTimelineItemPayloadSchema.safeParse({ + ...legacy, + agentMessage: { ...agentMessage, sender: { id: 42 } }, + }).success, + ).toBe(false); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 41b9ca3f74..c8fe60ff67 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,3 +1,4 @@ +import { AgentMessageSchema } from "./agent-message.js"; import { PluginRegistryIdentitySchema } from "./plugin-registry.js"; import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js"; export { @@ -625,6 +626,7 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, unknown> = z.discri ); const ToolCallBasePayloadSchema = z.object({ + agentMessage: AgentMessageSchema.optional(), type: z.literal("tool_call"), callId: z.string(), name: z.string(), @@ -837,6 +839,8 @@ export const AgentSnapshotPayloadSchema = z.object({ attentionTimestamp: z.string().nullable().optional(), archivedAt: z.string().nullable().optional(), providerUnavailable: z.boolean().optional(), + // Private helper marker, retained for exact-ID snapshots and older wire messages. + internal: z.boolean().optional(), }); export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>; @@ -908,6 +912,9 @@ const AgentDirectoryFilterSchema = z.object({ includeArchived: z.boolean().optional(), requiresAttention: z.boolean().optional(), thinkingOptionId: z.string().nullable().optional(), + // Retained for wire parsing; private helpers are excluded from public discovery. + includeInternal: z.boolean().optional(), + includeBackground: z.boolean().optional(), }); export const DeleteAgentRequestMessageSchema = z.object({ @@ -1235,6 +1242,7 @@ export const FetchAgentsRequestMessageSchema = z.object({ type: z.literal("fetch_agents_request"), requestId: z.string(), scope: z.enum(["active"]).optional(), + includeBackground: z.boolean().optional(), filter: AgentDirectoryFilterSchema.optional(), sort: z .array( @@ -1268,6 +1276,7 @@ const WorkspaceStateBucketSchema = z.enum([ ]); export const FetchWorkspacesRequestMessageSchema = z.object({ + includeBackground: z.boolean().optional(), type: z.literal("fetch_workspaces_request"), requestId: z.string(), filter: z @@ -1276,6 +1285,9 @@ export const FetchWorkspacesRequestMessageSchema = z.object({ projectId: z.string().optional(), // Unused: accepted so older clients still parse, but the server does not filter on it. idPrefix: z.string().optional(), + // Retained for wire parsing of older requests. + includeInternal: z.boolean().optional(), + includeBackground: z.boolean().optional(), }) .optional(), sort: z @@ -1354,6 +1366,8 @@ export const SendAgentMessageRequestSchema = z.object({ /** Accepts full ID, unique prefix, or exact full title (server resolves). */ agentId: z.string(), text: z.string(), + /** Opaque sender identity for agent-originated prompts. */ + sourceAgentId: z.string().min(1).optional(), messageId: z.string().optional(), // Client-provided ID for deduplication activeTurnBehavior: ActiveTurnBehaviorSchema.optional(), images: z.array(ImageAttachmentSchema).optional(), @@ -1710,6 +1724,13 @@ export const CreateAgentRequestMessageSchema = z.object({ git: GitSetupOptionsSchema.optional(), worktree: CreateAgentWorktreeTargetSchema.optional(), autoArchive: z.boolean().optional(), + // An ephemeral helper: the daemon never persists, lists, flags or announces + // it, and plugin lifecycle hooks skip it. Lives on the request rather than + // in the config schema, which is reused for updates. Retained for wire parsing; + // public internal creation is no longer supported. + internal: z.boolean().optional(), + // Workspace creation intent, rejected when selecting an existing workspace. + background: z.boolean().optional(), labels: z.record(z.string(), z.string()).default({}), requestId: z.string(), }); @@ -2629,6 +2650,10 @@ export const WorkspaceCreateRequestSchema = z.object({ title: z.string().optional(), // Optional prompt context for workspace-level name/branch generation. firstAgentContext: FirstAgentContextSchema.optional(), + // Retained for wire parsing; public internal creation is no longer supported. + internal: z.boolean().optional(), + background: z.boolean().optional(), + callerAgentId: z.string().optional(), source: z.discriminatedUnion("kind", [ z.object({ kind: z.literal("directory"), @@ -3554,6 +3579,11 @@ export const ServerInfoStatusPayloadSchema = z creationLifecycle: z.boolean().optional(), // COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05. hubAgentRpc: z.boolean().optional(), + // COMPAT(internalAgents): added in v0.9.0; remove gate after 2027-03-17. + internalAgents: z.boolean().optional(), + // COMPAT(internalWorkspaces): added in v0.9.0; remove gate after 2027-03-17. + internalWorkspaces: z.boolean().optional(), + backgroundWorkspaces: z.boolean().optional(), providersSnapshot: z.boolean().optional(), usageSources: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. @@ -3680,6 +3710,8 @@ export const ServerInfoStatusPayloadSchema = z commitBaseClassification: z.boolean().optional(), // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: z.boolean().optional(), + // COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter. + hopliteProvider: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), // COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02. @@ -3695,6 +3727,7 @@ export const ServerInfoStatusPayloadSchema = z ownedSubscriptions: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), + agentMessageProvenance: z.boolean().optional(), // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. agentTurnIdentity: z.boolean().optional(), // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. @@ -4019,6 +4052,9 @@ export const WorkspaceDescriptorPayloadSchema = z pinnedAt: z.string().nullable().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14. labels: z.array(z.string()).optional(), + // Retained for wire parsing of older daemon descriptors. + internal: z.boolean().optional(), + background: z.boolean().optional(), archivingAt: z.string().nullable().optional().default(null), status: WorkspaceStateBucketSchema, // Best-effort workspace status entry timestamp. Old daemons omit the @@ -6632,6 +6668,9 @@ export const PluginNpmInstallationSchema = z.object({ export const PluginListItemSchema = z.object({ id: PluginIdSchema, + name: z.string().optional(), + icon: z.string().optional(), + media: z.array(z.string()).optional(), description: z.string().optional(), path: z.string(), enabled: z.boolean(), diff --git a/packages/protocol/src/paseo-tool-call-detail.ts b/packages/protocol/src/paseo-tool-call-detail.ts index a922a78442..4b06d49923 100644 --- a/packages/protocol/src/paseo-tool-call-detail.ts +++ b/packages/protocol/src/paseo-tool-call-detail.ts @@ -47,6 +47,7 @@ const AGENT_FIELDS = [ "modeId", "background", "notifyOnFinish", + "internal", "settings", "labels", ] as const; @@ -196,6 +197,7 @@ const FIELD_LABELS: Readonly<Record<string, string>> = { newMode: "New mode", nextRunAt: "Next run", notifyOnFinish: "Notify on finish", + internal: "Internal", prNumber: "Change request", projectId: "Project", removedDirectory: "Removed directory", diff --git a/packages/protocol/src/plugin-overview.test.ts b/packages/protocol/src/plugin-overview.test.ts new file mode 100644 index 0000000000..c4023965da --- /dev/null +++ b/packages/protocol/src/plugin-overview.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { pluginOverviewUrl } from "./plugin-overview.js"; + +describe("plugin overview destinations", () => { + it.each([ + undefined, + "", + "./docs", + "../docs", + "/login", + "//example.com", + "#section", + "?login", + "http://example.com", + "mailto:hello@example.com", + "ftp://example.com", + "javascript:alert(1)", + "data:image/png;base64,AAAA", + "vbscript:msgbox(1)", + "javascript:alert(1)", + "javascript%3Aalert(1)", + "javascript%253Aalert(1)", + "java\tscript:alert(1)", + " https://example.com", + "https://example.com\n", + "https:example.com", + "https:///example.com", + "https://paseo.sh@evil.example", + "https://example.com\\@evil.example", + "https://", + ])("leaves %s as text", (url) => { + expect(pluginOverviewUrl(url)).toBeUndefined(); + }); + + it.each([ + "https://example.com/path?q=a&b=c#section", + "HTTPS://example.com/path", + "https://example.com/%3Cscript%3E", + ])("allows %s without decoding it again", (url) => { + expect(pluginOverviewUrl(url)).toBe(url); + }); +}); diff --git a/packages/protocol/src/plugin-overview.ts b/packages/protocol/src/plugin-overview.ts new file mode 100644 index 0000000000..2507b511b0 --- /dev/null +++ b/packages/protocol/src/plugin-overview.ts @@ -0,0 +1,49 @@ +/** Presentation contract for author-controlled registry content, independent of the UI runtime. */ +export const pluginOverviewPolicy = { + rawHtml: false, + elements: [ + "p", + "br", + "hr", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "ul", + "ol", + "li", + "strong", + "em", + "del", + "pre", + "code", + "a", + "img", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + ], + link: { rel: "noopener noreferrer nofollow", target: "_blank" }, +} as const; + +/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */ +export function pluginOverviewUrl(value: string | undefined): string | undefined { + // URL parsers discard controls and normalize backslashes; reject those ambiguous spellings. + // eslint-disable-next-line no-control-regex + if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value)) + return undefined; + try { + const url = new URL(value); + if (url.protocol !== "https:" || !url.hostname || url.username || url.password) + return undefined; + return value; + } catch { + return undefined; + } +} diff --git a/packages/protocol/src/plugin-registry.test.ts b/packages/protocol/src/plugin-registry.test.ts new file mode 100644 index 0000000000..5692c4c56e --- /dev/null +++ b/packages/protocol/src/plugin-registry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { pluginMediaKind } from "./plugin-registry.js"; + +describe("plugin media kind", () => { + it.each([ + ["https://cdn.example.com/a/preview.png", "image"], + ["https://cdn.example.com/a/preview.JPG", "image"], + ["https://cdn.example.com/a/preview.jpeg", "image"], + ["https://cdn.example.com/a/preview.webp", "image"], + ["https://cdn.example.com/a/preview.gif", "image"], + ["https://cdn.example.com/a/demo.mp4", "video"], + ["https://cdn.example.com/a/demo.WebM", "video"], + ["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"], + ["https://cdn.example.com/mp4/preview.png?format=mp4", "image"], + ] as const)("reads %s as %s from its path extension", (url, kind) => { + expect(pluginMediaKind(url)).toBe(kind); + }); +}); diff --git a/packages/protocol/src/plugin-registry.ts b/packages/protocol/src/plugin-registry.ts index 36c5354c88..90854962ba 100644 --- a/packages/protocol/src/plugin-registry.ts +++ b/packages/protocol/src/plugin-registry.ts @@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({ repository: z.object({ url: z.string().url(), commit: z.string().optional() }), artifact: PluginRegistryArtifactSchema, icon: z.string().url().optional(), - screenshots: z.array(z.string().url()), + /** Images and videos in display order; see `pluginMediaKind`. */ + media: z.array(z.string().url()), submittedAt: z.string(), reviewedAt: z.string(), updatedAt: z.string(), publishedAt: z.string(), installs: z.number().int().nonnegative().optional(), }); +const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]); + +/** Whether a registry media entry is a video or an image, from its path extension. */ +export function pluginMediaKind(url: string): "image" | "video" { + const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? ""; + return VIDEO_EXTENSIONS.has(extension) ? "video" : "image"; +} export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() }); export const PluginRegistryIndexSchema = z.object({ schemaVersion: z.literal(1), @@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({ z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }), ), plugins: z.array(PublishedPluginSchema), + /** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */ + featured: z.array(z.string()), generatedAt: z.string(), }); export type PluginRegistryIdentity = z.infer<typeof PluginRegistryIdentitySchema>; diff --git a/packages/protocol/src/plugin-source-reference.test.ts b/packages/protocol/src/plugin-source-reference.test.ts index b8ce6e7346..83f41ce3df 100644 --- a/packages/protocol/src/plugin-source-reference.test.ts +++ b/packages/protocol/src/plugin-source-reference.test.ts @@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest"; import { parsePluginSourceReference } from "./plugin-source-reference.js"; describe("plugin source references", () => { + it.each([ + ".", + "..", + "./owner/slug", + "../owner/slug", + "/owner/slug", + "~/owner/slug", + "~", + "C:\\", + "C:/", + "D:\\plugins\\repository", + "D:/plugins/repository", + "\\\\server\\share", + "\\\\server\\share\\plugins\\review", + ".\\plugins\\review", + "..\\plugins\\review", + ])("recognizes the explicit directory %s by shape", (source) => { + expect(parsePluginSourceReference(source)).toEqual({ + kind: "directory", + source, + pluginPath: undefined, + }); + expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({ + kind: "directory", + source, + pluginPath: "plugins/review", + }); + }); + it.each([ ["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"], ["npm:review@^1.2.0", "npm:review@^1.2.0", undefined], @@ -16,6 +45,7 @@ describe("plugin source references", () => { ["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"], ["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined], ["owner/repository", "owner/repository", undefined], + ["plugins/foo", "plugins/foo", undefined], ["owner/repository:plugins/review", "owner/repository", "plugins/review"], [ "https://example.test:8443/owner/repository.git", @@ -39,10 +69,8 @@ describe("plugin source references", () => { "file:///D:/plugins/repository", "plugins/review", ], - ["D:\\plugins\\repository", "D:\\plugins\\repository", undefined], - ["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"], ])("parses %s", (reference, source, pluginPath) => { - expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath }); + expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath }); }); }); diff --git a/packages/protocol/src/plugin-source-reference.ts b/packages/protocol/src/plugin-source-reference.ts index 229dc1df88..15171531f0 100644 --- a/packages/protocol/src/plugin-source-reference.ts +++ b/packages/protocol/src/plugin-source-reference.ts @@ -1,19 +1,27 @@ import { parsePluginRegistryReference } from "./plugin-registry.js"; import type { PluginInstallation } from "./messages.js"; export interface PluginSourceReference { + kind: "directory" | "managed"; source: string; pluginPath: string | undefined; } export function parsePluginSourceReference(reference: string): PluginSourceReference { - if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined }; const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0]; const source = prefix ? reference.slice(prefix.length) : reference; const parsed = splitPluginPath(source, { scp: prefix !== "npm:" }); - return { ...parsed, source: `${prefix ?? ""}${parsed.source}` }; + if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) { + return { kind: "directory", ...parsed }; + } + if (parsePluginRegistryReference(reference)) + return { kind: "managed", source: reference, pluginPath: undefined }; + return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` }; } -function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference { +function splitPluginPath( + reference: string, + options: { scp: boolean }, +): Pick<PluginSourceReference, "source" | "pluginPath"> { const separator = reference.lastIndexOf(":"); if (separator === -1) return { source: reference, pluginPath: undefined }; diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index 6265b631c3..4ca8ea44e3 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -31,6 +31,7 @@ export const ACP_PROVIDER_ICON_NAMES = [ "glm-acp-agent", "goose", "grok", + "hoplite", "junie", "kilo", "kimi", diff --git a/packages/protocol/src/terminal-snapshot.test.ts b/packages/protocol/src/terminal-snapshot.test.ts index 0262f0e45f..96d82a9205 100644 --- a/packages/protocol/src/terminal-snapshot.test.ts +++ b/packages/protocol/src/terminal-snapshot.test.ts @@ -47,3 +47,33 @@ describe("renderTerminalSnapshotToAnsi", () => { expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP"); }); }); + +describe("renderTerminalSnapshotToAnsi wide chars", () => { + it("skips wide-char continuation cells so the glyph keeps its two columns", () => { + // The daemon captures "통" as the glyph followed by an empty continuation + // cell. Emitting anything for that cell pushes the rest of the row right. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]], + cursor: { row: 0, col: 5 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!"); + }); + + it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => { + // Old daemons already collapsed the continuation to " ". The serializer + // cannot tell that apart from a real space, so it must not guess. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [cells("통 합 !")], + cursor: { row: 0, col: 6 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !"); + }); +}); diff --git a/packages/protocol/src/terminal-snapshot.ts b/packages/protocol/src/terminal-snapshot.ts index f978cd884b..1860067573 100644 --- a/packages/protocol/src/terminal-snapshot.ts +++ b/packages/protocol/src/terminal-snapshot.ts @@ -87,6 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string { for (let index = 0; index < length; index += 1) { const cell = row[index] ?? { char: " " }; + // An empty char marks a wide-char continuation cell. The glyph in the + // previous cell already advances the cursor two columns, so emitting + // anything here would shift the rest of the row one column right. + if (cell.char === "") { + continue; + } const nextStyle = getTerminalStyle(cell); if (!terminalStylesEqual(previousStyle, nextStyle)) { output.push(styleToAnsi(nextStyle)); diff --git a/packages/protocol/src/tool-call-display.ts b/packages/protocol/src/tool-call-display.ts index 6930fe187a..ab597b5acd 100644 --- a/packages/protocol/src/tool-call-display.ts +++ b/packages/protocol/src/tool-call-display.ts @@ -1,10 +1,11 @@ +import { buildAgentMessageDisplay } from "./agent-message-display.js"; import type { ToolCallTimelineItem } from "./agent-types.js"; import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js"; import { stripCwdPrefix } from "./path-utils.js"; export type ToolCallDisplayInput = Pick< ToolCallTimelineItem, - "name" | "status" | "error" | "metadata" | "detail" + "name" | "status" | "error" | "metadata" | "detail" | "agentMessage" > & { cwd?: string; }; @@ -151,6 +152,9 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay } export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel { + if (input.agentMessage) { + return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName }; + } const canonicalDisplay = buildCanonicalDetailDisplay(input); const unknownDetailOverride = buildUnknownDetailOverride(input); const displayName = diff --git a/packages/protocol/src/websocket-message-limit.ts b/packages/protocol/src/websocket-message-limit.ts new file mode 100644 index 0000000000..9e2bb48e21 --- /dev/null +++ b/packages/protocol/src/websocket-message-limit.ts @@ -0,0 +1,5 @@ +/** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */ +export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024; + +/** Largest encrypted frame the relay accepts. */ +export const MAX_RELAY_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024; diff --git a/packages/protocol/tests/fixtures/plugin-overview.json b/packages/protocol/tests/fixtures/plugin-overview.json new file mode 100644 index 0000000000..bbe8575ac8 --- /dev/null +++ b/packages/protocol/tests/fixtures/plugin-overview.json @@ -0,0 +1,1202 @@ +[ + { + "name": "script", + "markdown": "<script>alert(1)</script>", + "links": [], + "images": [] + }, + { + "name": "image event", + "markdown": "<img src=\"https://example.com/x\" onerror=\"alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "svg event", + "markdown": "<svg onload=\"alert(1)\"></svg>", + "links": [], + "images": [] + }, + { + "name": "iframe", + "markdown": "<iframe src=\"https://example.com\"></iframe>", + "links": [], + "images": [] + }, + { + "name": "HTML javascript link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "HTML block", + "markdown": "<div>\n<img src=x onerror=alert(1)>\n</div>", + "links": [], + "images": [] + }, + { + "name": "comment", + "markdown": "<!-- <img src=x onerror=alert(1)> -->", + "links": [], + "images": [] + }, + { + "name": "meta refresh", + "markdown": "<meta http-equiv=\"refresh\" content=\"0;url=https://example.com\">", + "links": [], + "images": [] + }, + { + "name": "base", + "markdown": "<base href=\"https://example.com\">", + "links": [], + "images": [] + }, + { + "name": "form", + "markdown": "<form action=\"https://example.com\"><input name=\"password\"></form>", + "links": [], + "images": [] + }, + { + "name": "style expression", + "markdown": "<p style=\"width:expression(alert(1));position:fixed\">click</p>", + "links": [], + "images": [] + }, + { + "name": "style block", + "markdown": "<style>body{display:none}</style>", + "links": [], + "images": [] + }, + { + "name": "closing script", + "markdown": "</script><script>alert(1)</script>", + "links": [], + "images": [] + }, + { + "name": "HTML entities", + "markdown": "<img src=x onerror=alert(1)>", + "links": [], + "images": [] + }, + { + "name": "nested HTML entities", + "markdown": "&lt;script&gt;alert(1)&lt;/script&gt;", + "links": [], + "images": [] + }, + { + "name": "javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript HTML link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "javascript HTML image", + "markdown": "<img src=\"javascript:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript link", + "markdown": "[click](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript image", + "markdown": "![picture](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference link", + "markdown": "[click][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML link", + "markdown": "<a href=\"JaVaScRiPt:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML image", + "markdown": "<img src=\"JaVaScRiPt:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "data HTML link", + "markdown": "[click](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML image", + "markdown": "![picture](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML reference link", + "markdown": "[click][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML reference image", + "markdown": "![picture][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML link", + "markdown": "<a href=\"data:text/html;base64,PHNjcmlwdD4=\">click</a>", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML image", + "markdown": "<img src=\"data:text/html;base64,PHNjcmlwdD4=\">", + "links": [], + "images": [] + }, + { + "name": "data image link", + "markdown": "[click](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image image", + "markdown": "![picture](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image reference link", + "markdown": "[click][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image reference image", + "markdown": "![picture][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image HTML link", + "markdown": "<a href=\"data:image/png;base64,AAAA\">click</a>", + "links": [], + "images": [] + }, + { + "name": "data image HTML image", + "markdown": "<img src=\"data:image/png;base64,AAAA\">", + "links": [], + "images": [] + }, + { + "name": "vbscript link", + "markdown": "[click](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript image", + "markdown": "![picture](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript reference link", + "markdown": "[click][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript reference image", + "markdown": "![picture][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML link", + "markdown": "<a href=\"vbscript:msgbox(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML image", + "markdown": "<img src=\"vbscript:msgbox(1)\">", + "links": [], + "images": [] + }, + { + "name": "http link", + "markdown": "[click](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http image", + "markdown": "![picture](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http reference link", + "markdown": "[click][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http reference image", + "markdown": "![picture][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http HTML link", + "markdown": "<a href=\"http://example.com/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "http HTML image", + "markdown": "<img src=\"http://example.com/path\">", + "links": [], + "images": [] + }, + { + "name": "ftp link", + "markdown": "[click](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp image", + "markdown": "![picture](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp reference link", + "markdown": "[click][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp reference image", + "markdown": "![picture][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp HTML link", + "markdown": "<a href=\"ftp://example.com/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "ftp HTML image", + "markdown": "<img src=\"ftp://example.com/path\">", + "links": [], + "images": [] + }, + { + "name": "mailto link", + "markdown": "[click](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto image", + "markdown": "![picture](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto reference link", + "markdown": "[click][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto reference image", + "markdown": "![picture][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto HTML link", + "markdown": "<a href=\"mailto:attacker@example.com\">click</a>", + "links": [], + "images": [] + }, + { + "name": "mailto HTML image", + "markdown": "<img src=\"mailto:attacker@example.com\">", + "links": [], + "images": [] + }, + { + "name": "protocol relative link", + "markdown": "[click](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative image", + "markdown": "![picture](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference link", + "markdown": "[click][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference image", + "markdown": "![picture][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML link", + "markdown": "<a href=\"//example.com/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML image", + "markdown": "<img src=\"//example.com/path\">", + "links": [], + "images": [] + }, + { + "name": "relative link", + "markdown": "[click](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative image", + "markdown": "![picture](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative reference link", + "markdown": "[click][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative HTML link", + "markdown": "<a href=\"./image.png\">click</a>", + "links": [], + "images": [] + }, + { + "name": "relative HTML image", + "markdown": "<img src=\"./image.png\">", + "links": [], + "images": [] + }, + { + "name": "parent relative link", + "markdown": "[click](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative image", + "markdown": "![picture](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative reference link", + "markdown": "[click][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML link", + "markdown": "<a href=\"../login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML image", + "markdown": "<img src=\"../login\">", + "links": [], + "images": [] + }, + { + "name": "root relative link", + "markdown": "[click](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative image", + "markdown": "![picture](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative reference link", + "markdown": "[click][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative reference image", + "markdown": "![picture][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative HTML link", + "markdown": "<a href=\"/login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "root relative HTML image", + "markdown": "<img src=\"/login\">", + "links": [], + "images": [] + }, + { + "name": "fragment link", + "markdown": "[click](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment image", + "markdown": "![picture](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment reference link", + "markdown": "[click][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment reference image", + "markdown": "![picture][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment HTML link", + "markdown": "<a href=\"#login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "fragment HTML image", + "markdown": "<img src=\"#login\">", + "links": [], + "images": [] + }, + { + "name": "query link", + "markdown": "[click](?login)", + "links": [], + "images": [] + }, + { + "name": "query image", + "markdown": "![picture](?login)", + "links": [], + "images": [] + }, + { + "name": "query reference link", + "markdown": "[click][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query reference image", + "markdown": "![picture][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query HTML link", + "markdown": "<a href=\"?login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "query HTML image", + "markdown": "<img src=\"?login\">", + "links": [], + "images": [] + }, + { + "name": "entity javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML image", + "markdown": "<img src=\"javascript:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "entity letter link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML image", + "markdown": "<img src=\"javascript:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "nested entity link", + "markdown": "[click](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity image", + "markdown": "![picture](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity reference link", + "markdown": "[click][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML link", + "markdown": "<a href=\"javascript&#58;alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML image", + "markdown": "<img src=\"javascript&#58;alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "percent encoded link", + "markdown": "[click](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded image", + "markdown": "![picture](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML link", + "markdown": "<a href=\"javascript%3Aalert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML image", + "markdown": "<img src=\"javascript%3Aalert(1)\">", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded link", + "markdown": "[click](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded image", + "markdown": "![picture](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML link", + "markdown": "<a href=\"javascript%253Aalert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML image", + "markdown": "<img src=\"javascript%253Aalert(1)\">", + "links": [], + "images": [] + }, + { + "name": "tab scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML link", + "markdown": "<a href=\"java script:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML image", + "markdown": "<img src=\"java script:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "newline scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML link", + "markdown": "<a href=\"java script:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML image", + "markdown": "<img src=\"java script:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials link", + "markdown": "[click](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials image", + "markdown": "![picture](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference link", + "markdown": "[click][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference image", + "markdown": "![picture][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML link", + "markdown": "<a href=\"https://paseo.sh@evil.example/login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML image", + "markdown": "<img src=\"https://paseo.sh@evil.example/login\">", + "links": [], + "images": [] + }, + { + "name": "backslash link", + "markdown": "[click](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash image", + "markdown": "![picture](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash reference link", + "markdown": "[click][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash reference image", + "markdown": "![picture][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash HTML link", + "markdown": "<a href=\"https:\\evil.example/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "backslash HTML image", + "markdown": "<img src=\"https:\\evil.example/path\">", + "links": [], + "images": [] + }, + { + "name": "missing authority link", + "markdown": "[click](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority image", + "markdown": "![picture](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority reference link", + "markdown": "[click][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority reference image", + "markdown": "![picture][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML link", + "markdown": "<a href=\"https:evil.example/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML image", + "markdown": "<img src=\"https:evil.example/path\">", + "links": [], + "images": [] + }, + { + "name": "javascript autolink", + "markdown": "<javascript:alert(1)>", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript autolink", + "markdown": "<JaVaScRiPt:alert(1)>", + "links": [], + "images": [] + }, + { + "name": "data HTML autolink", + "markdown": "<data:text/html;base64,PHNjcmlwdD4=>", + "links": [], + "images": [] + }, + { + "name": "data image autolink", + "markdown": "<data:image/png;base64,AAAA>", + "links": [], + "images": [] + }, + { + "name": "vbscript autolink", + "markdown": "<vbscript:msgbox(1)>", + "links": [], + "images": [] + }, + { + "name": "http autolink", + "markdown": "<http://example.com/path>", + "links": [], + "images": [] + }, + { + "name": "ftp autolink", + "markdown": "<ftp://example.com/path>", + "links": [], + "images": [] + }, + { + "name": "mailto autolink", + "markdown": "<mailto:attacker@example.com>", + "links": [], + "images": [] + }, + { + "name": "HTTPS link", + "markdown": "[click](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "HTTPS image", + "markdown": "![picture](https://example.com/image.png)", + "links": [], + "images": ["https://example.com/image.png"] + }, + { + "name": "HTTPS autolink", + "markdown": "<https://example.com/path>", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTPS", + "markdown": "https://example.com/path", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTP", + "markdown": "http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "deceptive RTL text", + "markdown": "[paseo.sh‮evil](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "title injection", + "markdown": "[click](https://example.com/path \"\\\" onmouseover=alert(1)\")", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "table alignment", + "markdown": "| A |\n| :-: |\n| safe |", + "links": [], + "images": [] + }, + { + "name": "heading", + "markdown": "## Heading", + "links": [], + "images": [] + }, + { + "name": "IRC link", + "markdown": "[click](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC image", + "markdown": "![picture](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC autolink", + "markdown": "<irc://example.com/channel>", + "links": [], + "images": [] + }, + { + "name": "IRCS link", + "markdown": "[click](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS image", + "markdown": "![picture](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS autolink", + "markdown": "<ircs://example.com/channel>", + "links": [], + "images": [] + }, + { + "name": "XMPP link", + "markdown": "[click](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP image", + "markdown": "![picture](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP autolink", + "markdown": "<xmpp:attacker@example.com>", + "links": [], + "images": [] + }, + { + "name": "tel link", + "markdown": "[click](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel image", + "markdown": "![picture](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel autolink", + "markdown": "<tel:12345>", + "links": [], + "images": [] + }, + { + "name": "file link", + "markdown": "[click](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file image", + "markdown": "![picture](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file autolink", + "markdown": "<file:///etc/passwd>", + "links": [], + "images": [] + }, + { + "name": "blob link", + "markdown": "[click](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob image", + "markdown": "![picture](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob autolink", + "markdown": "<blob:https://example.com/id>", + "links": [], + "images": [] + }, + { + "name": "named entity link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity autolink", + "markdown": "<javascript:alert(1)>", + "links": [], + "images": [] + }, + { + "name": "double encoded entity link", + "markdown": "[click](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity image", + "markdown": "![picture](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity autolink", + "markdown": "<javascript&colon;alert(1)>", + "links": [], + "images": [] + } +] diff --git a/packages/relay/package.json b/packages/relay/package.json index 58c0f442cd..d53a1a870e 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.11.0-beta.5", + "version": "0.11.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/.env.example b/packages/server/.env.example index ba031a16b5..55a2bded69 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -11,9 +11,10 @@ OPENAI_TTS_BASE_URL= # TTS Configuration (optional - defaults shown) TTS_VOICE=alloy -# Available voices: alloy, echo, fable, onyx, nova, shimmer +# Any voice the server knows: OpenAI ships alloy, echo, fable, onyx, nova, shimmer TTS_MODEL=tts-1 -# Available models: tts-1 (faster), tts-1-hd (higher quality) +# OpenAI ships tts-1 (faster) and tts-1-hd (higher quality); self-hosted +# servers accept whatever model id they serve. Case sensitive. # Server Configuration # Location for runtime state (agents.json, etc.). Defaults to ~/.paseo diff --git a/packages/server/package.json b/packages/server/package.json index c11efb3571..6e5e2448a2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.11.0-beta.5", + "version": "0.11.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -107,11 +107,11 @@ "@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", diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 7589cb92a1..6f9754994a 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -29,6 +29,7 @@ async function runSupervisorFixture(options: { blockLogPath?: boolean; /** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */ closeOutput?: boolean; + env?: NodeJS.ProcessEnv; }): Promise<{ code: number | null; signal: NodeJS.Signals | null; @@ -84,7 +85,9 @@ async function runSupervisorFixture(options: { ]; const child = spawn(command, args, { cwd: repoRoot, - env: { ...process.env }, + // A Node color-conflict warning can hit closed stderr before runSupervisor installs + // its pipe-error handlers. This fixture exercises the supervisor's own output. + env: { ...process.env, ...options.env, NO_COLOR: undefined, FORCE_COLOR: undefined }, stdio: ["ignore", "pipe", "pipe"], }); @@ -205,10 +208,16 @@ describe("supervisor durable logging", () => { expect(result.stderr).toContain('"worker-json-stderr"'); }); - test("keeps supervising the worker when nobody reads its stdout or stderr", async () => { - const result = await runSupervisorFixture({ - closeOutput: true, - workerSource: ` + test.each([ + { colors: "inherited", env: {} }, + { colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } }, + ])( + "keeps supervising the worker when nobody reads its stdout or stderr with $colors colors", + async ({ env }) => { + const result = await runSupervisorFixture({ + closeOutput: true, + env, + workerSource: ` process.on("message", (message) => { if (message?.type === "paseo:graceful-shutdown") process.exit(0); }); @@ -220,14 +229,15 @@ describe("supervisor durable logging", () => { }, 500); setInterval(() => {}, 1000); `, - }); + }); - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - expect(result.log).toContain("first stderr line\n"); - expect(result.log).toContain("later stdout line\n"); - expect(result.log).toContain('"reason":"closed_output_probe"'); - }); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.log).toContain("first stderr line\n"); + expect(result.log).toContain("later stdout line\n"); + expect(result.log).toContain('"reason":"closed_output_probe"'); + }, + ); test("preserves raw non-JSON stdout and stderr lines", async () => { const result = await runSupervisorFixture({ diff --git a/packages/server/scripts/transcribe-local-wav.test.ts b/packages/server/scripts/transcribe-local-wav.test.ts new file mode 100644 index 0000000000..703025e2c5 --- /dev/null +++ b/packages/server/scripts/transcribe-local-wav.test.ts @@ -0,0 +1,40 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, test } from "vitest"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("transcribe-local-wav CLI", () => { + test("initializes the complete speech provider configuration", () => { + const modelsDir = mkdtempSync(path.join(tmpdir(), "paseo-transcribe-local-")); + tempDirs.push(modelsDir); + const missingWav = path.join(modelsDir, "missing.wav"); + const scriptPath = fileURLToPath(new URL("./transcribe-local-wav.ts", import.meta.url)); + + const result = spawnSync( + process.execPath, + ["--import", "tsx", scriptPath, missingWav, "--models-dir", modelsDir], + { + encoding: "utf8", + timeout: 15_000, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).not.toBe(0); + // The worker client is lazy, so reaching the missing WAV proves provider initialization completed first. + expect(result.stderr).toContain("ENOENT"); + expect(result.stderr).toContain(missingWav); + expect(result.stderr).not.toContain("Cannot read properties of undefined"); + }); +}); diff --git a/packages/server/scripts/transcribe-local-wav.ts b/packages/server/scripts/transcribe-local-wav.ts index 0b323df029..a9cb10b7a2 100644 --- a/packages/server/scripts/transcribe-local-wav.ts +++ b/packages/server/scripts/transcribe-local-wav.ts @@ -120,6 +120,7 @@ async function main(): Promise<void> { const providers: RequestedSpeechProviders = { dictationStt: { provider: "local", explicit: true }, + voiceTurnDetection: { provider: "local", explicit: false, enabled: false }, voiceStt: { provider: "local", explicit: true }, // Not used here, but required by the shared runtime config shape. voiceTts: { provider: "openai", explicit: false }, diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index 59c218511b..97bb8af3a3 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -1,5 +1,6 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; +import { delimiter, resolve } from "node:path"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { execCommand } from "../utils/spawn.js"; import { windowsExecutableResolution } from "./windows.js"; @@ -120,6 +121,29 @@ export function executableExists( return exists(executablePath) ? executablePath : null; } +// PATH lookup that never runs the candidate, unlike findExecutable which probes +// each one with `--version`. A terminal spawn path cannot afford that probe: it +// would execute the profile command once before the terminal even opens. +// `path` overrides the PATH to search, so a caller can resolve against the +// environment its child process will actually get rather than the daemon's own. +export async function findExecutableWithoutProbing( + name: string, + options: { path?: string; cwd: string }, +): Promise<string | null> { + if (!name) { + return null; + } + const path = + options.path === undefined + ? undefined + : options.path + .split(delimiter) + .map((entry) => resolve(options.cwd, entry)) + .join(delimiter); + const candidates = await enumerateCandidatesViaLibrary(name, { PATH: path }); + return candidates[0] ?? null; +} + export interface ExecutableResolutionOptions { probeTimeoutMs?: number; env?: ProcessEnvRecord; diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 15ad9db90e..58a8a8b4f1 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -39,6 +39,45 @@ function row(seq: number, item: AgentTimelineItem): AgentTimelineRow { } describe("curateAgentActivity", () => { + it("keeps reasoning before the assistant text that followed it", () => { + const timeline: AgentTimelineItem[] = [ + { type: "reasoning", text: "Check the commands first." }, + { type: "assistant_message", text: "I will list the commands." }, + toolCallItem({ + callId: "shell-order", + name: "Bash", + detail: { + type: "shell", + command: "ls packages/cli/src/commands/agent/", + output: "", + exitCode: 0, + }, + }), + ]; + + expect(curateAgentActivity(timeline)).toBe( + "[Thought] Check the commands first.\nI will list the commands.\n[Shell] ls packages/cli/src/commands/agent/", + ); + }); + + it("preserves alternating text kinds, labels, and fragment concatenation", () => { + const timeline: AgentTimelineItem[] = [ + { type: "assistant_message", text: "First message." }, + { type: "reasoning", text: "First " }, + { type: "reasoning", text: "thought." }, + { type: "assistant_message", text: "Second " }, + { type: "assistant_message", text: "message." }, + { type: "reasoning", text: "Second thought." }, + ]; + + expect(curateAgentActivity(timeline, { labelAssistantMessages: true })).toBe( + "[Assistant] First message.\n[Thought] First thought.\n[Assistant] Second message.\n[Thought] Second thought.", + ); + expect(curateAgentActivity(timeline, { maxItems: 2 })).toBe( + "Second message.\n[Thought] Second thought.", + ); + }); + it("renders user/assistant/reasoning entries", () => { const timeline: AgentTimelineItem[] = [ { type: "user_message", text: "Hello" }, @@ -438,3 +477,49 @@ second line'`, ).toThrow("Selected assistant message is no longer available."); }); }); + +it("bounds fork history to 100,000 characters while preserving the newest messages", () => { + const result = buildAgentForkContextAttachment({ + rows: [ + row(1, { type: "user_message", text: "old-message " + "x".repeat(1_100_000) }), + row(2, { type: "assistant_message", messageId: "checkpoint", text: "Latest answer." }), + row(3, { type: "user_message", text: "Beyond the selected checkpoint." }), + ], + boundaryMessageId: "checkpoint", + }); + expect(result.attachment.text.length).toBeLessThanOrEqual(100_000); + expect(result.attachment.text).toContain("[Earlier chat history omitted]"); + expect(result.attachment.text).toContain("[Assistant] Latest answer."); + expect(result.attachment.text).not.toContain("old-message"); + expect(result.attachment.text).not.toContain("Beyond the selected checkpoint."); + expect(result.attachment.text).toMatch(/^<chat-history-summary>\n/); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); +}); + +it("bounds one oversized fork message and metadata without losing its beginning and end", () => { + const result = buildAgentForkContextAttachment({ + rows: [ + row(1, { + type: "assistant_message", + text: "Beginning. " + "x".repeat(1_100_000) + " The end.", + }), + ], + agentTitle: "T".repeat(200_000), + cwd: "/" + "p".repeat(200_000), + }); + expect(result.attachment.text.length).toBe(100_000); + expect(result.attachment.text).toContain("[Assistant] Beginning."); + expect(result.attachment.text).toContain("[Message truncated]"); + expect(result.attachment.text).toContain("The end."); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); +}); + +it("preserves every message in fork history below the character budget", () => { + const text = "Complete conversation " + "x".repeat(90_000); + const result = buildAgentForkContextAttachment({ + rows: [row(1, { type: "user_message", text })], + }); + expect(result.attachment.text).toBe( + `<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] ${text}\n</chat-history-summary>`, + ); +}); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 3bcab550e7..efd0a8e48d 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -8,6 +8,9 @@ import { projectTimelineRows } from "./timeline-projection.js"; const DEFAULT_MAX_ITEMS = 0; const MAX_TOOL_INPUT_CHARS = 400; const MAX_TOOL_SUMMARY_CHARS = 200; +const MAX_FORK_CONTEXT_CHARS = 100_000; +const MAX_FORK_METADATA_CHARS = 1_000; +const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n"; interface ActivityCuratorOptions { maxItems?: number; @@ -22,36 +25,20 @@ interface ActivityEntry { type TextAgentAttachment = Extract<AgentAttachment, { type: "text" }>; -function appendText(buffer: string, text: string): string { - const normalized = text.trim(); - if (!normalized) { - return buffer; - } - if (!buffer) { - return normalized; - } - return `${buffer}\n${normalized}`; -} - function activityEntry(text: string): ActivityEntry { return { text }; } -function flushBuffers( +function appendTextEntry( entries: ActivityEntry[], - buffers: { message: string; thought: string }, + item: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>, options?: ActivityCuratorOptions, ) { - if (buffers.message.trim()) { - const text = buffers.message.trim(); - entries.push(activityEntry(options?.labelAssistantMessages ? `[Assistant] ${text}` : text)); - } - if (buffers.thought.trim()) { - const text = buffers.thought.trim(); - entries.push(activityEntry(`[Thought] ${text}`)); - } - buffers.message = ""; - buffers.thought = ""; + const text = item.text.trim(); + if (!text) return; + let prefix = options?.labelAssistantMessages ? "[Assistant] " : ""; + if (item.type === "reasoning") prefix = "[Thought] "; + entries.push(activityEntry(`${prefix}${text}`)); } function formatToolInputJson(input: unknown): string | null { @@ -144,7 +131,6 @@ function curateProjectedActivityEntries( const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items; const entries: ActivityEntry[] = []; - const buffers = { message: "", thought: "" }; for (const item of recentItems) { if (!shouldIncludeItem(item, options)) { @@ -153,17 +139,13 @@ function curateProjectedActivityEntries( switch (item.type) { case "user_message": - flushBuffers(entries, buffers, options); entries.push(activityEntry(`[User] ${item.text.trim()}`)); break; case "assistant_message": - buffers.message = appendText(buffers.message, item.text); - break; case "reasoning": - buffers.thought = appendText(buffers.thought, item.text); + appendTextEntry(entries, item, options); break; case "tool_call": { - flushBuffers(entries, buffers, options); entries.push(formatToolCallEntry(item, options)); if (item.detail.type === "sub_agent" && item.detail.log.trim()) { entries.push(activityEntry(item.detail.log.trim())); @@ -171,7 +153,6 @@ function curateProjectedActivityEntries( break; } case "todo": - flushBuffers(entries, buffers, options); entries.push(activityEntry("[Tasks]")); for (const entry of item.items) { const checkbox = entry.completed ? "[x]" : "[ ]"; @@ -180,18 +161,14 @@ function curateProjectedActivityEntries( } break; case "error": - flushBuffers(entries, buffers, options); entries.push(activityEntry(`[Error] ${item.message}`)); break; case "compaction": - flushBuffers(entries, buffers, options); entries.push(activityEntry("[Compacted]")); break; } } - flushBuffers(entries, buffers, options); - return entries; } @@ -276,7 +253,7 @@ function selectForkContextRows(input: { function trimContextMetadata(value: string | null | undefined): string | null { const trimmed = value?.trim(); - return trimmed ? trimmed : null; + return trimmed ? trimmed.slice(0, MAX_FORK_METADATA_CHARS) : null; } function buildForkContextText(input: { @@ -296,6 +273,30 @@ function buildForkContextText(input: { return `<chat-history-summary>\n${header.join("\n")}\n\n${input.body}\n</chat-history-summary>`; } +function fitForkContextEntries(entries: ActivityEntry[], budget: number): string { + const length = entries.reduce((total, entry) => total + entry.text.length + 1, -1); + if (length <= budget) return entries.map((entry) => entry.text).join("\n"); + + const selected: string[] = []; + let remaining = budget - FORK_HISTORY_OMITTED.length; + for (let index = entries.length - 1; index >= 0; index--) { + const text = entries[index]!.text; + const size = text.length + (selected.length > 0 ? 1 : 0); + if (size > remaining) { + if (selected.length === 0) { + const marker = "\n[Message truncated]\n"; + const available = remaining - marker.length; + const head = Math.ceil(available / 2); + selected.push(text.slice(0, head) + marker + text.slice(-(available - head))); + } + break; + } + selected.push(text); + remaining -= size; + } + return FORK_HISTORY_OMITTED + selected.toReversed().join("\n"); +} + export function buildAgentForkContextAttachment(input: { rows: readonly AgentTimelineRow[]; cursorBoundary?: ForkCursorBoundary | null; @@ -319,10 +320,10 @@ export function buildAgentForkContextAttachment(input: { includeKinds: ["user_message", "assistant_message", "tool_call"], includeExternalToolInput: false, }); + const metadata = { agentTitle: input.agentTitle, cwd: input.cwd }; + const budget = MAX_FORK_CONTEXT_CHARS - buildForkContextText({ body: "", ...metadata }).length; const body = - entries.length > 0 - ? entries.map((entry) => entry.text).join("\n") - : "No chat history to display."; + entries.length > 0 ? fitForkContextEntries(entries, budget) : "No chat history to display."; return { attachment: { type: "text", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 6396fc589f..763659e9f4 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,3 +1,8 @@ +import { + prepareAgentMessage, + formatAgentMessage, + formatSystemNotificationPrompt, +} from "./agent-messages/index.js"; import { expect, test, vi } from "vitest"; import { spawn } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -18,7 +23,7 @@ import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; import { toAgentPayload } from "./agent-projections.js"; import { projectTimelineRows } from "./timeline-projection.js"; import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; -import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js"; +import { startAgentRun } from "./agent-prompt.js"; import { StaleProviderSessionError } from "./stale-provider-session-error.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js"; import type { StoredAgentRecord } from "./agent-storage.js"; @@ -3949,12 +3954,23 @@ test("a prompt after provider replacement reopens the stale session", async () = clients: { [provider]: replacement }, }); - const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger); + const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger, { + runOptions: { clientMessageId: "stale-session-retry" }, + }); expect(dispatch.disposition).toBe("turn_started"); await replacement.waitForRetryStart(); const result = await manager.waitForAgentEvent(created.id); expect(result.status).toBe("idle"); expect(replacement.resumeOverrides).toHaveLength(1); + const timeline = manager.getTimeline(created.id); + expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([ + { text: "continue after reload", clientMessageId: "stale-session-retry" }, + ]); + expect( + timeline.some( + (item) => item.type === "assistant_message" && item.text.includes("[System Error]"), + ), + ).toBe(false); } finally { await manager.closeAgent(created.id).catch(() => undefined); await manager.flush().catch(() => undefined); @@ -7694,6 +7710,81 @@ test("listAgents excludes internal agents", async () => { const agents = manager.listAgents(); expect(agents).toHaveLength(1); expect(agents[0]?.config.title).toBe("Normal Agent"); + + // Opting in lists both, and the internal one is marked. + const everyAgent = manager.listAgents({ includeInternal: true }); + expect(everyAgent.map((agent) => [agent.config.title, agent.internal])).toEqual([ + ["Normal Agent", false], + ["Internal Agent", true], + ]); +}); + +test("workspace ownership does not turn an ordinary agent into a disposable helper", async () => { + const agentId = "00000000-0000-4000-8000-000000000115"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const beforeHookCalls: string[] = []; + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + idFactory: () => agentId, + pluginLifecycle: { + emit: () => {}, + before: async (name, request) => { + beforeHookCalls.push(name); + return request; + }, + }, + }); + + await manager.createAgent({ provider: "codex", cwd: workdir, title: "Helper" }, undefined, { + workspaceId: "wks_internal", + }); + + expect(manager.getAgent(agentId)?.internal).toBe(false); + expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]); + expect(await storage.get(agentId)).not.toBeNull(); + // Workspace ownership preserves ordinary agent lifecycle hooks. + expect(beforeHookCalls).toContain("agent.create"); +}); + +test("global subscribers receive internal agent events only when they opt in", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000116"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + idFactory: () => internalAgentId, + }); + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Agent", internal: true }, + undefined, + { workspaceId: undefined }, + ); + + const hidden: string[] = []; + const optedIn: string[] = []; + const unsubscribeHidden = manager.subscribe((event) => { + if (event.type === "agent_state") hidden.push(event.agent.id); + }); + const unsubscribeOptedIn = manager.subscribe( + (event) => { + if (event.type === "agent_state") optedIn.push(event.agent.id); + }, + { includeInternal: true }, + ); + + expect(hidden).toEqual([]); + expect(optedIn).toEqual([internalAgentId]); + + await manager.archiveAgent(internalAgentId); + expect(hidden).toEqual([]); + expect(optedIn.length).toBeGreaterThan(1); + unsubscribeHidden(); + unsubscribeOptedIn(); }); test("getAgent returns internal agents by ID", async () => { @@ -7726,6 +7817,124 @@ test("getAgent returns internal agents by ID", async () => { expect(agent?.internal).toBe(true); }); +test("archiveAgent closes an internal agent without writing a record", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000108"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => internalAgentId, + }); + + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }, + undefined, + { workspaceId: undefined }, + ); + + const { archivedAt } = await manager.archiveAgent(internalAgentId); + + expect(Number.isNaN(Date.parse(archivedAt))).toBe(false); + expect(manager.getAgent(internalAgentId)).toBeNull(); + expect(await storage.get(internalAgentId)).toBeNull(); + expect(await storage.list()).toEqual([]); + expect(manager.getRetiredInternalAgent(internalAgentId)).toEqual({ + record: expect.objectContaining({ + id: internalAgentId, + title: "Internal Agent", + internal: true, + archivedAt, + lastStatus: "idle", + }), + lastMessage: null, + }); + expect(manager.getRetiredInternalAgent("00000000-0000-4000-8000-000000000999")).toBeNull(); +}); + +test("archiveAgent on an internal parent still archives its attached children", async () => { + const parentAgentId = "00000000-0000-4000-8000-000000000109"; + const childAgentId = "00000000-0000-4000-8000-000000000110"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const ids = [parentAgentId, childAgentId]; + let agentCounter = 0; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => ids[agentCounter++] ?? randomUUID(), + }); + + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Parent", internal: true }, + undefined, + { workspaceId: undefined }, + ); + await manager.createAgent({ provider: "codex", cwd: workdir, title: "Child" }, undefined, { + workspaceId: undefined, + labels: { "paseo.parent-agent-id": parentAgentId }, + }); + + await manager.archiveAgent(parentAgentId); + + expect(manager.getAgent(parentAgentId)).toBeNull(); + expect(manager.getAgent(childAgentId)).toBeNull(); + const child = await storage.get(childAgentId); + expect(child?.archivedAt).toEqual(expect.any(String)); + expect(await storage.get(parentAgentId)).toBeNull(); +}); + +test("archiveAgent on a parent archives its live internal children", async () => { + const parentAgentId = "00000000-0000-4000-8000-000000000111"; + const childAgentId = "00000000-0000-4000-8000-000000000112"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const ids = [parentAgentId, childAgentId]; + let agentCounter = 0; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => ids[agentCounter++] ?? randomUUID(), + }); + + await manager.createAgent({ provider: "codex", cwd: workdir, title: "Parent" }, undefined, { + workspaceId: undefined, + }); + // An internal child is never in storage, so only the live scan can reach it. + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Child", internal: true }, + undefined, + { workspaceId: undefined, labels: { "paseo.parent-agent-id": parentAgentId } }, + ); + expect(manager.getAgent(childAgentId)).not.toBeNull(); + + await manager.archiveAgent(parentAgentId); + + expect(manager.getAgent(parentAgentId)).toBeNull(); + expect(manager.getAgent(childAgentId)).toBeNull(); + expect(manager.getRetiredInternalAgent(childAgentId)?.record.archivedAt).toEqual( + expect.any(String), + ); + expect(await storage.get(childAgentId)).toBeNull(); +}); + test("subscribe does not emit state events for internal agents to global subscribers", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -8136,9 +8345,20 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist { workspaceId: undefined }, ); - await expect(manager.runAgent(agent.id, "fail before turn id")).rejects.toThrow( - "Invalid request: missing field `text`", - ); + await expect( + manager.runAgent(agent.id, "fail before turn id", { + clientMessageId: "rejected-prompt", + }), + ).rejects.toThrow("Invalid request: missing field `text`"); + expect(manager.getTimeline(agent.id)).toEqual([ + { + type: "user_message", + text: "fail before turn id", + clientMessageId: "rejected-prompt", + messageId: "rejected-prompt", + }, + { type: "assistant_message", text: "[System Error] Invalid request: missing field `text`" }, + ]); await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual( expect.objectContaining({ @@ -11467,3 +11687,173 @@ test("usage session is a pure read of the live adapter and disappears on close", } expect(manager.usageSession(agent.id)).toBeNull(); }); + +test.each(["live", "history"])( + "agent messages become notification tools during %s ingestion", + async (path) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-message-provenance-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const item: AgentTimelineItem = { + type: "user_message", + text: '<paseo-system version="1" kind="agent-message" source-agent-id="remote::sender" message-id="delivery-1">\nReview <changes> & report back.\n</paseo-system>', + messageId: "provider-message", + }; + const codex = fakeCodexEmitting( + path === "live" ? { turnItems: [item] } : { historyItems: [item] }, + ); + const manager = new AgentManager({ clients: { codex }, registry: storage, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + if (path === "live") await manager.runAgent(agent.id, { text: "start" }); + else await manager.hydrateTimelineFromProvider(agent.id, { force: true }); + expect(manager.getTimeline(agent.id)).toContainEqual( + expect.objectContaining({ + type: "tool_call", + callId: "paseo-agent-message:delivery-1", + agentMessage: { + event: "message", + sender: { id: "remote::sender" }, + text: "Review <changes> & report back.", + }, + name: "agent_message", + status: "completed", + detail: { + type: "plain_text", + icon: "bot", + text: "Review <changes> & report back.", + }, + }), + ); + expect(manager.getTimeline(agent.id).filter((entry) => entry.type === "user_message")).toEqual( + [], + ); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + }, +); + +test.each([false, true])( + "accepted agent prompt appears once with provider echo=%s", + async (echo) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-message-acceptance-")); + const delivery = prepareAgentMessage( + "Please review", + { kind: "agent-message", agentId: "remote::sender" }, + "accepted-delivery", + ); + const codex = fakeCodexEmitting({ + turnItems: echo + ? [ + { + type: "user_message", + text: String(delivery.prompt), + clientMessageId: delivery.messageId, + messageId: "native-echo", + }, + ] + : [], + }); + const manager = new AgentManager({ clients: { codex }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + for await (const _event of manager.streamAgent(agent.id, delivery.prompt, { + clientMessageId: delivery.messageId, + })) { + } + expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([ + expect.objectContaining({ + callId: "paseo-agent-message:accepted-delivery", + status: "completed", + }), + ]); + expect(manager.getTimeline(agent.id).filter((item) => item.type === "user_message")).toEqual( + [], + ); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); + +test("a rejected agent prompt never becomes a delivered notification", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-message-rejected-")); + const delivery = prepareAgentMessage( + "Please review", + { kind: "agent-message", agentId: "sender" }, + "rejected", + ); + class RejectingSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + throw new Error("provider rejected delivery"); + } + } + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig) { + return new RejectingSession(config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + const run = manager.streamAgent(agent.id, delivery.prompt, { + clientMessageId: delivery.messageId, + }); + await expect(run.next()).rejects.toThrow("provider rejected delivery"); + expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([]); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test.each([false, true])( + "human envelope examples remain human after echo with correlation=%s", + async (correlated) => { + const workdir = mkdtempSync(join(tmpdir(), "human-envelope-example-")); + const example = formatAgentMessage({ + id: "example", + source: { kind: "agent-message", agentId: "sender" }, + text: "hello", + }); + const delivery = prepareAgentMessage(example, undefined, "human-submission"); + const codex = fakeCodexEmitting({ + turnItems: [ + { + type: "user_message", + text: String(delivery.prompt), + messageId: "provider-echo", + ...(correlated ? { clientMessageId: "human-submission" } : {}), + }, + ], + }); + const manager = new AgentManager({ clients: { codex }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + for await (const _event of manager.streamAgent(agent.id, delivery.prompt, { + clientMessageId: delivery.messageId, + })) { + } + expect(manager.getTimeline(agent.id)).toEqual([ + { + type: "user_message", + text: example, + clientMessageId: "human-submission", + messageId: "human-submission", + }, + ]); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeInstanceOf(Date); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 9e809a457f..e09d62f6a9 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -55,6 +55,7 @@ import { type ListImportableSessionsOptions, } from "./agent-sdk-types.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; +import { toStoredAgentRecord } from "./agent-projections.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; import type { AgentOwner } from "./agent-owner.js"; import { @@ -78,7 +79,7 @@ import { type PendingForegroundRun, } from "./agent-run-state.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; -import { isSystemInjectedEnvelope } from "./agent-prompt.js"; +import { projectAgentMessage } from "./agent-messages/index.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; @@ -243,6 +244,8 @@ export type AgentSubscriber = (event: AgentManagerEvent) => void; export interface SubscribeOptions { agentId?: string; replayState?: boolean; + /** Receive events for internal agents too. Off by default for global subscribers. */ + includeInternal?: boolean; } interface HydrateTimelineOptions { @@ -316,6 +319,7 @@ export interface CreateAgentOptions { export interface AgentManagerOptions { pluginLifecycle?: PluginLifecycle; + /** An internal workspace makes every agent created inside it internal. */ clients?: ProviderClientMap; providerDefinitions?: ProviderEnabledMap; idFactory?: () => string; @@ -568,6 +572,7 @@ function attachPersistenceCwd( interface SubscriptionRecord { callback: AgentSubscriber; agentId: string | null; + includeInternal: boolean; } interface SteerEventBarrier { @@ -652,13 +657,12 @@ function buildExplicitTimelineSeedForRegister( function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] { const rows: AgentTimelineRow[] = []; for (const entry of entries) { - if (entry.item.type === "user_message" && isSystemInjectedEnvelope(entry.item.text)) { - continue; - } + const item = projectAgentMessage(entry.item); + if (!item) continue; rows.push({ seq: rows.length + 1, timestamp: entry.timestamp ?? new Date().toISOString(), - item: limitAgentTimelineItemContent(entry.item), + item: limitAgentTimelineItemContent(item), }); } return rows; @@ -694,7 +698,7 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str } function shouldDetachFromArchivedParent( - parent: StoredAgentRecord, + parent: Pick<StoredAgentRecord, "workspaceId">, child: StoredAgentRecord, ): boolean { const isCrossWorkspace = @@ -714,8 +718,25 @@ function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatc return patch; } +/** + * How long an archived internal agent stays readable by id. Storage plays this + * role for public agents; internal agents never reach it, and a one-shot helper + * can finish and auto-archive before its creator's `waitForFinish` arrives. + */ +const RETIRED_INTERNAL_AGENT_TTL_MS = 10 * 60 * 1000; +const RETIRED_INTERNAL_AGENT_LIMIT = 500; + +export interface RetiredInternalAgent { + record: ArchivedStoredAgentRecord; + lastMessage: string | null; +} + export class AgentManager { private readonly pluginLifecycle: PluginLifecycle | undefined; + private readonly retiredInternalAgents = new Map< + string, + RetiredInternalAgent & { expiresAt: number } + >(); private readonly clients = new Map<AgentProvider, AgentClient>(); private readonly providerEnabled = new Map<AgentProvider, boolean>(); private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>(); @@ -949,6 +970,7 @@ export class AgentManager { const record: SubscriptionRecord = { callback, agentId: targetAgentId, + includeInternal: options?.includeInternal === true, }; this.subscribers.add(record); @@ -962,9 +984,9 @@ export class AgentManager { }); } } else { - // For global subscribers, skip internal agents during replay + // Global subscribers skip internal agents unless they opted in. for (const agent of this.agents.values()) { - if (agent.internal) { + if (agent.internal && !record.includeInternal) { continue; } callback({ @@ -984,12 +1006,26 @@ export class AgentManager { return this.subscribers.size; } - listAgents(): ManagedAgent[] { + listAgents(options?: { includeInternal?: boolean }): ManagedAgent[] { return Array.from(this.agents.values()) - .filter((agent) => !agent.internal) + .filter((agent) => options?.includeInternal === true || !agent.internal) .map((agent) => Object.assign({}, agent)); } + /** Archived internal agents still readable by id; see getRetiredInternalAgent. */ + listRetiredInternalAgents(): RetiredInternalAgent[] { + const now = Date.now(); + const retired: RetiredInternalAgent[] = []; + for (const [agentId, entry] of this.retiredInternalAgents) { + if (entry.expiresAt <= now) { + this.retiredInternalAgents.delete(agentId); + continue; + } + retired.push({ record: entry.record, lastMessage: entry.lastMessage }); + } + return retired; + } + async listImportableSessions( options?: ImportablePersistedAgentQueryOptions, ): Promise<ManagedImportableSessionsResult> { @@ -1673,6 +1709,42 @@ export class AgentManager { } } + /** + * The archived snapshot of an internal agent, for a while after archive. + * Null once it has expired or was never an internal agent. + */ + getRetiredInternalAgent(agentId: string): RetiredInternalAgent | null { + const entry = this.retiredInternalAgents.get(agentId); + if (!entry) { + return null; + } + if (entry.expiresAt <= Date.now()) { + this.retiredInternalAgents.delete(agentId); + return null; + } + return { record: entry.record, lastMessage: entry.lastMessage }; + } + + private retireInternalAgent(entry: RetiredInternalAgent): void { + const now = Date.now(); + for (const [agentId, retired] of this.retiredInternalAgents) { + if (retired.expiresAt <= now) { + this.retiredInternalAgents.delete(agentId); + } + } + while (this.retiredInternalAgents.size >= RETIRED_INTERNAL_AGENT_LIMIT) { + const oldest = this.retiredInternalAgents.keys().next().value; + if (oldest === undefined) { + break; + } + this.retiredInternalAgents.delete(oldest); + } + this.retiredInternalAgents.set(entry.record.id, { + ...entry, + expiresAt: now + RETIRED_INTERNAL_AGENT_TTL_MS, + }); + } + closeAgent(agentId: string): Promise<void> { const existing = this.inFlightAgentCloses.get(agentId); if (existing) { @@ -1758,6 +1830,23 @@ export class AgentManager { requestedArchivedAt?: string, ): Promise<{ archivedAt: string }> { const agent = this.requireAgent(agentId); + if (agent.internal) { + // Nothing about an internal agent is on disk, and archiving must not put + // it there. Its final snapshot is kept in memory for a while (see + // getRetiredInternalAgent); the runtime is closed and the committed + // timeline dropped, so `getAgent` returns null from here on. + const archivedAt = requestedArchivedAt ?? new Date().toISOString(); + const lastMessage = await this.getLastAssistantMessage(agentId); + const record = buildArchivedAgentRecord( + toStoredAgentRecord(agent, { title: agent.config.title ?? null, internal: true }), + { archivedAt, updatedAt: archivedAt }, + ); + await this.closeAgentRuntime(agentId); + await this.deleteAgentState(agentId); + this.retireInternalAgent({ record, lastMessage }); + await this.cascadeArchiveChildren(record); + return { archivedAt }; + } if (!this.registry) { throw new Error("Agent storage is not configured"); } @@ -1776,7 +1865,7 @@ export class AgentManager { await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive"); this.discardRetainedAgentState(agentId); - await this.cascadeArchiveChildren(agentId); + await this.cascadeArchiveChildren(stored); return { archivedAt }; } @@ -1785,16 +1874,15 @@ export class AgentManager { // label pointing back at the caller. Archiving the parent cascades to those // children so subagent fleets don't outlive their orchestrator. Detached // handoff agents omit this label, so they stand outside the cascade. - private async cascadeArchiveChildren(parentAgentId: string): Promise<void> { + private async cascadeArchiveChildren( + parent: Pick<StoredAgentRecord, "id" | "workspaceId">, + ): Promise<void> { const registry = this.registry; if (!registry) { return; } + const parentAgentId = parent.id; const records = await registry.list(); - const parent = records.find((record) => record.id === parentAgentId); - if (!parent) { - throw new Error(`Archived parent ${parentAgentId} not found in storage`); - } for (const record of records) { if (record.archivedAt) { continue; @@ -1824,6 +1912,21 @@ export class AgentManager { } }); } + // Internal children never reach storage, so the scan above cannot see them. + // They have no tab to keep open and no listing to detach into, so they + // always archive with the parent. + for (const child of Array.from(this.agents.values())) { + if (!child.internal || getParentAgentIdFromLabels(child.labels) !== parentAgentId) { + continue; + } + await this.runLifecycleMutation(child.id, async () => { + const current = this.agents.get(child.id); + if (!current || getParentAgentIdFromLabels(current.labels) !== parentAgentId) { + return; + } + await this.archiveAgentUnlocked(current.id); + }); + } } private async markRecordArchived( @@ -2199,7 +2302,7 @@ export class AgentManager { if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord); await this.fireAgentArchived(agentId); - await this.cascadeArchiveChildren(agentId); + await this.cascadeArchiveChildren(record); return nextRecord; } @@ -2361,7 +2464,7 @@ export class AgentManager { return false; } if (options?.clientMessageId) { - this.recordSubmittedPrompt(agent, prompt, options.clientMessageId); + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted"); this.emitState(agent); } const dispatch = (event: AgentStreamEvent): void => { @@ -2457,6 +2560,12 @@ export class AgentManager { agent.pendingReplacement = false; const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; pendingRun.start = { status: "failed", error: errorMsg }; + // A terminal rejection belongs after the submitted prompt even though no provider turn exists. + if (options?.clientMessageId) { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", { + messageId: options.clientMessageId, + }); + } await this.handleStreamEvent(agent, { type: "turn_failed", provider: agent.provider, @@ -2546,7 +2655,7 @@ export class AgentManager { ) : undefined; if (options?.clientMessageId) { - this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted", { messageId: options.clientMessageId, turnId, providerMessageId: @@ -2852,7 +2961,7 @@ export class AgentManager { if (!clientMessageId) { return; } - this.recordSubmittedPrompt(agent, prompt, clientMessageId, { + this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", { messageId: clientMessageId, turnId: expectedTurnId, }); @@ -3511,7 +3620,7 @@ export class AgentManager { // Legacy/imported chats need their existing history before startup rows. await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory); } else { - for (const entry of session.initialTimeline) { + for (const entry of buildImportedTimelineRows(session.initialTimeline)) { this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp }); } } @@ -4018,10 +4127,9 @@ export class AgentManager { for await (const rawEvent of agent.session.streamHistory()) { const event = limitAgentStreamEventContent(rawEvent); if (event.type === "timeline") { - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { - continue; - } - historyEvents.push(event); + const item = projectAgentMessage(event.item); + if (!item) continue; + historyEvents.push({ ...event, item }); } else if (event.type === "provider_subagent") { providerSubagentEvents.push(event); } @@ -4086,10 +4194,9 @@ export class AgentManager { if (event.type !== "timeline") { continue; } - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { - continue; - } - historyEvents.push(event); + const item = projectAgentMessage(event.item); + if (!item) continue; + historyEvents.push({ ...event, item }); } } catch (error) { this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history"); @@ -4421,12 +4528,14 @@ export class AgentManager { }): Promise<void> { const { agent, event, options, flags } = params; - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { + const item = projectAgentMessage(event.item); + if (!item) { flags.shouldDispatchEvent = false; flags.shouldNotifyWaiters = false; return; } + event.item = item; if ( event.item.type === "user_message" && event.item.clientMessageId && @@ -4697,19 +4806,34 @@ export class AgentManager { agent: ActiveManagedAgent, prompt: AgentPromptInput, clientMessageId: string, + outcome: "accepted" | "rejected", options?: { messageId?: string; providerMessageId?: string; turnId?: string }, ): void { - if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) { - return; - } - this.touchUpdatedAt(agent); - agent.lastUserMessageAt = new Date(); - const item: AgentTimelineItem = { + const item = projectAgentMessage({ type: "user_message", text: submittedPromptText(prompt), clientMessageId, ...(options?.messageId ? { messageId: options.messageId } : {}), - }; + }); + if (!item) return; + // Human attempts stay in history on rejection; delivery notifications require acceptance. + if (outcome === "rejected" && item.type !== "user_message") return; + this.recordSubmittedPromptItem(agent, item, options); + } + + private recordSubmittedPromptItem( + agent: ActiveManagedAgent, + item: AgentTimelineItem, + options?: { providerMessageId?: string; turnId?: string }, + ): void { + if ( + item.type === "user_message" && + item.clientMessageId && + this.timelineStore.getSubmittedUserMessage(agent.id, item.clientMessageId) + ) + return; + this.touchUpdatedAt(agent); + if (item.type === "user_message") agent.lastUserMessageAt = new Date(); this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, options?.turnId, options); } @@ -4722,11 +4846,14 @@ export class AgentManager { if (!clientMessageId) return null; let existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); if (!existing) { - this.recordSubmittedPrompt(agent, item.text, clientMessageId, { - messageId: clientMessageId, - ...(messageId ? { providerMessageId: messageId } : {}), - ...(turnId ? { turnId } : {}), - }); + this.recordSubmittedPromptItem( + agent, + { ...item, messageId: clientMessageId }, + { + ...(messageId ? { providerMessageId: messageId } : {}), + ...(turnId ? { turnId } : {}), + }, + ); existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); } if (!existing || existing.item.type !== "user_message") return null; @@ -5061,8 +5188,12 @@ export class AgentManager { ) { continue; } - // Skip internal agents for global subscribers (those without a specific agentId) - if (!subscriber.agentId && this.eventBelongsToInternalAgent(event)) { + // Global subscribers (no agentId) skip internal agents unless they opted in. + if ( + !subscriber.agentId && + !subscriber.includeInternal && + this.eventBelongsToInternalAgent(event) + ) { continue; } subscriber.callback(event); diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index d1422f5b6b..cf9711a9df 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -215,22 +215,19 @@ function createMcpRecordingAgentClients(recorder: LaunchRecorder) { }; } -async function assertAgentNotRunning(options: { - client: McpClient; - agentId: string; -}): Promise<void> { - const statusResult = await options.client.callTool({ - name: "get_agent_status", - args: { agentId: options.agentId }, - }); - const payload = getStructuredContent(statusResult); - if (!payload) { - throw new Error("get_agent_status returned no structured payload"); - } - const status = payload.status; - if (status === "running" || status === "initializing") { - throw new Error(`Agent still running after blocking create_agent (status=${status})`); - } +async function waitForAgentFinish(options: { client: McpClient; agentId: string }): Promise<void> { + await expect + .poll( + async () => { + const result = await options.client.callTool({ + name: "get_agent_status", + args: { agentId: options.agentId }, + }); + return getStructuredContent(result)?.status; + }, + { timeout: 10000 }, + ) + .toBe("idle"); } describe("agent MCP end-to-end (offline)", () => { @@ -276,7 +273,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt, - background: false, }, }); @@ -284,7 +280,7 @@ describe("agent MCP end-to-end (offline)", () => { agentId = typeof payload?.agentId === "string" ? payload.agentId : null; expect(agentId).toBeTruthy(); - await assertAgentNotRunning({ client, agentId: agentId! }); + await waitForAgentFinish({ client, agentId: agentId! }); if (existsSync(filePath)) { const contents = await readFile(filePath, "utf8"); @@ -377,7 +373,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const payload = getStructuredContent(result); @@ -452,7 +447,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const payload = getStructuredContent(result); @@ -476,7 +470,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const disabledPayload = getStructuredContent(disabledResult); @@ -541,7 +534,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const payload = getStructuredContent(result); @@ -601,7 +593,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "codex/gpt-5.4-mini", mode: "full-access", initialPrompt: "Run exactly: sleep 30", - background: true, }, }); @@ -772,7 +763,6 @@ describe("agent MCP end-to-end (offline)", () => { const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`); - let agentId: string | null = null; try { const result = await client.callTool({ name: "create_agent", @@ -782,29 +772,13 @@ describe("agent MCP end-to-end (offline)", () => { provider: "codex/gpt-5.4-mini", mode: "full-access", initialPrompt: "Run exactly: sleep 30", - background: true, }, }); - const payload = getStructuredContent(result); - agentId = typeof payload?.agentId === "string" ? payload.agentId : null; - expect(agentId).toBeTruthy(); - - await assertAgentNotRunning({ client, agentId: agentId! }); - const statusResult = await client.callTool({ - name: "get_agent_status", - args: { agentId }, - }); - const statusPayload = getStructuredContent(statusResult); - expect(statusPayload?.status).toBe("error"); - const snapshot = statusPayload?.snapshot; - const lastError = - snapshot && typeof snapshot === "object" ? Reflect.get(snapshot, "lastError") : undefined; - expect(lastError).toContain("Initial turn failed to start"); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("Initial turn failed to start"); + expect(getStructuredContent(result)?.agentId).toBeUndefined(); } finally { - if (agentId) { - await client.callTool({ name: "kill_agent", args: { agentId } }); - } await client.close(); await daemon.stop(); await rm(paseoHome, { recursive: true, force: true }); @@ -880,7 +854,6 @@ describe("agent MCP end-to-end (offline)", () => { initialPrompt: "say done and stop", worktreeName: "mcp-worktree-setup-test", baseBranch: "main", - background: true, }, }), timeoutMs: 2500, diff --git a/packages/server/src/server/agent/agent-messages/index.ts b/packages/server/src/server/agent/agent-messages/index.ts new file mode 100644 index 0000000000..1543bf33b4 --- /dev/null +++ b/packages/server/src/server/agent/agent-messages/index.ts @@ -0,0 +1,191 @@ +import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import type { + AgentPromptContentBlock, + AgentPromptInput, + AgentTimelineItem, +} from "../agent-sdk-types.js"; + +const agentIdSchema = z.string().min(1); +const sourceSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("agent-message"), + agentId: agentIdSchema, + title: z.string().min(1).optional(), + }), + z.object({ + kind: z.literal("agent-notification"), + agentId: agentIdSchema, + title: z.string().min(1).optional(), + event: z.enum(["finished", "errored", "permission-required", "closed"]), + }), +]); +export type AgentPromptSource = z.infer<typeof sourceSchema>; + +interface AgentMessage { + id: string; + source: AgentPromptSource | null; + text: string; +} + +function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +function unescapeXml(value: string): string | null { + if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null; + return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => { + switch (entity) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return '"'; + default: + return "'"; + } + }); +} + +export function formatAgentMessage(message: AgentMessage): string { + const { source } = message; + const attributes = source + ? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"` + : 'kind="user-message"'; + const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : ""; + const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : ""; + return `<paseo-system version="1" ${attributes} message-id="${escapeXml(message.id)}"${title}${event}>\n${escapeXml(message.text)}\n</paseo-system>`; +} + +export function parseAgentMessage(text: string): AgentMessage | null { + const envelope = /^<paseo-system((?:\s+[a-z-]+="[^"<>]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec( + text, + ); + if (!envelope) return null; + const attributes = new Map<string, string>(); + for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) { + const value = unescapeXml(match[2]); + if (value === null || attributes.has(match[1])) return null; + attributes.set(match[1], value); + } + if (attributes.get("version") !== "1") return null; + const id = attributes.get("message-id"); + const body = unescapeXml(envelope[2]); + if (!id || body === null) return null; + if (attributes.get("kind") === "user-message") { + if (attributes.has("source-agent-id") || attributes.has("event")) return null; + return { id, source: null, text: body }; + } + const source = sourceSchema.safeParse({ + kind: attributes.get("kind"), + agentId: attributes.get("source-agent-id"), + event: attributes.get("event"), + title: attributes.get("source-agent-title"), + }); + if (!source.success) return null; + return { id, source: source.data, text: body }; +} + +function renderMessageText(block: AgentPromptContentBlock): string[] { + if (block.type === "image") return []; + if (block.type === "text") return [block.text]; + return [renderPromptAttachmentAsText(block)]; +} + +export function prepareAgentMessage( + prompt: AgentPromptInput, + source?: AgentPromptSource, + id?: string, +): { prompt: AgentPromptInput; messageId?: string } { + if (!source) { + const submittedText = + typeof prompt === "string" + ? prompt + : prompt + .flatMap((block) => + block.type === "text" && !("mimeType" in block) ? [block.text] : [], + ) + .join("\n") + .trim(); + // Quote reserved envelopes only when they arrived through the human send path. + // This provenance must survive provider history, not only the accepted local row. + if (!parseAgentMessage(submittedText) && !isSystemInjectedEnvelope(submittedText)) { + return { prompt, messageId: id }; + } + } + const messageId = id ?? randomUUID(); + const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n"); + // Render textual attachments before wrapping, so provider replay still contains one + // complete envelope instead of an envelope followed by provider-rendered context. + const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text }); + return { + messageId, + prompt: + typeof prompt === "string" + ? envelope + : [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")], + }; +} + +/** The same projection owns provider echoes, acceptance, import, and replay. */ +export function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null { + if (item.type !== "user_message") return item; + const message = parseAgentMessage(item.text); + if (!message) { + // COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once + // legacy notification history is retired. Schedules still use this envelope. + return isSystemInjectedEnvelope(item.text) ? null : item; + } + const { source } = message; + if (!source) { + return { ...item, text: message.text, clientMessageId: message.id }; + } + return { + type: "tool_call", + callId: `paseo-agent-message:${message.id}`, + agentMessage: { + event: source.kind === "agent-message" ? "message" : source.event, + sender: { id: source.agentId, ...(source.title ? { title: source.title } : {}) }, + text: message.text, + }, + // COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08 + // once clients can accept a dedicated timeline variant. Older clients still + // parse this existing shape and can expand the delivered body. + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: message.text, icon: "bot" }, + }; +} + +/** + * Wrap a body in <paseo-system>…</paseo-system> so the receiving agent + * recognizes the prompt as system-injected context — not a user turn. + * Used by schedule fires; agent messages use the versioned envelope. + */ +export function formatSystemNotificationPrompt(reason: string): string { + return `<paseo-system>\n${reason}\n</paseo-system>`; +} + +const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/; + +export function isSystemInjectedEnvelope(text: string): boolean { + return SYSTEM_ENVELOPE_PATTERN.test(text); +} diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index f72bd84db1..ec4186b022 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -407,6 +407,17 @@ describe("toAgentPayload", () => { }); }); + it("carries a stored lastError onto the payload", () => { + const record = toStoredAgentRecord( + createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }), + ); + + expect(buildStoredAgentPayload(record, ["codex"]).lastError).toBe("boom"); + expect(buildStoredAgentPayload({ ...record, lastError: null }, ["codex"])).not.toHaveProperty( + "lastError", + ); + }); + it("omits lastUsage when not available", () => { const agent = createManagedAgent({ lastUsage: undefined }); const payload = toAgentPayload(agent); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 05f932aea9..3f2a9b7775 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -139,6 +139,7 @@ export function toAgentPayload( persistence: projectPersistenceHandleForWire(agent.persistence), title: options?.title ?? null, labels: agent.labels, + ...(agent.internal ? { internal: true } : {}), }; const usage = sanitizeUsage(agent.lastUsage); @@ -247,7 +248,9 @@ export function buildStoredAgentPayload( attentionReason: record.attentionReason ?? null, attentionTimestamp: record.attentionTimestamp ?? null, archivedAt: record.archivedAt ?? null, + ...(record.internal ? { internal: true } : {}), labels: normalizeLabels(record.labels), + ...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}), ...(providerAvailable ? {} : { providerUnavailable: true }), }; } diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 08bd66a79c..dcdfdb1cbf 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -1,3 +1,11 @@ +import { + formatSystemNotificationPrompt, + isSystemInjectedEnvelope, + parseAgentMessage, + formatAgentMessage, + prepareAgentMessage, + projectAgentMessage, +} from "./agent-messages/index.js"; import { expect, it, test, vi } from "vitest"; import pino, { type Logger } from "pino"; import { randomUUID } from "node:crypto"; @@ -8,12 +16,7 @@ import { join } from "node:path"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; -import { - formatSystemNotificationPrompt, - isSystemInjectedEnvelope, - setupFinishNotification, - waitForAgentRunStartWithTimeout, -} from "./agent-prompt.js"; +import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js"; import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; import type { AgentClient, @@ -219,6 +222,11 @@ function createFinishNotificationScenario( type: "agent_state", agent: childAgent, }); + subscriber?.({ + type: "agent_stream", + agentId: "child-agent", + event: { type: "turn_completed", provider: "codex" }, + }); }, async finishChildAndReadParentPrompt() { const parentPrompt = new Promise<string>((resolve) => { @@ -272,11 +280,16 @@ test("finish notifications tell the parent the child's last assistant message", scenario.startWatchingChild(); const parentPrompt = await scenario.finishChildAndReadParentPrompt(); - expect(parentPrompt).toEqual( - formatSystemNotificationPrompt( - "Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>", - ), - ); + expect(parseAgentMessage(parentPrompt)).toEqual({ + id: expect.any(String), + source: { + kind: "agent-notification", + agentId: "child-agent", + title: "Child Agent", + event: "finished", + }, + text: "Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>", + }); expect(scenario.steerAttemptCount()).toBe(1); }); @@ -303,9 +316,16 @@ test("closing a watched child notifies the caller", async () => { scenario.startWatchingChild(); const parentPrompt = await scenario.closeChildAndReadParentPrompt(); - expect(parentPrompt).toEqual( - formatSystemNotificationPrompt("Agent child-agent (Child Agent) was closed."), - ); + expect(parseAgentMessage(parentPrompt)).toEqual({ + id: expect.any(String), + source: { + kind: "agent-notification", + agentId: "child-agent", + title: "Child Agent", + event: "closed", + }, + text: "Agent child-agent (Child Agent) was closed.", + }); }); test("finish notifications survive permission responses", async () => { @@ -318,9 +338,9 @@ test("finish notifications survive permission responses", async () => { expect(scenario.parentPrompts()).toHaveLength(1); }); expect(scenario.parentPrompts()[0]).toContain("needs permission."); - const permissionPayload = scenario - .parentPrompts()[0] - .match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1]; + const permissionPayload = parseAgentMessage(scenario.parentPrompts()[0])!.text.match( + /<permission-request>\n([\s\S]+?)\n<\/permission-request>/, + )?.[1]; expect(permissionPayload).toBeDefined(); expect(JSON.parse(permissionPayload!)).toEqual({ agentId: "child-agent", @@ -377,7 +397,9 @@ test("finish notifications report every concurrently pending permission", async await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); expect( scenario.parentPrompts().map((prompt) => { - const payload = prompt.match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1]; + const payload = parseAgentMessage(prompt)!.text.match( + /<permission-request>\n([\s\S]+?)\n<\/permission-request>/, + )?.[1]; return JSON.parse(payload!).requestId; }), ).toEqual(["permission-1", "permission-2"]); @@ -523,6 +545,11 @@ it("does not notify archived callers", async () => { type: "agent_state", agent: childAgent, }); + subscriber?.({ + type: "agent_stream", + agentId: "child-agent", + event: { type: "turn_completed", provider: "codex" }, + }); await vi.waitFor(() => { expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent"); @@ -762,3 +789,75 @@ test("waiting for a run start still gives up at the run start budget", async () await scenario.cleanup(); } }); + +test("agent envelopes round-trip opaque sender IDs and XML-sensitive messages", () => { + const message = { + id: "delivery-1", + source: { + kind: "agent-message" as const, + agentId: 'host::agent<&"', + title: 'QA <messenger> & "reviewer"', + }, + text: 'Review <changes> & "quotes"\n</paseo-system>\n<paseo-system>nested</paseo-system>', + }; + const encoded = formatAgentMessage(message); + expect(parseAgentMessage(encoded)).toEqual(message); + expect(parseAgentMessage(`Example: ${encoded}`)).toBeNull(); + expect(parseAgentMessage(encoded.replace('version="1"', 'version="2"'))).toBeNull(); + expect(parseAgentMessage(encoded.replace("<", "<"))).toBeNull(); + expect(parseAgentMessage(encoded.replace('version="1"', 'version="1" version="1"'))).toBeNull(); +}); + +test("agent envelope includes rendered attachment context and preserves images", () => { + const image = { type: "image" as const, data: "aW1hZ2U=", mimeType: "image/png" }; + const attachment = { + type: "github_issue" as const, + mimeType: "application/github-issue" as const, + number: 42, + title: "Review context", + url: "https://example.com/issues/42", + body: "Attached context", + }; + const source = { kind: "agent-message" as const, agentId: "remote:sender" }; + expect( + prepareAgentMessage( + [{ type: "text", text: "First" }, image, attachment, { type: "text", text: "Second" }], + source, + "message", + ), + ).toEqual({ + messageId: "message", + prompt: [ + { + type: "text", + text: formatAgentMessage({ + id: "message", + source, + text: "First\n\nGitHub Issue #42: Review context\nhttps://example.com/issues/42\n\nAttached context\n\nSecond", + }), + }, + image, + ], + }); +}); + +test("a human pasting an agent envelope stays a human message through provider replay", () => { + const pasted = formatAgentMessage({ + id: "example", + source: { kind: "agent-message", agentId: "sender" }, + text: "hello", + }); + const delivery = prepareAgentMessage(pasted, undefined, "human-submission"); + expect( + projectAgentMessage({ + type: "user_message", + text: String(delivery.prompt), + messageId: "provider-echo", + }), + ).toEqual({ + type: "user_message", + text: pasted, + messageId: "provider-echo", + clientMessageId: "human-submission", + }); +}); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 41e2d52014..4044fd84bc 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,3 +1,4 @@ +import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js"; import type { Logger } from "pino"; import type { @@ -27,6 +28,8 @@ export type AgentRunController = Pick< }; export interface StartAgentRunOptions { + /** Called after selecting the run, before consuming its events. */ + onDispatch?: (disposition: PromptDispatchDisposition) => void; replaceRunning?: boolean; activeTurnBehavior?: ActiveTurnBehavior; runOptions?: AgentRunOptions; @@ -113,6 +116,7 @@ export async function startAgentRun( // in-flight turn — replaceAgentRun would interrupt the running turn. The // intercept lives at this layer so it covers every prompt entrypoint. if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { + options?.onDispatch?.("out_of_band"); return { disposition: "out_of_band" }; } try { @@ -137,6 +141,7 @@ async function startAgentRunInner( const snapshot = agentManager.getAgent(agentId); const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options); if (steered?.disposition === "steered") { + options?.onDispatch?.("steered"); return steered; } const { iterator, replaced } = steered @@ -151,6 +156,7 @@ async function startAgentRunInner( }, "agent.session.start_stream.iterator_returned", ); + options?.onDispatch?.("turn_started"); void (async () => { try { try { @@ -206,27 +212,14 @@ export async function unarchiveAgentState( return true; } -/** - * Wrap a body in <paseo-system>…</paseo-system> so the receiving agent - * recognizes the prompt as system-injected context — not a user turn. - * Used by chat mentions, schedule fires, and notify-on-finish. - */ -export function formatSystemNotificationPrompt(reason: string): string { - return `<paseo-system>\n${reason}\n</paseo-system>`; -} - -const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/; - -export function isSystemInjectedEnvelope(text: string): boolean { - return SYSTEM_ENVELOPE_PATTERN.test(text); -} - export interface SendPromptToAgentParams { + onDispatch?: StartAgentRunOptions["onDispatch"]; agentManager: AgentManager; agentStorage: AgentStorage; agentId: string; /** Prompt to dispatch to the provider (may include image blocks or wrapped text). */ prompt: AgentPromptInput; + source?: AgentPromptSource; messageId?: string; activeTurnBehavior?: ActiveTurnBehavior; runOptions?: AgentRunOptions; @@ -244,10 +237,12 @@ export interface SendPromptToAgentParams { } export interface StartCreatedAgentInitialPromptParams { + agentStorage: AgentStorage; agentManager: AgentManager; agentId: string; snapshot?: ManagedAgent; prompt: AgentPromptInput | null; + source?: AgentPromptSource; runOptions?: AgentRunOptions; logger: Logger; } @@ -292,6 +287,18 @@ export async function waitForAgentRunStartWithTimeout( } } +async function resolvePromptSource( + source: AgentPromptSource | undefined, + manager: Pick<AgentManager, "getAgent">, + storage: AgentStorage, +): Promise<AgentPromptSource | undefined> { + if (!source) return undefined; + const title = ( + manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title + )?.trim(); + return title ? { ...source, title } : source; +} + /** * Full send-prompt orchestration: (optional unarchive) → load → (optional * mode change) → start run. @@ -309,29 +316,44 @@ export async function sendPromptToAgent( const unarchive = params.unarchive ?? true; const record = await params.agentStorage.get(params.agentId); + let archivedAtToRestore: string | null = null; if (record?.archivedAt) { if (!unarchive) { return { disposition: "turn_started" }; } - await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId); + if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) { + archivedAtToRestore = record.archivedAt; + } } - await ensureAgentLoaded(params.agentId, { - agentManager: params.agentManager, - agentStorage: params.agentStorage, - logger: params.logger, - }); + try { + await ensureAgentLoaded(params.agentId, { + agentManager: params.agentManager, + agentStorage: params.agentStorage, + logger: params.logger, + }); + } catch (error) { + // A send that could not load the agent leaves it where it was: still archived. + // Concurrent sends share this load, so none of them holds a live session. + if (archivedAtToRestore) { + await params.agentManager.archiveSnapshot(params.agentId, archivedAtToRestore); + } + throw error; + } if (params.sessionMode) { await params.agentManager.setAgentMode(params.agentId, params.sessionMode); } - const runOptions = params.messageId - ? { ...params.runOptions, clientMessageId: params.messageId } + const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage); + const delivery = prepareAgentMessage(params.prompt, source, params.messageId); + const runOptions = delivery.messageId + ? { ...params.runOptions, clientMessageId: delivery.messageId } : params.runOptions; - return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, { + return await startAgentRun(params.agentManager, params.agentId, delivery.prompt, params.logger, { replaceRunning: true, + onDispatch: params.onDispatch, activeTurnBehavior: params.activeTurnBehavior, clearPendingPermissions: params.clearPendingPermissions, runOptions, @@ -350,14 +372,17 @@ export async function startCreatedAgentInitialPrompt( return currentSnapshot; } + const delivery = prepareAgentMessage( + params.prompt, + await resolvePromptSource(params.source, params.agentManager, params.agentStorage), + params.runOptions?.clientMessageId, + ); const dispatchResult = await startAgentRun( params.agentManager, params.agentId, - params.prompt, + delivery.prompt, params.logger, - { - runOptions: params.runOptions, - }, + { runOptions: { ...params.runOptions, clientMessageId: delivery.messageId } }, ); if (dispatchResult.disposition === "turn_started") { @@ -377,10 +402,26 @@ export interface SetupFinishNotificationParams { childAgentId: string; callerAgentId: string; requireParentOwnership?: boolean; + /** A replacement must observe its new start, not the displaced running snapshot. */ + waitForTurnStart?: boolean; logger: Logger; } -type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed"; +type FinishNotificationReason = + | "finished" + | "errored" + | "needs permission" + | "was closed" + | "was canceled"; + +const finishNotificationEvents = { + finished: "finished", + errored: "errored", + "needs permission": "permission-required", + "was closed": "closed", + // Use the existing failure event so older clients still parse agent-message metadata. + "was canceled": "errored", +} as const; const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; @@ -430,16 +471,18 @@ interface NotifySafelyOptions { // next finish reaches the caller once. const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>(); -export function setupFinishNotification(params: SetupFinishNotificationParams): void { +export function setupFinishNotification(params: SetupFinishNotificationParams): () => void { const { agentManager, agentStorage, childAgentId, callerAgentId, requireParentOwnership = false, + waitForTurnStart = false, logger, } = params; let hasSeenRunning = false; + let hasSeenTurn = false; let stopped = false; const notifiedPermissionRequestIds = new Set<string>(); let unsubscribe: (() => void) | null = null; @@ -487,7 +530,12 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): agentManager, agentStorage, agentId: callerAgentId, - prompt: formatSystemNotificationPrompt(body), + prompt: body, + source: { + kind: "agent-notification", + agentId: childAgentId, + event: finishNotificationEvents[reason], + }, activeTurnBehavior: "steer", unarchive: false, logger, @@ -507,6 +555,35 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): }); } + function observeState(agent: ManagedAgent): void { + for (const requestId of notifiedPermissionRequestIds) { + if (!agent.pendingPermissions.has(requestId)) { + notifiedPermissionRequestIds.delete(requestId); + } + } + if (agent.lifecycle === "running") { + if (waitForTurnStart && !hasSeenTurn) return; + hasSeenTurn = true; + if (agent.pendingPermissions.size === 0) { + hasSeenRunning = true; + } + return; + } + // Prior terminal state is not this task's outcome. A failure before turn start + // is reported by the dispatch acknowledgement instead of a finish callback. + if (agent.lifecycle === "error" && hasSeenTurn) { + notifySafely("errored"); + return; + } + // Idle is published before the terminal stream event for both completion + // and cancellation. Let that event identify the outcome instead. + if (agent.lifecycle === "closed") { + notifySafely("was closed"); + return; + } + return; + } + unsubscribe = agentManager.subscribe( (event) => { if (stopped) { @@ -514,29 +591,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): } if (event.type === "agent_state") { - for (const requestId of notifiedPermissionRequestIds) { - if (!event.agent.pendingPermissions.has(requestId)) { - notifiedPermissionRequestIds.delete(requestId); - } - } - if (event.agent.lifecycle === "running") { - if (event.agent.pendingPermissions.size === 0) { - hasSeenRunning = true; - } - return; - } - if (event.agent.lifecycle === "error") { - notifySafely("errored"); - return; - } - if (event.agent.lifecycle === "idle" && hasSeenRunning) { - notifySafely("finished"); - return; - } - if (event.agent.lifecycle === "closed") { - notifySafely("was closed"); - return; - } + observeState(event.agent); return; } @@ -544,6 +599,24 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } + if (event.event.type === "turn_started") { + hasSeenTurn = true; + hasSeenRunning = true; + return; + } + + if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") { + // Replacing a turn keeps the child running through the displaced turn's + // cancellation. The task settles only when the child becomes idle. + if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return; + if (event.event.type === "turn_completed" && hasSeenRunning) { + notifySafely("finished"); + } else if (event.event.type === "turn_canceled" && hasSeenTurn) { + notifySafely("was canceled"); + } + return; + } + if (event.event.type === "permission_requested") { // A permission pause is an intermediate checkpoint. Forget the run // observed before it so an idle state during follow-up startup cannot @@ -578,11 +651,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): const childSnapshot = agentManager.getAgent(childAgentId); if (!childSnapshot || childSnapshot.lifecycle === "closed") { stop(); - return; + return stop; } - if (childSnapshot.lifecycle === "running") { + if (childSnapshot.lifecycle === "running" && !waitForTurnStart) { + hasSeenTurn = true; hasSeenRunning = true; - } else if (childSnapshot.lifecycle === "error") { - notifySafely("errored"); } + return stop; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 3e5e739577..34d36c9e16 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -628,6 +628,11 @@ export interface AgentCreateSessionOptions { * Defaults to true. Providers that cannot honor false should no-op. */ persistSession?: boolean; + /** + * Model ids added by provider configuration (`models` / `additionalModels`). + * Providers that validate against runtime-advertised models must accept these. + */ + configuredModelIds?: readonly string[]; } /** What a resumed session is for: driving the agent, or reading what it already did. */ @@ -637,6 +642,8 @@ export type AgentResumePurpose = "interactive" | "history"; export interface AgentResumeSessionOptions { /** Defaults to interactive. History loading may be read-only for archived native sessions. */ purpose?: AgentResumePurpose; + /** See AgentCreateSessionOptions.configuredModelIds. */ + configuredModelIds?: readonly string[]; } /** diff --git a/packages/server/src/server/agent/agent-stream-coalescer.test.ts b/packages/server/src/server/agent/agent-stream-coalescer.test.ts index c48a5ec8e4..1cdf4bf375 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.test.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.test.ts @@ -723,6 +723,57 @@ describe("AgentStreamCoalescer", () => { ]); }); + test("keeps a known tool call detail when a later update in the window has none", () => { + const { coalescer, flushes } = createHarness(); + primeLeadingEdge(coalescer, flushes); + + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Task", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }), + ); + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { type: "unknown", input: { description: "List files" }, output: null }, + }), + ); + + expect(flushes.map((flush) => flush.item)).toEqual([ + { + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }, + ]); + }); + test("preserves mixed text and tool call arrival order within a flush", async () => { const { coalescer, flushes } = createHarness(); primeLeadingEdge(coalescer, flushes); diff --git a/packages/server/src/server/agent/agent-stream-coalescer.ts b/packages/server/src/server/agent/agent-stream-coalescer.ts index ca52a5479e..c11d968228 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.ts @@ -1,4 +1,5 @@ import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "./agent-sdk-types.js"; +import { mergeToolCallItems } from "./timeline-projection.js"; export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 60; @@ -183,9 +184,16 @@ export class AgentStreamCoalescer { } const existingIndex = buffer.toolCallEntryIndexes.get(event.item.callId); + const existing = existingIndex !== undefined ? buffer.entries[existingIndex] : undefined; + // Merge rather than replace, so an update that carries less (an unknown detail after a + // labeled one) cannot erase what the timeline would have kept had both been flushed. + let item = event.item; + if (existing?.kind === "tool_call") { + item = mergeToolCallItems(existing.item, event.item); + } const entry: PendingToolCallEntry = { kind: "tool_call", - item: event.item, + item, provider: event.provider, ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), }; diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 53a2177272..554bb9869e 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -7,13 +7,11 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, + CreatePaseoWorktreeSetupContinuationInput, + NormalizedGitOptions, } from "../worktree-session.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; -import type { - CreateAgentWorktreeTarget, - FirstAgentContext, - SessionOutboundMessage, -} from "../messages.js"; +import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js"; import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; @@ -59,18 +57,34 @@ export class CreateAgentLifecycleDispatch { async createWorktreeForRequest(input: { cwd: string; - target: CreateAgentWorktreeTarget | undefined; + target: NormalizedGitOptions | null; firstAgentContext: FirstAgentContext; - hasLegacyGitOptions: boolean; + setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; + background?: boolean; + callerWorkspaceId?: string; }): Promise<CreatePaseoWorktreeWorkflowResult | null> { - if (input.target && input.hasLegacyGitOptions) { - throw new Error("create_agent_request worktree cannot be combined with git options"); - } - if (!input.target) { - return null; - } - - return this.createWorktreeForTarget(input.cwd, input.target, input.firstAgentContext); + const target = input.target; + if (!target?.createWorktree) return null; + return this.dependencies.createPaseoWorktreeWorkflow( + { + cwd: input.cwd, + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, + firstAgentContext: input.firstAgentContext, + worktreeSlug: target.worktreeSlug, + refName: target.refName, + action: target.action, + checkoutSource: target.checkoutSource, + githubPrNumber: target.githubPrNumber, + runSetup: false, + paseoHome: this.dependencies.paseoHome, + worktreesRoot: this.dependencies.worktreesRoot, + }, + { + ...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}), + setupContinuation: input.setupContinuation, + }, + ); } registerAutoArchiveIfRequested(input: { @@ -111,47 +125,6 @@ export class CreateAgentLifecycleDispatch { }); } - private async createWorktreeForTarget( - cwd: string, - target: CreateAgentWorktreeTarget, - firstAgentContext: FirstAgentContext, - ): Promise<CreatePaseoWorktreeWorkflowResult> { - const baseInput = { - cwd, - firstAgentContext, - runSetup: false, - paseoHome: this.dependencies.paseoHome, - worktreesRoot: this.dependencies.worktreesRoot, - } as const; - - switch (target.mode) { - case "branch-off": - return this.dependencies.createPaseoWorktreeWorkflow( - { - ...baseInput, - worktreeSlug: target.newBranch, - action: "branch-off", - ...(target.base ? { refName: target.base } : {}), - }, - target.base ? { resolveDefaultBranch: async () => target.base! } : undefined, - ); - case "checkout-branch": - return this.dependencies.createPaseoWorktreeWorkflow({ - ...baseInput, - action: "checkout", - refName: target.branch, - }); - case "checkout-pr": - return this.dependencies.createPaseoWorktreeWorkflow({ - ...baseInput, - action: "checkout", - githubPrNumber: target.prNumber, - }); - default: - throw new Error("Unsupported create_agent_request worktree target"); - } - } - private registerAutoArchiveOnTerminalState( agentId: string, target: AutoArchiveTarget, diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index e8547d27ed..f12309da2a 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -11,6 +11,7 @@ import { AgentStorage } from "../agent-storage.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { createAgentCommand } from "./create.js"; import type { ManagedAgent } from "../agent-manager.js"; +import type { AgentSessionConfig } from "../agent-sdk-types.js"; const logger = createTestLogger(); @@ -211,7 +212,6 @@ test("mcp create accepts provider-only internal input and leaves model undefined workspaceId: "ws-create-test", title: "provider default", initialPrompt: "hello", - background: true, notifyOnFinish: false, }); @@ -329,7 +329,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async provider: "codex/gpt-5.4", title: "child", initialPrompt: "do the thing", - background: true, notifyOnFinish: false, callerAgentId: parent.id, worktree: { worktreeName: "feature", baseBranch: "main" }, @@ -378,7 +377,6 @@ test("mcp create exposes the created worktree before dispatching the initial pro cwd: workdir, title: "worktree callback", initialPrompt: "Say done.", - background: true, notifyOnFinish: false, worktree: { worktreeName: "feature", baseBranch: "main" }, onCreated: ({ agentId, createdWorktree: callbackWorktree }) => { @@ -471,3 +469,47 @@ test("session create keeps an explicit title after the initial prompt settles", await removeRealAgentManagerWorkdir({ agentManager, storage, workdir }); } }); + +test("session create with an internal config skips provider session persistence", async () => { + const snapshot = { + id: "agent-1", + provider: "codex", + cwd: "/tmp/paseo-create-test", + runtimeInfo: null, + } as ManagedAgent; + const createAgent = vi.fn(async () => snapshot); + const dependencies: Parameters<typeof createAgentCommand>[0] = { + agentManager: { + createAgent, + } as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"], + agentStorage: {} as Parameters<typeof createAgentCommand>[0]["agentStorage"], + logger: createTestLogger(), + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + }; + const input = { + kind: "session" as const, + workspaceId: "ws-create-test", + labels: {}, + provisionalTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }), + }; + + await createAgentCommand(dependencies, { + ...input, + config: { provider: "codex", cwd: "/tmp/paseo-create-test", internal: true }, + }); + await createAgentCommand(dependencies, { + ...input, + config: { provider: "codex", cwd: "/tmp/paseo-create-test" }, + }); + + expect(createAgent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ provider: "codex", internal: true }), + undefined, + expect.objectContaining({ persistSession: false }), + ); + expect(createAgent.mock.calls[1]?.[0]).not.toHaveProperty("internal"); + expect(createAgent.mock.calls[1]?.[2]).not.toHaveProperty("persistSession"); +}); diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 814a8f3aec..05f98285e1 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -1,3 +1,4 @@ +import type { AgentPromptSource } from "../agent-messages/index.js"; import type { Logger } from "pino"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; @@ -51,6 +52,7 @@ export interface CreateAgentCommandDependencies { export type EnsureWorkspaceForCreate = ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ) => Promise<string>; export interface CreateAgentFromSessionInput { @@ -61,6 +63,7 @@ export interface CreateAgentFromSessionInput { workspaceId: string; worktreeName?: string; initialPrompt?: string; + source?: AgentPromptSource; clientMessageId?: string; outputSchema?: Record<string, unknown>; images?: Array<{ data: string; mimeType: string }>; @@ -92,7 +95,6 @@ export interface CreateAgentFromMcpInput { mode?: string; unattended?: boolean; promptFailure?: CreateAgentPromptFailureMode; - background: boolean; notifyOnFinish: boolean; internal?: boolean; detached?: boolean; @@ -125,7 +127,6 @@ export type CreateAgentPromptFailureMode = "throw" | "log" | "return-error"; export interface CreateAgentCommandResult { snapshot: ManagedAgent; liveSnapshot: ManagedAgent; - background: boolean; initialPromptStarted: boolean; initialPromptError: unknown | null; createdWorktree?: CreatePaseoWorktreeWorkflowResult; @@ -164,9 +165,9 @@ interface ResolvedCreateAgent { config: AgentSessionConfig; createOptions: CreateAgentOptions; prompt?: AgentPromptInput; + source?: AgentPromptSource; runOptions?: AgentRunOptions; setupContinuation?: AgentWorktreeSetupContinuation; - background: boolean; promptFailure: CreateAgentPromptFailureMode; promptLogger?: Logger; createdWorktree?: CreatePaseoWorktreeWorkflowResult; @@ -187,10 +188,6 @@ export async function createAgentCommand( resolved.createOptions, ); - resolved.setupContinuation?.startAfterAgentCreate({ - agentId: snapshot.id, - }); - if (input.kind === "session") await input.onAgentReady?.(snapshot); let liveSnapshot = snapshot; @@ -199,28 +196,37 @@ export async function createAgentCommand( if (input.kind === "mcp") { input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null }); } + const stopNotification = + input.kind === "mcp" && + input.notifyOnFinish && + input.callerAgentId && + (resolved.prompt !== undefined || resolved.setupContinuation !== undefined) + ? setupFinishNotification({ + agentManager: dependencies.agentManager, + agentStorage: dependencies.agentStorage, + childAgentId: snapshot.id, + callerAgentId: input.callerAgentId, + requireParentOwnership: true, + logger: dependencies.logger, + }) + : undefined; + + resolved.setupContinuation?.startAfterAgentCreate({ + agentId: snapshot.id, + }); if (resolved.prompt !== undefined) { - const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot); + const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => { + stopNotification?.(); + throw error; + }); initialPromptStarted = sendResult.started; liveSnapshot = sendResult.liveSnapshot; initialPromptError = sendResult.error ?? null; } - if (input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && initialPromptStarted) { - setupFinishNotification({ - agentManager: dependencies.agentManager, - agentStorage: dependencies.agentStorage, - childAgentId: snapshot.id, - callerAgentId: input.callerAgentId, - requireParentOwnership: true, - logger: dependencies.logger, - }); - } - return { snapshot, liveSnapshot, - background: resolved.background, initialPromptStarted, initialPromptError, ...(resolved.createdWorktree ? { createdWorktree: resolved.createdWorktree } : {}), @@ -287,16 +293,20 @@ async function resolveSessionCreateAgent( initialPrompt: trimmedPrompt, env: input.env, initialTitle: input.provisionalTitle, + // Paseo never persists an internal agent, so nothing could resume its + // provider session either. Same as the daemon's own ephemeral helpers. + ...(sessionConfig.internal ? { persistSession: false } : {}), // A legacy git/worktreeName worktree creates a fresh workspace, so the // agent belongs to that workspace, not the source one. createdWorkspaceId // is the freshly created worktree's workspace. workspaceId: requireResolvedWorkspaceId(workspaceId), }, prompt: hasPromptContent ? prompt : undefined, + source: input.source, runOptions, setupContinuation, - background: true, - promptFailure: "throw", + // Registration commits creation. First-turn failures belong to the created agent. + promptFailure: "return-error", promptLogger: dependencies.logger.child({ clientMessageId: resolveClientMessageId(input.clientMessageId), }), @@ -319,6 +329,7 @@ async function resolveMcpCreateAgent( cwd, worktree: input.worktree, initialPrompt: input.initialPrompt ?? "", + callerWorkspaceId: parentAgent?.workspaceId, }); if (createdWorktree) input.onWorktreeCreated?.(createdWorktree); @@ -333,7 +344,12 @@ async function resolveMcpCreateAgent( resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: resolvedCwd }), createWorkspace: async () => ({ workspaceId: requireResolvedWorkspaceId( - await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt ?? ""), + await ensureWorkspaceForMcpCreate( + dependencies, + resolvedCwd, + input.initialPrompt ?? "", + parentAgent?.workspaceId, + ), ), cwd: resolvedCwd, }), @@ -364,9 +380,11 @@ async function resolveMcpCreateAgent( env: input.env, }, prompt: trimmedPrompt ? trimmedPrompt : undefined, + source: input.callerAgentId + ? { kind: "agent-message", agentId: input.callerAgentId } + : undefined, setupContinuation, createdWorktree, - background: input.background, promptFailure: input.promptFailure ?? "log", }; } @@ -441,11 +459,16 @@ async function ensureWorkspaceForMcpCreate( dependencies: CreateAgentCommandDependencies, cwd: string, initialPrompt: string, + callerWorkspaceId?: string, ): Promise<string | undefined> { if (!dependencies.ensureWorkspaceForCreate) { return undefined; } - return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }); + return dependencies.ensureWorkspaceForCreate( + cwd, + { prompt: initialPrompt }, + { callerWorkspaceId }, + ); } async function sendInitialPrompt( @@ -459,10 +482,12 @@ async function sendInitialPrompt( return { started: false, liveSnapshot: snapshot }; } const liveSnapshot = await startCreatedAgentInitialPrompt({ + agentStorage: dependencies.agentStorage, agentManager: dependencies.agentManager, agentId: snapshot.id, snapshot, prompt, + source: resolved.source, runOptions: resolved.runOptions, logger: resolved.promptLogger ?? dependencies.logger, }); @@ -472,7 +497,11 @@ async function sendInitialPrompt( throw error; } if (resolved.promptFailure === "return-error") { - return { started: false, liveSnapshot: snapshot, error }; + return { + started: false, + liveSnapshot: dependencies.agentManager.getAgent(snapshot.id) ?? snapshot, + error, + }; } dependencies.logger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); return { started: false, liveSnapshot: snapshot }; @@ -510,6 +539,7 @@ async function resolveMcpCwd(params: { dependencies: CreateAgentCommandDependencies; cwd: string; initialPrompt: string; + callerWorkspaceId?: string; worktree: CreateAgentFromMcpInput["worktree"]; }): Promise<{ resolvedCwd: string; @@ -540,6 +570,7 @@ async function resolveMcpCwd(params: { const createdWorktree = await createMcpWorktree({ input: { cwd: params.cwd, + callerWorkspaceId: params.callerWorkspaceId, worktreeSlug: worktree.worktreeName, branchName: worktree.branchName, refName: worktree.refName, diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts new file mode 100644 index 0000000000..2f51d4a4c1 --- /dev/null +++ b/packages/server/src/server/agent/directory.ts @@ -0,0 +1,65 @@ +import type { AgentSnapshotPayload } from "../messages.js"; +import type { WorkspaceRegistry } from "../workspace-registry.js"; +import type { AgentManager, ManagedAgent } from "./agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; + +/** Public agent discovery owns workspace visibility for live and durable records. */ +export class AgentDirectory { + constructor( + private readonly deps: { + manager: AgentManager; + storage: AgentStorage; + workspaces: Pick<WorkspaceRegistry, "get" | "list">; + projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>; + projectStored(record: StoredAgentRecord): AgentSnapshotPayload; + isProviderVisible(provider: string): boolean; + isStoredProviderAvailable(record: StoredAgentRecord): boolean; + }, + ) {} + + async includes( + agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">, + includeBackground = false, + ): Promise<boolean> { + if (agent.internal) return false; + if (includeBackground || !agent.workspaceId) return true; + return !(await this.deps.workspaces.get(agent.workspaceId))?.background; + } + + async list(filter?: { + labels?: Record<string, string>; + includeArchived?: boolean; + includeBackground?: boolean; + includeUnavailablePersisted?: boolean; + }): Promise<AgentSnapshotPayload[]> { + const live = this.deps.manager.listAgents(); + const liveIds = new Set(live.map((agent) => agent.id)); + const labels = Object.entries(filter?.labels ?? {}); + const backgroundIds = new Set( + filter?.includeBackground + ? [] + : (await this.deps.workspaces.list()) + .filter((workspace) => workspace.background) + .map((workspace) => workspace.workspaceId), + ); + const matches = (agent: { + archivedAt?: string | null; + workspaceId?: string; + labels?: Record<string, string>; + }) => + (filter?.includeArchived || !agent.archivedAt) && + (filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) && + labels.every(([key, value]) => agent.labels?.[key] === value); + const stored = (await this.deps.storage.list()) + .filter((record) => !record.internal && !liveIds.has(record.id) && matches(record)) + .filter( + (record) => + filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record), + ) + .map((record) => this.deps.projectStored(record)); + const active = await Promise.all( + live.filter(matches).map((agent) => this.deps.projectLive(agent)), + ); + return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider)); + } +} diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts index 6e5714d489..763e676f43 100644 --- a/packages/server/src/server/agent/lifecycle-command.test.ts +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -219,6 +219,25 @@ describe("agent lifecycle commands", () => { expect(manager.archivedAgentIds).toEqual(["agent-1"]); }); + test("archives a live internal agent without touching storage", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + manager.liveAgents.set("agent-1", { ...managedAgent("agent-1", "idle"), internal: true }); + + const result = await archiveAgentCommand( + { agentManager: manager, agentStorage: storage, logger }, + "agent-1", + ); + + expect(result).toEqual({ + agentId: "agent-1", + archivedAt: "2026-05-10T10:00:00.000Z", + record: null, + }); + expect(manager.archivedAgentIds).toEqual(["agent-1"]); + expect(storage.upserts).toEqual([]); + }); + test("archives a live agent when its graceful cancellation is rejected", async () => { const storage = new FakeLifecycleAgentStorage(); const manager = new FakeLifecycleAgentManager(storage); diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index cb172ce620..431cff0fe6 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -8,7 +8,7 @@ import { import type { StoredAgentRecord } from "./agent-storage.js"; import type { AgentProviderNotice } from "./agent-sdk-types.js"; -export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle">; +export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle" | "internal">; export interface LifecycleAgentManager { getAgent(agentId: string): LifecycleAgentSnapshot | null; @@ -112,7 +112,8 @@ export async function cancelAgentRunCommand( export interface ArchiveAgentResult { agentId: string; archivedAt: string; - record: StoredAgentRecord; + /** Null for an internal agent, which is closed and leaves no record behind. */ + record: StoredAgentRecord | null; } export async function archiveAgentCommand( @@ -124,7 +125,10 @@ export async function archiveAgentCommand( if (liveAgent) { await requestAgentRunCancellation(dependencies, agentId); await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined); - await dependencies.agentManager.archiveAgent(agentId); + const archived = await dependencies.agentManager.archiveAgent(agentId); + if (liveAgent.internal) { + return { agentId, archivedAt: archived.archivedAt, record: null }; + } record = await dependencies.agentStorage.get(agentId); } else { record = await archiveStoredAgent(dependencies, agentId); diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 50867f10d9..bfb6be7964 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -12,6 +12,8 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import type { AgentClient, AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { parseAgentMessage } from "./agent-messages/index.js"; interface StructuredContent { [key: string]: unknown; @@ -215,7 +217,6 @@ async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<s provider: "claude/claude-test-model", initialPrompt: "say done and stop", settings: { modeId: "bypassPermissions" }, - background: true, ...rest, }); return str(payload.agentId); @@ -267,28 +268,6 @@ async function killTerminalIfPresent(terminalId: string | null | undefined): Pro } } -async function archiveWorktreeIfPresent(params: { - cwd: string; - worktreePath?: string | null; - worktreeSlug?: string | null; -}): Promise<void> { - if (!params.worktreePath && !params.worktreeSlug) { - return; - } - try { - await topLevelClient.callTool({ - name: "archive_worktree", - args: { - cwd: params.cwd, - ...(params.worktreePath ? { worktreePath: params.worktreePath } : {}), - ...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}), - }, - }); - } catch { - // ignore cleanup errors - } -} - beforeAll(async () => { tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-")); parentAgentCwd = await makeCwd("parent-agent-cwd"); @@ -307,7 +286,6 @@ beforeAll(async () => { provider: "claude/claude-test-model", initialPrompt: "say done and stop", settings: { modeId: "bypassPermissions" }, - background: true, }); parentAgentId = str(parentPayload.agentId); @@ -335,6 +313,167 @@ afterAll(async () => { }); describe("Suite A: Core Fixes", () => { + test.each([ + ["Stop", "cancelAgent"], + ["Reload", "refreshAgent"], + ] as const)( + "%s tells the parent that a watched child was canceled", + async (_action, method) => { + let releaseChild!: () => void; + let childReachedGate!: () => void; + const heldTurn = new Promise<void>((resolve) => { + releaseChild = resolve; + }); + const childAtGate = new Promise<void>((resolve) => { + childReachedGate = resolve; + }); + const parentPrompts: string[] = []; + const daemon = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + onStartTurn(prompt, config) { + if (config.title === "Watching parent" && typeof prompt === "string") + parentPrompts.push(prompt); + }, + async beforeTurnComplete(prompt) { + if ( + typeof prompt === "string" && + parseAgentMessage(prompt).text === "Hold this child turn" + ) { + childReachedGate(); + await heldTurn; + } + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.2", + }); + let mcp: McpClient | undefined; + let unsubscribe: (() => void) | undefined; + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const parent = await client.createAgent({ + provider: "claude", + cwd: daemon.paseoHome, + title: "Watching parent", + modeId: "bypassPermissions", + }); + mcp = await createMcpClient( + buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: parent.id }), + ); + const created = await callToolStructured(mcp, "create_agent", { + relationship: { kind: "subagent" }, + workspace: { kind: "current" }, + title: "Watched child", + provider: "codex/gpt-5.4", + initialPrompt: "Hold this child turn", + notifyOnFinish: true, + settings: { modeId: "full-access" }, + }); + const childId = str(created.agentId); + await childAtGate; + expect(daemon.daemon.agentManager.getAgent(childId)?.lifecycle).toBe("running"); + const terminalEvents: string[] = []; + unsubscribe = daemon.daemon.agentManager.subscribe( + (event) => { + if ( + event.type === "agent_stream" && + event.agentId === childId && + (event.event.type === "turn_completed" || event.event.type === "turn_canceled") + ) + terminalEvents.push(event.event.type); + }, + { agentId: childId, replayState: false }, + ); + + await client[method](childId); + await waitFor({ + timeoutMs: 10000, + label: "parent cancellation notification", + check: () => (parentPrompts.length === 1 ? true : null), + }); + const notification = parseAgentMessage(parentPrompts[0]); + expect(terminalEvents).toEqual(["turn_canceled"]); + expect(notification.source).toEqual({ + kind: "agent-notification", + agentId: childId, + title: "Watched child", + event: "errored", + }); + expect(notification.text).toContain(`Agent ${childId} (Watched child) was canceled.`); + expect(notification.text).not.toContain("finished."); + await waitFor({ + timeoutMs: 10000, + label: "parent notification turn drained", + check: () => + daemon.daemon.agentManager.getAgent(parent.id)?.lifecycle === "idle" ? true : null, + }); + expect(parentPrompts).toHaveLength(1); + } finally { + releaseChild(); + unsubscribe?.(); + await mcp?.close(); + await client.close(); + await daemon.close(); + } + }, + 30000, + ); + + test.each([ + { outcome: "finished", prompt: "say done and stop", status: "idle" }, + { outcome: "errored", prompt: "Emit a turn failure", status: "error" }, + ])( + "a watched child still reports $outcome after send_agent_prompt", + async ({ outcome, prompt, status }) => { + const childId = await createChildAgent({ + title: "Follow-up child", + settings: { modeId: "bypassPermissions" }, + }); + try { + await waitFor({ + timeoutMs: 10000, + label: "initial child turn completed", + check: () => + daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null, + }); + await callToolStructured(agentScopedClient, "send_agent_prompt", { + agentId: childId, + prompt, + notifyOnFinish: true, + }); + const notifications = await waitFor({ + timeoutMs: 10000, + label: `${outcome} notification`, + check: () => { + const items = daemonHandle.daemon.agentManager + .getTimeline(parentAgentId) + .filter( + (item) => item.type === "tool_call" && item.agentMessage?.sender.id === childId, + ); + return items.length > 0 ? items : null; + }, + }); + expect(notifications).toEqual([ + expect.objectContaining({ + type: "tool_call", + agentMessage: { + event: outcome, + sender: { id: childId, title: "Follow-up child" }, + text: expect.stringContaining(`(Follow-up child) ${outcome}.`), + }, + }), + ]); + expect(daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle).toBe(status); + } finally { + await archiveAgentIfPresent(childId); + } + }, + 30000, + ); + test("AGENT_WAIT_TIMEOUT_MS is 30000", () => { expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000); }); @@ -845,117 +984,107 @@ describe("Suite D: Provider Tools", () => { }); }); -describe("Suite E: Worktree Tools", () => { - test("list_worktrees on empty repo", async () => { - const payload = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, +describe("Suite E: Workspace Tools", () => { + test("background worktree creation, discovery and archive use ordinary workspace ownership", async () => { + const created = await callToolStructured(topLevelClient, "create_workspace", { + isolation: "worktree", + path: worktreeRepoCwd, + baseBranch: "main", + branchName: `parity-background-${Date.now()}`, + background: true, }); - expect(payload.worktrees).toEqual([]); - }); - - test("create_worktree and list_worktrees", async () => { - let worktreePath: string | null = null; - const branchName = `parity-create-${Date.now()}`; + const workspaceId = str(created.workspaceId); try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - target: { - kind: "branch-off", - worktreeSlug: branchName, - baseBranch: "main", - }, + expect(created.background).toBe(true); + const ordinary = await callToolStructured(topLevelClient, "list_workspaces", {}); + expect( + recordArr(ordinary.workspaces).map((workspace) => workspace.workspaceId), + ).not.toContain(workspaceId); + const inclusive = await callToolStructured(topLevelClient, "list_workspaces", { + includeBackground: true, }); - worktreePath = str(created.worktreePath); - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, - }); - const worktrees = recordArr(listed.worktrees); - expect(worktrees).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: worktreePath, - branchName, - }), - ]), + expect(recordArr(inclusive.workspaces)).toContainEqual( + expect.objectContaining({ workspaceId, background: true }), ); + const child = await callToolStructured(topLevelClient, "create_agent", { + workspaceId, + title: "Background worker", + provider: "claude/claude-test-model", + initialPrompt: "Say done.", + }); + const agentId = str(child.agentId); + const workerClient = await createMcpClient( + `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${agentId}`, + ); + let terminalId: string; + try { + const terminal = await callToolStructured(workerClient, "create_terminal", { + name: "Background terminal", + }); + terminalId = str(terminal.id); + expect(terminal.cwd).toBe(created.cwd); + } finally { + await workerClient.close(); + } + const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId }); + expect((status.snapshot as StructuredContent).workspaceId).toBe(workspaceId); + const archived = await callToolStructured(topLevelClient, "archive_workspace", { + workspaceId, + }); + expect(archived.archivedAgentIds).toContain(agentId); + const terminals = await callToolStructured(topLevelClient, "list_terminals", { all: true }); + expect(recordArr(terminals.terminals).map((terminal) => terminal.id)).not.toContain( + terminalId, + ); + const remaining = await callToolStructured(topLevelClient, "list_workspaces", { + includeBackground: true, + }); + expect( + recordArr(remaining.workspaces).map((workspace) => workspace.workspaceId), + ).not.toContain(workspaceId); } finally { - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + await topLevelClient + .callTool({ name: "archive_workspace", args: { workspaceId } }) + .catch(() => undefined); } }); - test("archive_worktree removes worktree", async () => { - let worktreePath: string | null = null; - const branchName = `parity-archive-${Date.now()}`; + test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => { + const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", { + isolation: "local", + path: worktreeRepoCwd, + background: true, + }); + const parentWorkspaceId = str(parentWorkspace.workspaceId); + let scoped: McpClient | null = null; + const ownedWorkspaces: string[] = [parentWorkspaceId]; try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - target: { - kind: "branch-off", - worktreeSlug: branchName, - baseBranch: "main", - }, + const parent = await callToolStructured(topLevelClient, "create_agent", { + workspaceId: parentWorkspaceId, + title: "Background parent", + provider: "claude/claude-test-model", + initialPrompt: "Say done.", }); - worktreePath = str(created.worktreePath); - - await callToolStructured(topLevelClient, "archive_worktree", { - cwd: worktreeRepoCwd, - worktreePath, - }); - worktreePath = null; - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, - }); - const worktrees = recordArr(listed.worktrees); - expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false); - } finally { - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); - } - }); - - test("archive_worktree succeeds when caller cwd is inside the archived worktree", async () => { - let worktreePath: string | null = null; - let worktreeAgentId: string | null = null; - let worktreeScopedClient: McpClient | null = null; - const branchName = `parity-archive-self-cwd-${Date.now()}`; - - try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - target: { - kind: "branch-off", - worktreeSlug: branchName, - baseBranch: "main", - }, - }); - worktreePath = str(created.worktreePath); - worktreeAgentId = await createTopLevelAgent({ - cwd: worktreePath, - title: "Worktree scoped parity agent", - }); - worktreeScopedClient = await createMcpClient( - `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${encodeURIComponent( - worktreeAgentId, - )}`, + scoped = await createMcpClient( + `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${str(parent.agentId)}`, ); - - const archived = await callToolStructured(worktreeScopedClient, "archive_worktree", { - worktreePath, + const inherited = await callToolStructured(scoped, "create_workspace", { + isolation: "local", }); - expect(archived).toEqual({ success: true }); - worktreePath = null; - worktreeAgentId = null; - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, + ownedWorkspaces.push(str(inherited.workspaceId)); + expect(inherited.background).toBe(true); + const visible = await callToolStructured(scoped, "create_workspace", { + isolation: "local", + background: false, }); - const worktrees = recordArr(listed.worktrees); - expect(worktrees.map((worktree) => worktree.path)).not.toContain(created.worktreePath); + ownedWorkspaces.push(str(visible.workspaceId)); + expect(visible.background).toBe(false); } finally { - await worktreeScopedClient?.close(); - await archiveAgentIfPresent(worktreeAgentId); - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + await scoped?.close(); + for (const workspaceId of ownedWorkspaces.toReversed()) + await topLevelClient + .callTool({ name: "archive_workspace", args: { workspaceId } }) + .catch(() => undefined); } }); }); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ad2c4babd8..de0099cecf 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,3 +1,4 @@ +import { parseAgentMessage } from "./agent-messages/index.js"; import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; @@ -10,7 +11,7 @@ import { tmpdir } from "node:os"; import { z } from "zod"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { createAgentMcpServer } from "./mcp-server.js"; +import { createAgentMcpServer as createAgentMcpServerWithDependencies } from "./mcp-server.js"; import { AgentManager, type ManagedAgent } from "./agent-manager.js"; import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; @@ -832,6 +833,14 @@ function createPaseoWorktreeForMcpTest(options: { }; } +// Typed in-memory workspace port for catalogue tests without workspace setup. +function createAgentMcpServer(options: Parameters<typeof createAgentMcpServerWithDependencies>[0]) { + return createAgentMcpServerWithDependencies({ + workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} }, + ...options, + }); +} + describe("browser MCP tools", () => { const logger = createTestLogger(); @@ -1326,10 +1335,13 @@ describe("create_agent MCP tool", () => { title: "Top-level agent", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); - expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" }); + expect(ensureWorkspace).toHaveBeenCalledWith( + existingCwd, + { prompt: "Do work" }, + { callerWorkspaceId: undefined }, + ); expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ cwd: existingCwd }), undefined, @@ -1337,6 +1349,32 @@ describe("create_agent MCP tool", () => { ); }); + it("rejects public internal helpers and the old top-level execution background option", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const agentManager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + }); + try { + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "create_agent"); + const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" }; + await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/); + await expect(tool.handler({ ...input, background: true })).rejects.toThrow( + /Unrecognized key/, + ); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }); + it("rejects partial explicit workspace shape", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ @@ -1394,6 +1432,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, provider: "codex", currentModeId: "full-access", @@ -1444,7 +1483,6 @@ describe("create_agent MCP tool", () => { title: "Existing workspace", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); expect(response.structuredContent.workspaceId).toBe("wks_existing"); @@ -1481,7 +1519,7 @@ describe("create_agent MCP tool", () => { title: "Feature test", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, + settings: { features: { fast_mode: true } }, }; @@ -1534,7 +1572,6 @@ describe("create_agent MCP tool", () => { title: "Mode test", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); expect(response.structuredContent).toEqual( @@ -1860,7 +1897,6 @@ describe("create_agent MCP tool", () => { title: "Worktree agent", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); expect(broadcasts).toHaveLength(1); @@ -1948,7 +1984,6 @@ describe("create_agent MCP tool", () => { title: "Worktree agent", provider: "codex/gpt-5.4", initialPrompt: "Fix workspace creation naming", - background: true, }); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); @@ -2029,7 +2064,6 @@ describe("create_agent MCP tool", () => { title: "Agent title", provider: "codex/gpt-5.4", initialPrompt: "Build a workspace auto title flow", - background: true, }); const workspaceId = z.string().parse(createdWorkspaceIds[0]); await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow"); @@ -2131,7 +2165,6 @@ describe("create_agent MCP tool", () => { title: "Agent title", provider: "codex/gpt-5.4", initialPrompt: "Keep the manually renamed workspace title", - background: true, }); const renameWorkspaceTool = registeredTool(server, "rename_workspace"); await renameWorkspaceTool.handler({ @@ -2221,7 +2254,6 @@ describe("create_agent MCP tool", () => { title: "Explicit Agent Title", provider: "codex/gpt-5.4", initialPrompt: "Generate the workspace title anyway", - background: true, }); const workspaceId = z.string().parse(createdWorkspaceIds[0]); await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title"); @@ -2328,7 +2360,6 @@ describe("create_agent MCP tool", () => { title: "Directory agent", provider: "codex/gpt-5.4", initialPrompt: "Name a directory workspace from the prompt", - background: true, }); await waitForWorkspaceTitle( workspaceRecords, @@ -2435,7 +2466,6 @@ describe("create_agent MCP tool", () => { title: "Checkout agent", provider: "codex/gpt-5.4", initialPrompt: "Rename this checkout from the prompt", - background: true, }); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); @@ -2456,7 +2486,7 @@ describe("create_agent MCP tool", () => { }); expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({ version: 1, - baseRefName: "existing-feature", + baseRefName: "main", }); expect(generateCalls).toBe(1); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); @@ -2542,7 +2572,6 @@ describe("create_agent MCP tool", () => { title: "PR agent", provider: "codex/gpt-5.4", initialPrompt: "Rename this PR branch from prompt", - background: true, }); expect(createPaseoWorktree).toHaveBeenCalledWith( @@ -3054,6 +3083,47 @@ describe("create_agent MCP tool", () => { ]); }); + it("creates an background local workspace and leaves it out of list_workspaces", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const createCalls: Array<{ cwd: string; context: { background?: boolean } | undefined }> = []; + const backgroundWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-background", + projectId: "project-1", + cwd: process.cwd(), + kind: "directory", + displayName: "helper", + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:00:00.000Z", + background: true, + }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + workspaceRegistry: { + get: vi.fn(async () => backgroundWorkspace), + list: vi.fn(async () => [backgroundWorkspace]), + upsert: vi.fn(async () => undefined), + }, + createDirectoryWorkspace: async (cwd, _title, _projectId, context) => { + createCalls.push({ cwd, context }); + return backgroundWorkspace; + }, + logger, + }); + + const created = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), { + isolation: "local", + path: process.cwd(), + background: true, + }); + expect(created.structuredContent.workspaceId).toBe("ws-background"); + expect(createCalls).toEqual([{ cwd: process.cwd(), context: { background: true } }]); + + const listed = await registeredTool(server, "list_workspaces").handler({}); + expect(listed.structuredContent.workspaces).toEqual([]); + }); + it("accepts custom provider IDs in create_agent input validation", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ @@ -3082,6 +3152,7 @@ describe("create_agent MCP tool", () => { await mkdir(subdir, { recursive: true }); spies.agentManager.getAgent.mockReturnValue({ id: "voice-agent", + config: { title: "Voice" }, cwd: baseDir, workspaceId: "wks_voice", provider: "codex", @@ -3136,6 +3207,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3182,6 +3254,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3223,10 +3296,55 @@ describe("create_agent MCP tool", () => { ); }); + it("notifies the caller when a created child finishes its initial turn before the turn start returns", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-create-fast-child-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", false, { finishTurnsDuringStart: true }); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + + const response = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), { + ...subagentCurrentWorkspace(), + title: "Fast Child", + provider: "codex/gpt-5.4", + initialPrompt: "Finish immediately", + }); + const childId = z.object({ agentId: z.string() }).parse(response.structuredContent).agentId; + + await vi.waitFor(() => { + const parentPrompts = parentClient.sessions[0]!.prompts; + expect(parentPrompts).toHaveLength(1); + expect(parentPrompts[0]).toContain(childId); + expect(parentPrompts[0]).toContain("finished"); + }); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }); + it("creates detached caller agents without a parent label", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3279,6 +3397,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "claude", @@ -3593,6 +3712,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "claude", @@ -3645,6 +3765,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "claude", @@ -3693,6 +3814,11 @@ const HELD_TURN_CAPABILITIES = { supportsToolInvocations: false, } as const; +interface HeldTurnOptions { + /** Run the whole turn before the turn-start request returns. */ + finishTurnsDuringStart?: boolean; +} + /** * Provider session that records every prompt it receives. With `holdTurns`, a started * turn stays running until `finishTurn()` so a caller's bounded wait can run out first. @@ -3704,10 +3830,12 @@ class HeldTurnAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private activeTurnId: string | null = null; private turnStartGate: Promise<void> | null = null; + private nextStartError: Error | null = null; constructor( readonly provider: AgentProvider, private readonly holdTurns: boolean, + private readonly options: HeldTurnOptions = {}, ) {} /** Hold the next turn's acknowledgment, as a provider awaiting its turn-start request does. */ @@ -3723,12 +3851,44 @@ class HeldTurnAgentSession implements AgentSession { return { sessionId: this.id, finalText: "", timeline: [] }; } + rejectNextTurnStart(error: Error): void { + this.nextStartError = error; + } + + tryHandleOutOfBand(prompt: AgentPromptInput) { + const text = typeof prompt === "string" ? (parseAgentMessage(prompt)?.text ?? prompt) : null; + return text === "/side-command" ? { run: async () => {} } : null; + } + async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt)); await this.turnStartGate; + if (this.nextStartError) { + const error = this.nextStartError; + this.nextStartError = null; + throw error; + } const turnId = randomUUID(); + if (this.options.finishTurnsDuringStart) { + // The whole turn runs before the turn-start request returns, as a provider whose + // start acknowledgment arrives together with the turn's completion does. + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + return { turnId }; + } this.activeTurnId = turnId; + this.pushEvent({ + type: "timeline", + provider: this.provider, + turnId, + item: { + type: "user_message", + text: typeof prompt === "string" ? prompt : JSON.stringify(prompt), + messageId: randomUUID(), + }, + }); setTimeout(() => { + if (this.activeTurnId !== turnId) return; this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); if (!this.holdTurns && this.activeTurnId === turnId) { this.finishTurn(); @@ -3737,15 +3897,29 @@ class HeldTurnAgentSession implements AgentSession { return { turnId }; } - finishTurn(): void { + finishTurn(finalText?: string): void { const turnId = this.activeTurnId; if (!turnId) { throw new Error("No held turn to finish"); } this.activeTurnId = null; + if (finalText) + this.pushEvent({ + type: "timeline", + provider: this.provider, + turnId, + item: { type: "assistant_message", text: finalText }, + }); this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); } + failTurn(error: string): void { + const turnId = this.activeTurnId; + if (!turnId) throw new Error("No held turn to fail"); + this.activeTurnId = null; + this.pushEvent({ type: "turn_failed", provider: this.provider, error, turnId }); + } + subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.subscribers.add(callback); return () => { @@ -3809,6 +3983,7 @@ class HeldTurnAgentClient implements AgentClient { constructor( readonly provider: AgentProvider, private readonly holdTurns: boolean, + private readonly options: HeldTurnOptions = {}, ) {} async isAvailable(): Promise<boolean> { @@ -3816,7 +3991,7 @@ class HeldTurnAgentClient implements AgentClient { } async createSession(): Promise<AgentSession> { - const session = new HeldTurnAgentSession(this.provider, this.holdTurns); + const session = new HeldTurnAgentSession(this.provider, this.holdTurns, this.options); this.sessions.push(session); return session; } @@ -3838,6 +4013,7 @@ describe("send_agent_prompt MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3875,7 +4051,6 @@ describe("send_agent_prompt MCP tool", () => { throw new Error("Expected caller send_agent_prompt input to parse"); } expect(parsed.data).toMatchObject({ - background: true, notifyOnFinish: true, }); @@ -3888,7 +4063,7 @@ describe("send_agent_prompt MCP tool", () => { ); }); - it("keeps top-level prompts blocking by default", async () => { + it("acknowledges top-level prompts without a completion wait", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "child-agent", @@ -3916,23 +4091,20 @@ describe("send_agent_prompt MCP tool", () => { throw new Error("Expected top-level send_agent_prompt input to parse"); } expect(parsed.data).toMatchObject({ - background: false, notifyOnFinish: false, }); await tool.handler(parsed.data as Record<string, unknown>); expect(spies.agentManager.subscribe).not.toHaveBeenCalled(); - expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith( - "child-agent", - expect.objectContaining({ waitForActive: true }), - ); + expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); }); - it("does not arm a finish notification for blocking agent-scoped prompts", async () => { + it("allows agent-scoped callers to opt out of finish notifications", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3964,17 +4136,175 @@ describe("send_agent_prompt MCP tool", () => { await invokeToolWithParsedInput(tool, { agentId: "child-agent", prompt: "Follow up", - background: false, + notifyOnFinish: false, }); expect(spies.agentManager.subscribe).not.toHaveBeenCalled(); - expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith( - "child-agent", - expect.objectContaining({ waitForActive: true }), - ); + expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); }); - it("notifies the caller when a blocking agent-scoped prompt outlasts the wait", async () => { + it.each([ + { immediateFinish: false, failure: false }, + { immediateFinish: true, failure: false }, + { immediateFinish: false, failure: true }, + ])( + "notifies the retry outcome rather than the prior failed turn (immediate: $immediateFinish, failure: $failure)", + async ({ immediateFinish, failure }) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-retry-notification-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd, title: "Retry child" }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Old task", + notifyOnFinish: false, + }); + childClient.sessions[0]!.failTurn("Old task failed"); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("error")); + const unsubscribe = agentManager.subscribe( + (event) => { + if ( + immediateFinish && + event.type === "agent_state" && + event.agent.lifecycle === "running" + ) { + childClient.sessions[0]!.finishTurn("Retry done"); + } + }, + { agentId: child.id, replayState: false }, + ); + const response = await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Retry now", + }); + unsubscribe(); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "turn_started", + }); + if (!immediateFinish) { + expect(response.structuredContent.status).toBe("running"); + if (failure) childClient.sessions[0]!.failTurn("Retry failed"); + else childClient.sessions[0]!.finishTurn("Retry done"); + } + await vi.waitFor(() => { + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + expect(parentClient.sessions[0]!.prompts[0]).toContain( + `Agent ${child.id} (Retry child) ${failure ? "errored" : "finished"}.`, + ); + expect(parentClient.sessions[0]!.prompts[0]).not.toContain("Old task failed"); + }); + await vi.waitFor(() => expect(agentManager.getAgent(parent.id)?.lifecycle).toBe("idle")); + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }, + ); + + it.each(["out_of_band", "startup_failure"] as const)( + "preserves %s dispatch notification semantics", + async (scenario) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Ongoing task", + notifyOnFinish: false, + }); + if (scenario === "out_of_band") { + const response = await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "/side-command", + }); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "out_of_band", + status: "running", + }); + expect(childClient.sessions[0]!.prompts).toHaveLength(1); + expect(parseAgentMessage(childClient.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-message", agentId: parent.id }, + text: "Ongoing task", + }); + expect(parentClient.sessions[0]!.prompts).toHaveLength(0); + childClient.sessions[0]!.finishTurn("Ongoing task done"); + await vi.waitFor(() => { + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + expect(parentClient.sessions[0]!.prompts[0]).toContain("finished."); + }); + } else { + childClient.sessions[0]!.rejectNextTurnStart(new Error("Requested turn could not start")); + await expect( + invokeToolWithParsedInput(tool, { agentId: child.id, prompt: "Replacement task" }), + ).rejects.toThrow("Requested turn could not start"); + // A later real turn must not inherit a subscription from the rejected dispatch. + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Recovery", + notifyOnFinish: false, + }); + childClient.sessions[0]!.finishTurn("Recovered"); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle")); + expect(parentClient.sessions[0]!.prompts).toHaveLength(0); + } + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }, + ); + + it("acknowledges a pending agent-scoped turn and notifies its completion", async () => { const workdir = await mkdtemp(join(tmpdir(), "mcp-blocking-send-timeout-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const parentClient = new HeldTurnAgentClient("claude", false); @@ -4005,24 +4335,19 @@ describe("send_agent_prompt MCP tool", () => { }); const tool = registeredTool(server, "send_agent_prompt"); - vi.useFakeTimers(); const pending = invokeToolWithParsedInput(tool, { agentId: child.id, prompt: "Follow up", - background: false, }); - await vi.advanceTimersByTimeAsync(31_000); const response = await pending; expect(response.structuredContent).toMatchObject({ status: "running", guidance: "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", }); - expect(response.structuredContent.lastMessage).toContain("timed out after 30s"); + expect(response.structuredContent).not.toHaveProperty("lastMessage"); childClient.sessions[0]!.finishTurn(); - await vi.advanceTimersByTimeAsync(1_000); - vi.useRealTimers(); await vi.waitFor(() => { const parentPrompts = parentClient.sessions[0]!.prompts; @@ -4081,6 +4406,7 @@ describe("send_agent_prompt MCP tool", () => { const childSession = childClient.sessions[0]!; await vi.waitFor(() => expect(childSession.prompts).toHaveLength(2)); + expect(parentClient.sessions[0]!.prompts).toHaveLength(0); childSession.finishTurn(); function finishNotifications() { @@ -4091,6 +4417,10 @@ describe("send_agent_prompt MCP tool", () => { await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0)); await new Promise((resolve) => setTimeout(resolve, 100)); expect(finishNotifications()).toHaveLength(1); + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + expect(parseAgentMessage(parentClient.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-notification", agentId: childId, event: "finished" }, + }); } finally { await removeAgentStateDir(agentManager, storage, workdir); } @@ -4133,7 +4463,12 @@ describe("send_agent_prompt MCP tool", () => { agentId: child.id, prompt: "Follow up", }); - await vi.waitFor(() => expect(childSession.prompts).toEqual(["Follow up"])); + await vi.waitFor(() => expect(childSession.prompts).toHaveLength(1)); + expect(parseAgentMessage(childSession.prompts[0])).toEqual({ + id: expect.any(String), + source: { kind: "agent-message", agentId: parent.id }, + text: "Follow up", + }); acknowledgeTurnStart(); const response = await pending; @@ -4205,7 +4540,11 @@ describe("send_agent_prompt MCP tool", () => { }, ); unsubscribe(); - expect(response.structuredContent).toMatchObject({ success: true, status: "idle" }); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "turn_started", + }); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle")); await vi.waitFor(() => { const parentPrompts = parentClient.sessions[0]!.prompts; expect(parentPrompts).toHaveLength(1); diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 8b894a7765..ae8ed805d7 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1121,3 +1121,50 @@ describe("plugin provider launch and status", () => { }, ); }); + +test("config-required plugin membership is independent of enablement and other providers", async () => { + const logger = createTestLogger(); + const plugins = new PluginAgentClientRegistry(logger); + const harness = createProviderHarness(); + let connections = 0; + let probes = 0; + plugins.replace([ + { + ...harness.registration, + requiresConfiguration: true, + async status() { + probes++; + return { available: true }; + }, + async connect(request) { + connections++; + return harness.registration.connect(request); + }, + }, + ]); + const build = (providerOverrides = {}) => + buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides, + }); + try { + const before = build(); + expect(before["plugin-direct"]).toBeUndefined(); + expect({ probes, connections }).toEqual({ probes: 0, connections: 0 }); + const added = build({ "plugin-direct": {} }); + expect(added["plugin-direct"].enabled).toBe(true); + const client = added["plugin-direct"].createClient(logger); + expect(await client.isAvailable()).toBe(true); + const catalog = await client.fetchCatalog({ scope: "global" }); + expect(catalog.models.map((model) => model.id)).toEqual(["plugin-model"]); + expect({ probes, connections }).toEqual({ probes: 1, connections: 1 }); + const disabled = build({ "plugin-direct": { enabled: false } }); + expect(disabled["plugin-direct"].enabled).toBe(false); + expect(Object.keys(build())).toEqual(Object.keys(before)); + for (const id of Object.keys(before)) { + expect(added[id].enabled).toBe(before[id].enabled); + } + } finally { + await plugins.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 58bddd07b6..e575ff0d45 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -885,6 +885,7 @@ function createPluginProviderDefinition( label: registration.label, description: registration.description ?? `Plugin provider ${registration.label}`, iconSvg: registration.icon, + requiresConfiguration: registration.requiresConfiguration, defaultModeId: null, modes: [], supportsExactMcpPreapproval: true, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index bd22d37df3..9f85e4d37f 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -97,6 +97,7 @@ export interface ProviderDefinition extends AgentProviderDefinition { /** Provider metadata and an unconfigured factory; the registry owns override application. */ export interface RegisteredProviderDefinition extends AgentProviderDefinition { + requiresConfiguration?: boolean; iconSvg?: string; supportsExactMcpPreapproval: boolean; createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient; @@ -492,6 +493,7 @@ function wrapClientProvider( const listFeatures = inner.listFeatures?.bind(inner); const archiveNativeSession = inner.archiveNativeSession?.bind(inner); const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner); + const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id); return { provider, @@ -509,7 +511,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), resumeSession: async (handle, overrides, launchContext, options) => @@ -526,7 +528,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), fetchCatalog: async (options, context) => { @@ -924,6 +926,8 @@ export function buildProviderRegistry( throw new Error(`Plugin provider '${provider}' conflicts with a built-in provider`); } const override = providerOverrides[provider]; + if (definition.requiresConfiguration && !override) continue; + if (override?.extends) { logger.warn({ provider }, "Plugin provider shadowed by configured provider"); continue; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 82acd13e1f..b10a4f8e02 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -348,6 +348,10 @@ export class ProviderSnapshotManager { return Object.prototype.hasOwnProperty.call(this.generation.definitions, provider); } + supportsConfigRequiredProvider(provider: AgentProvider): boolean { + return this.pluginProviders.definitions()[provider]?.requiresConfiguration === true; + } + getProviderLabel(provider: AgentProvider): string { return this.generation.definitions[provider]?.label ?? provider; } diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index f847745d69..33c9696900 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1,9 +1,11 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AgentSideConnection, ClientSideConnection, @@ -52,8 +54,10 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentCapabilityFlags, AgentPersistenceHandle, + AgentSession, ProviderRefreshContext, } from "../agent-sdk-types.js"; +import { buildProviderRegistry } from "../provider-registry.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js"; import { asInternals } from "../../test-utils/class-mocks.js"; @@ -3436,6 +3440,8 @@ interface ACPCloseInternals { child: ChildProcess | null; connection: unknown; sessionId: string | null; + activeForegroundTurnId: string | null; + agentCapabilities: { sessionCapabilities?: { close?: unknown } } | null; } async function startTerminal( @@ -3502,6 +3508,38 @@ describe("ACPAgentSession close() tree-kill", () => { expect(child.kill).not.toHaveBeenCalled(); }); + test("close() terminates the provider when cancel and closeSession never settle", async () => { + vi.useFakeTimers(); + try { + const terminator = new FakeTerminator(); + const session = createSession({ terminateProcess: terminator.terminate }); + const child = createTerminalChildStub(); + const cancel = vi.fn(() => new Promise<void>(() => undefined)); + const unstableCloseSession = vi.fn(() => new Promise<void>(() => undefined)); + const internals = asInternals<ACPCloseInternals>(session); + internals.child = child; + internals.sessionId = "session-1"; + internals.activeForegroundTurnId = "turn-1"; + internals.agentCapabilities = { sessionCapabilities: { close: {} } }; + internals.connection = { cancel, unstable_closeSession: unstableCloseSession }; + + let settled = false; + const closing = (async () => { + await session.close(); + settled = true; + })(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(settled).toBe(true); + expect(cancel).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(unstableCloseSession).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(terminator.terminated).toContain(child); + await closing; + } finally { + vi.useRealTimers(); + } + }); + test("close() terminates running terminal child processes", async () => { const terminator = new FakeTerminator(); const session = createSession({ terminateProcess: terminator.terminate }); @@ -4220,3 +4258,265 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () = }); }); }); + +const SILENT_CLOSE_ACP_AGENT = ` +import { readFileSync, writeFileSync } from "node:fs"; +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +writeFileSync(process.env.ACP_PID_FILE, String(process.pid)); +new AgentSideConnection( + () => ({ + async initialize() { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }; + }, + async newSession() { + return { sessionId: "silent-close-session" }; + }, + async authenticate() {}, + async cancel() {}, + unstable_closeSession() { + return new Promise(() => {}); + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function settlesWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> { + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise<false>((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }); + try { + return await Promise.race([promise.then(() => true), timedOut]); + } finally { + clearTimeout(timer); + } +} + +interface SilentCloseProvider { + session: ACPAgentSession; + readPid(): Promise<number>; + dispose(): Promise<void>; +} + +async function startSilentCloseProvider(): Promise<SilentCloseProvider> { + const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-silent-close-")); + const agentScript = path.join(dir, "agent.mjs"); + const pidFile = path.join(dir, "agent.pid"); + await writeFile(agentScript, SILENT_CLOSE_ACP_AGENT); + const session = new ACPAgentSession( + { provider: "silent-close-acp", cwd: dir }, + { + provider: "silent-close-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, agentScript], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + launchEnv: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ACP_PID_FILE: pidFile, + }, + }, + ); + const readPid = async () => Number(await readFile(pidFile, "utf8")); + return { + session, + readPid, + async dispose() { + const pid = await readPid().catch(() => null); + if (pid !== null && isProcessAlive(pid)) { + process.kill(pid, "SIGKILL"); + } + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +describe("ACPAgentSession close() with an unresponsive provider", () => { + let provider: SilentCloseProvider | null = null; + + afterEach(async () => { + await provider?.dispose(); + provider = null; + }); + + test("terminates a provider that never answers session/close", async () => { + provider = await startSilentCloseProvider(); + await provider.session.initializeNewSession(); + const pid = await provider.readPid(); + expect(isProcessAlive(pid)).toBe(true); + + expect(await settlesWithin(provider.session.close(), 8_000)).toBe(true); + expect(isProcessAlive(pid)).toBe(false); + }, 15_000); +}); + +// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but, +// like Cline, runs any model id it is given. +const PERMISSIVE_MODEL_ACP_AGENT = ` +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +let currentModelId = "advertised-default"; +const modelState = () => + process.env.ACP_ADVERTISE_MODELS === "none" + ? undefined + : { + currentModelId, + availableModels: [ + { modelId: "advertised-default", name: "Advertised default" }, + { modelId: "advertised-other", name: "Advertised other" }, + ], + }; +new AgentSideConnection( + () => ({ + async initialize() { + return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } }; + }, + async newSession() { + return { sessionId: "permissive-session", models: modelState() }; + }, + async loadSession() { + return { models: modelState() }; + }, + async unstable_setSessionModel({ modelId }) { + currentModelId = modelId; + return {}; + }, + async authenticate() {}, + async cancel() {}, + async prompt() { + return { stopReason: "end_turn" }; + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +describe("custom ACP provider with configured models", () => { + let dir: string; + let agentScript: string; + let session: AgentSession | null = null; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-configured-models-")); + agentScript = path.join(dir, "agent.mjs"); + await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT); + }); + + afterEach(async () => { + await session?.close(); + session = null; + await rm(dir, { recursive: true, force: true }); + }); + + function buildRegistry( + override: { + models?: Array<{ id: string; label: string }>; + additionalModels?: Array<{ id: string; label: string }>; + }, + agentEnv: Record<string, string> = {}, + ) { + return buildProviderRegistry(createTestLogger(), { + providerOverrides: { + permissive: { + extends: "acp", + label: "Permissive", + command: [process.execPath, agentScript], + env: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ...agentEnv, + }, + ...override, + }, + }, + }); + } + + async function createSessionOnModel( + registry: ReturnType<typeof buildRegistry>, + model: string, + ): Promise<AgentSession> { + const client = registry.permissive.createClient(createTestLogger()); + session = await client.createSession({ provider: "permissive", cwd: dir, model }); + return session; + } + + test("runs on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from a replacement models list that the agent does not advertise", async () => { + const registry = buildRegistry({ + models: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from additionalModels when the agent advertises no models", async () => { + const registry = buildRegistry( + { additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }] }, + { ACP_ADVERTISE_MODELS: "none" }, + ); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("resumes on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + const client = registry.permissive.createClient(createTestLogger()); + + session = await client.resumeSession( + { provider: "permissive", sessionId: "permissive-session", metadata: { cwd: dir } }, + { model: "stealth/pixel-canary" }, + ); + + expect((await session.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("keeps the agent's model when the requested model is neither advertised nor configured", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stale/removed-model"); + + expect((await created.getRuntimeInfo()).model).toBe("advertised-default"); + }); +}); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 1b47ccaccb..3b61fcb863 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -76,6 +76,9 @@ import { type AgentPersistenceHandle, type AgentPromptContentBlock, type AgentPromptInput, + type AgentResumePurpose, + type AgentCreateSessionOptions, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -148,6 +151,18 @@ function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promis }); } +async function isDirectory(target: string): Promise<boolean> { + try { + return (await fs.stat(target)).isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + function isRecord(value: unknown): value is Record<string, unknown> { return value != null && typeof value === "object" && !Array.isArray(value); } @@ -301,6 +316,10 @@ export function buildACPClientCapabilities( const PROBE_ENV: Record<string, string> = { NO_BROWSER: "true" }; const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000; const ACP_PROBE_CLOSE_TIMEOUT_MS = 2_000; +// Archive and delete await close(). The ACP SDK does not reject a pending +// request when the provider never answers, so these calls must be bounded +// or the provider process is never terminated. +const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000; const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000; const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000; @@ -493,6 +512,8 @@ interface ACPAgentSessionOptions { capabilities: AgentCapabilityFlags; extensionCommandsParser?: ACPExtensionCommandsParser; handle?: AgentPersistenceHandle; + resumePurpose?: AgentResumePurpose; + configuredModelIds?: readonly string[]; agentId?: string; launchEnv?: Record<string, string>; waitForInitialCommands?: boolean; @@ -960,6 +981,7 @@ export class ACPAgentClient implements AgentClient { async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise<AgentSession> { this.assertProvider(config); const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {}); @@ -987,6 +1009,7 @@ export class ACPAgentClient implements AgentClient { supportsMcpServers: providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1002,6 +1025,7 @@ export class ACPAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise<AgentSession> { if (handle.provider !== this.provider) { throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`); @@ -1043,6 +1067,8 @@ export class ACPAgentClient implements AgentClient { providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, handle, + resumePurpose: options?.purpose, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1671,6 +1697,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly configuredModelIds: ReadonlySet<string>; private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; @@ -1696,6 +1723,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly terminalEntries = new Map<string, TerminalEntry>(); private readonly persistedHistory: AgentTimelineItem[] = []; private readonly initialHandle?: AgentPersistenceHandle; + private readonly resumePurpose: AgentResumePurpose; private readonly config: AgentSessionConfig; private child: ChildProcessWithoutNullStreams | null = null; @@ -1722,6 +1750,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private fallbackAssistantMessageId: string | null = null; private closed = false; private historyPending = false; + private historyRuntimeReleased = false; private replayingHistory = false; private bootstrapThreadEventPending = false; private readonly terminateProcess: ProcessTerminator; @@ -1738,6 +1767,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.configuredModelIds = new Set(options.configuredModelIds); this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; @@ -1749,6 +1779,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.agentId = options.agentId; this.launchEnv = options.launchEnv; this.initialHandle = options.handle; + this.resumePurpose = options.resumePurpose ?? "interactive"; this.config = { ...config, provider: options.provider }; this.currentMode = config.modeId ?? null; this.currentModel = config.model ?? null; @@ -1837,11 +1868,38 @@ export class ACPAgentSession implements AgentSession, ACPClient { } await this.applyConfiguredOverrides(); + + if (this.resumePurpose === "history") { + await this.releaseHistoryRuntime(); + } } catch (error) { await this.closeAfterInitializationFailure(error); } } + // History resumes exist to replay persisted events into the canonical + // timeline; the provider process is released once replay finishes so + // archived agents hold no resident runtime. Interactive work goes through + // reloadAgentSession, which spawns a fresh process. + private async releaseHistoryRuntime(): Promise<void> { + const child = this.child; + if (child) { + const result = await this.terminateProcess(child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }); + if (result === "kill-timeout") { + // Keep the child registered so close() can retry the shutdown. + this.logger.warn("ACP history resume could not release the provider process"); + return; + } + } + this.historyRuntimeReleased = true; + this.connection = null; + this.child = null; + this.settleCommandsReady(); + } + private async closeAfterInitializationFailure(error: unknown): Promise<never> { try { await this.close(); @@ -2171,12 +2229,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } + await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) }); + } + + private resolveModelSelection(modelId: string): ACPModelSelection { const selection = resolveACPModelSelection({ modelId, availableModels: this.availableModels, configOptions: this.configOptions, }); - await this.setModelWithSelection({ modelId, selection }); + if (selection.availableModel || !this.configuredModelIds.has(modelId)) { + return selection; + } + // A model the user added in provider config is applied even when the agent does not advertise it. + return { ...selection, availableModel: { modelId, name: modelId } }; } private async setModelWithSelection({ @@ -2190,21 +2256,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error("ACP session not initialized"); } - if (selection.hasAvailableModels) { - if (!selection.availableModel) { - this.warnInvalidSelection( - modelId, - `is not a valid ${this.provider} model. Available options: ${this.availableModels - ?.map((model) => model.modelId) - .join(", ")}`, - ); - return; - } - - if (typeof this.connection.unstable_setSessionModel !== "function") { - throw new Error(this.modelSelectionUnavailableMessage()); - } + if (selection.hasAvailableModels && !selection.availableModel) { + this.warnInvalidSelection( + modelId, + `is not a valid ${this.provider} model. Available options: ${this.availableModels + ?.map((model) => model.modelId) + .join(", ")}`, + ); + return; + } + if ( + selection.availableModel && + typeof this.connection.unstable_setSessionModel === "function" + ) { try { await this.connection.unstable_setSessionModel({ sessionId: this.sessionId, @@ -2476,13 +2541,21 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (this.connection && this.sessionId) { try { if (this.activeForegroundTurnId) { - await this.connection.cancel({ sessionId: this.sessionId }); + await withTimeout( + this.connection.cancel({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP cancel timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch {} try { if (this.agentCapabilities?.sessionCapabilities?.close) { - await this.connection.unstable_closeSession({ sessionId: this.sessionId }); + await withTimeout( + this.connection.unstable_closeSession({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP closeSession timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch (error) { this.logger.debug({ err: error }, "ACP closeSession failed during shutdown"); @@ -2758,6 +2831,16 @@ export class ACPAgentSession implements AgentSession, ACPClient { return {}; } + // Loading an archived agent's history runs nothing in its working directory, which + // may have been removed with its worktree. session/load still names the original + // directory; only the process starts from the home directory. + private async resolveProcessCwd(): Promise<string> { + if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) { + return this.config.cwd; + } + return homedir(); + } + private async spawnProcess(): Promise<SpawnedACPProcess> { const prefix = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, @@ -2771,7 +2854,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { const command = prefix.command; const args = [...prefix.args, ...this.defaultCommand.slice(1)]; const child = spawnProcess(command, args, { - cwd: this.config.cwd, + cwd: await this.resolveProcessCwd(), ...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings, overlays: [this.launchEnv], @@ -2786,7 +2869,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { }); const spawnError = rejectOnSpawnError(child, stderrChunks); child.once("exit", (code, signal) => { - if (this.closed) { + if (this.closed || this.historyRuntimeReleased) { return; } if (this.activeForegroundTurnId) { @@ -2881,13 +2964,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { const configuredModelId = this.config.model; let switchedModel = false; if (configuredModelId && configuredModelId !== this.currentModel) { - const selection = resolveACPModelSelection({ - modelId: configuredModelId, - availableModels: this.availableModels, - configOptions: this.configOptions, - }); try { - await this.setModelWithSelection({ modelId: configuredModelId, selection }); + await this.setModelWithSelection({ + modelId: configuredModelId, + selection: this.resolveModelSelection(configuredModelId), + }); switchedModel = true; } catch (error) { if (!this.isModelSelectionUnavailableError(error)) { @@ -3017,7 +3098,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.cachedCommands = update.availableCommands.map((command) => ({ name: command.name, description: command.description, - argumentHint: "", + argumentHint: command.input?.hint ?? "", kind: "command", })); this.settleCommandsReady(); diff --git a/packages/server/src/server/agent/providers/claude/agent.context-switch.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.context-switch.real.e2e.test.ts new file mode 100644 index 0000000000..f05a6ee271 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.context-switch.real.e2e.test.ts @@ -0,0 +1,42 @@ +import pino from "pino"; +import { expect, test } from "vitest"; +import { ClaudeAgentClient } from "./agent.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; + +// Explicit real-provider test: run separately from the default suite. +test("uses the active model context window after switching from a 1M model", async () => { + const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) }); + const session = await client.createSession( + { provider: "claude", cwd: process.cwd(), model: "claude-haiku-4-5" }, + undefined, + { persistSession: false }, + ); + try { + for (const [model, maxTokens] of [ + ["claude-haiku-4-5", 200_000], + ["claude-opus-5-5", 1_000_000], + ["claude-haiku-4-5", 200_000], + ] as const) { + await session.setModel(model); + const events = await collectSessionTurnEvents( + session, + "Reply with exactly: OK. Do not use tools.", + { signal: AbortSignal.timeout(90_000) }, + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }), + }), + ); + } + } finally { + await session.close(); + } +}, 300_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.context-usage.local.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.context-usage.local.e2e.test.ts new file mode 100644 index 0000000000..7c3883986a --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.context-usage.local.e2e.test.ts @@ -0,0 +1,140 @@ +import { createServer } from "node:http"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +// Exercise the real Claude Code process against a local Messages API fixture. +// The input counter's location is the only difference between the two backends. +test.each([ + { placement: "start", cacheTokens: 0 }, + { placement: "delta", cacheTokens: 0 }, + { placement: "start", cacheTokens: 1_000 }, + { placement: "delta", cacheTokens: 1_000 }, + { placement: "start", cacheTokens: 0, model: "glm-5.2[1m]" }, + { placement: "start", cacheTokens: 0, model: "sonnet", resolvedModel: "glm-5.2[1m]" }, +])( + "counts $placement input and $cacheTokens cached tokens through Claude Code ($model)", + async ({ placement, cacheTokens, model = "claude-sonnet-4-6", resolvedModel = model }) => { + const cwd = await mkdtemp(path.resolve(".claude-context-usage-")); + const configDir = path.join(cwd, "config"); + await mkdir(configDir); + await writeFile( + path.join(configDir, "settings.json"), + JSON.stringify({ env: { ANTHROPIC_DEFAULT_SONNET_MODEL: resolvedModel } }), + ); + let requestCount = 0; + const server = createServer(async (request, response) => { + for await (const chunk of request) { + void chunk; + } + if (!request.url?.startsWith("/v1/messages")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end("{}"); + return; + } + if (request.url.includes("count_tokens")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ input_tokens: 20_236 })); + return; + } + const inputTokens = 20_236 + requestCount++; + response.writeHead(200, { "content-type": "text/event-stream" }); + const emit = (data: { type: string; [key: string]: unknown }) => { + response.write(`event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`); + }; + emit({ + type: "message_start", + message: { + id: `msg_context_usage_${requestCount}`, + type: "message", + role: "assistant", + model: resolvedModel, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: placement === "start" ? inputTokens : 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: cacheTokens, + }, + }, + }); + emit({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }); + emit({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hello" } }); + emit({ type: "content_block_stop", index: 0 }); + emit({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { + output_tokens: 64, + ...(placement === "delta" ? { input_tokens: inputTokens } : {}), + }, + }); + emit({ type: "message_stop" }); + response.end(); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Fixture has no TCP address"); + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + runtimeSettings: { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`, + ANTHROPIC_AUTH_TOKEN: "fixture-token", + ANTHROPIC_API_KEY: "", + CLAUDE_CONFIG_DIR: configDir, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }, + }, + }); + const session = await client.createSession({ provider: "claude", cwd, model }, undefined, { + persistSession: false, + }); + try { + for (let turn = 0; turn < 2; turn++) { + const events = await collectSessionTurnEvents(session, "say hello", { + signal: AbortSignal.timeout(20_000), + }); + const expectedTokens = 20_236 + turn + cacheTokens + 64; + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ + inputTokens: 20_236 + turn, + outputTokens: 64, + cachedInputTokens: cacheTokens, + contextWindowUsedTokens: expectedTokens, + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ + contextWindowUsedTokens: expectedTokens, + contextWindowMaxTokens: resolvedModel.endsWith("[1m]") ? 1_000_000 : 200_000, + }), + }), + ); + } + expect(requestCount).toBe(2); + } finally { + try { + await session.close(); + } finally { + try { + server.closeAllConnections(); + await new Promise<void>((resolve) => server.close(() => resolve())); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + } + }, + 60_000, +); diff --git a/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts b/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts new file mode 100644 index 0000000000..84f31779e4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts @@ -0,0 +1,218 @@ +import { expect, test } from "vitest"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; +import type { ClaudeQueryFactory } from "./query.js"; + +// A query dependency that speaks the same stream interface as Claude Code. +// Each request repeats init so a second turn exercises runtime-limit retention. +function contextQuery({ + runtimeModel, + reportedLimit, + modelWindowsByTurn, +}: { + runtimeModel: string; + reportedLimit: number; + modelWindowsByTurn?: Array<Record<string, number>>; +}): ClaudeQueryFactory { + return ({ prompt }) => { + let model = runtimeModel; + let turn = 0; + const messages = (async function* () { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + for await (const _message of prompt) { + const modelWindows = modelWindowsByTurn?.[turn] ?? { [model]: reportedLimit }; + turn += 1; + const usageEntries = Object.entries(modelWindows).map(([id, contextWindow]) => [ + id, + { contextWindow }, + ]); + const modelUsage = Object.fromEntries(usageEntries); + yield { + type: "system", + subtype: "init", + session_id: "context-session", + permissionMode: "default", + model, + }; + yield { + type: "stream_event", + session_id: "context-session", + event: { type: "message_start", message: { usage: { input_tokens: 249_950 } } }, + }; + yield { + type: "stream_event", + session_id: "context-session", + event: { type: "message_delta", usage: { output_tokens: 50 } }, + }; + yield { + type: "result", + subtype: "success", + session_id: "context-session", + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: "done", + stop_reason: null, + total_cost_usd: 0, + usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 }, + modelUsage, + permission_denials: [], + uuid: "context-result", + }; + } + })(); + return Object.assign(messages, { + close: () => { + void messages.return(); + }, + interrupt: async () => undefined, + setModel: async (value: string) => { + model = value; + }, + setPermissionMode: async () => undefined, + supportedModels: async () => [], + supportedCommands: async () => [], + }) as ReturnType<ClaudeQueryFactory>; + }; +} + +test.each([ + { selected: "glm-5.2[1m]", runtime: "glm-5.2[1m]", expected: 1_000_000 }, + { selected: "sonnet", runtime: "glm-5.2[1m]", expected: 1_000_000 }, + { selected: "glm-5.2", runtime: "glm-5.2", expected: undefined }, +])( + "streams the context limit for $selected resolved as $runtime", + async ({ selected, runtime, expected }) => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: contextQuery({ runtimeModel: runtime, reportedLimit: 800_000 }), + resolveBinary: async () => process.execPath, + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: selected, + }); + try { + const first = await collectSessionTurnEvents(session, "first turn"); + const live = first.filter((event) => event.type === "usage_updated"); + expect(live.map((event) => event.usage.contextWindowMaxTokens)).toEqual([expected, expected]); + expect(live.map((event) => event.usage.contextWindowUsedTokens)).toEqual([249_950, 250_000]); + expect(first).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: 800_000 }), + }), + ); + + const second = await collectSessionTurnEvents(session, "second turn"); + expect( + second + .filter((event) => event.type === "usage_updated") + .map((event) => event.usage.contextWindowMaxTokens), + ).toEqual([800_000, 800_000]); + + await session.setModel?.("another-custom-model"); + const switched = await collectSessionTurnEvents(session, "after model switch"); + expect( + switched + .filter((event) => event.type === "usage_updated") + .map((event) => event.usage.contextWindowMaxTokens), + ).toEqual([undefined, undefined]); + } finally { + await session.close(); + } + }, +); + +test("completed turns use the active model's window after switching both ways", async () => { + const haiku = "claude-haiku-4-5"; + const opus = "claude-opus-5-5"; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => process.execPath, + queryFactory: contextQuery({ + runtimeModel: haiku, + reportedLimit: 200_000, + modelWindowsByTurn: [ + { [haiku]: 200_000 }, + { [haiku]: 200_000, [opus]: 1_000_000 }, + { [haiku]: 200_000, [opus]: 1_000_000 }, + ], + }), + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: haiku, + }); + try { + for (const [model, limit] of [ + [haiku, 200_000], + [opus, 1_000_000], + [haiku, 200_000], + ] as const) { + await session.setModel(model); + const events = await collectSessionTurnEvents(session, "OK"); + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ contextWindowMaxTokens: limit }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: limit }), + }), + ); + } + } finally { + await session.close(); + } +}); + +test.each([ + { + name: "runtime-reported custom limit", + model: "custom-model", + windows: { "custom-model": 400_000, "prior-model": 1_000_000 }, + expected: 400_000, + }, + { + name: "missing active model entry", + model: "claude-haiku-4-5", + windows: { "prior-model": 1_000_000 }, + expected: 200_000, + }, + { + name: "invalid active model limit", + model: "claude-haiku-4-5", + windows: { "claude-haiku-4-5": -1, "prior-model": 1_000_000 }, + expected: 200_000, + }, +])("preserves $name without using another model's window", async ({ model, windows, expected }) => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => process.execPath, + queryFactory: contextQuery({ + runtimeModel: model, + reportedLimit: expected, + modelWindowsByTurn: [windows], + }), + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd(), model }); + try { + const events = await collectSessionTurnEvents(session, "OK"); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: expected }), + }), + ); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index c99687e438..0d2748fd62 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -31,6 +31,7 @@ function createQueryMock(events: unknown[]): Query { describe("Claude SDK env", () => { test("forwards launch-context env through Claude process env", async () => { let capturedEnv: Record<string, string | undefined> | undefined; + let capturedPerTaskStopAffordance: boolean | undefined; const launchContext: AgentLaunchContext = { env: { PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000201", @@ -39,6 +40,7 @@ describe("Claude SDK env", () => { }; const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { capturedEnv = options.env; + capturedPerTaskStopAffordance = options.perTaskStopAffordance; return createQueryMock([ { type: "system", @@ -91,6 +93,10 @@ describe("Claude SDK env", () => { expect(capturedEnv?.MCP_TIMEOUT).toBe("claude-startup-timeout"); expect(capturedEnv?.MCP_TOOL_TIMEOUT).toBe("claude-tool-timeout"); expect(session.usageSession?.()?.env).toBe(capturedEnv); + // Paseo reads session_state_changed to know when an autonomous turn is over. + expect(capturedEnv?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS).toBe("1"); + // Without it, Stop and replace kill every background subagent along with the turn. + expect(capturedPerTaskStopAffordance).toBe(true); } finally { await session.close(); expect(session.usageSession?.()).toBeNull(); diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index 223e2001f5..50d9c56ab5 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import pino from "pino"; @@ -90,4 +90,29 @@ describe("ClaudeAgentSession persisted history load", () => { }), ); }); + + // chmod does not remove read access on Windows, or for root. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "logs a warning with the error when Claude's project folders cannot be listed", + async () => { + // The transcript is not under cwd's folder, so the lookup lists the other project folders. + mkdirSync(path.join(configDir, "projects"), { recursive: true }); + chmodSync(path.join(configDir, "projects"), 0o000); + + try { + await resume(); + } finally { + chmodSync(path.join(configDir, "projects"), 0o755); + } + + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.warn, + msg: "Failed to load Claude history from transcript", + sessionId: SESSION_ID, + err: expect.objectContaining({ message: expect.stringContaining("EACCES") }), + }), + ); + }, + ); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts new file mode 100644 index 0000000000..e20b8d6faf --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts @@ -0,0 +1,239 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +const SESSION_CWD = "/tmp/paseo-claude-renamed-import"; + +interface ClaudeTranscriptRecord { + [key: string]: unknown; +} + +let configDir: string; +let previousConfigDir: string | undefined; + +interface UserRecordInput { + sessionId: string; + content: string; +} + +interface WriteSessionInput { + sessionId: string; + records: ClaudeTranscriptRecord[]; +} + +function userRecord({ sessionId, content }: UserRecordInput): ClaudeTranscriptRecord { + return { + isSidechain: false, + type: "user", + message: { role: "user", content }, + cwd: SESSION_CWD, + sessionId, + }; +} + +async function writeSession({ sessionId, records }: WriteSessionInput): Promise<void> { + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, + ); +} + +// Importing replays the transcript from disk, so this drives the real resume +// path without starting Claude Code. +async function importSession(sessionId: string) { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + }); + return client.importSession( + { providerHandleId: sessionId, cwd: SESSION_CWD }, + { + config: { provider: "claude", cwd: SESSION_CWD }, + storedConfig: { provider: "claude", cwd: SESSION_CWD }, + }, + ); +} + +beforeEach(async () => { + configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-renamed-")); + previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; +}); + +afterEach(async () => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + await fs.rm(configDir, { recursive: true, force: true }); +}); + +describe("importing a renamed Claude session", () => { + test.each(["provider", "launch"])( + "reads the rename from the %s config directory used for history", + async (source) => { + const sessionId = "provider-directory-rename"; + const providerConfigDir = path.join(configDir, "provider"); + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir: providerConfigDir }); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ] + .map((record) => JSON.stringify(record)) + .join("\n") + "\n", + ); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: + source === "provider" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined, + resolveBinary: async () => "/test/claude/bin", + }); + const imported = await client.importSession( + { providerHandleId: sessionId, cwd: SESSION_CWD }, + { + config: { provider: "claude", cwd: SESSION_CWD }, + storedConfig: { provider: "claude", cwd: SESSION_CWD }, + launchContext: + source === "launch" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined, + }, + ); + expect(imported.timeline.map((entry) => entry.item)).toContainEqual( + expect.objectContaining({ type: "user_message", text: "Review this project" }), + ); + expect(imported.config.title).toBe("My research session"); + }, + ); + + test("names the imported agent after a /rename", async () => { + const sessionId = "renamed-session"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("uses the last rename when the session was renamed more than once", async () => { + const sessionId = "renamed-twice"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Move invoices to the new schema" }), + { type: "custom-title", customTitle: "invoices", sessionId }, + { type: "custom-title", customTitle: "billing rework", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("billing rework"); + }); + + test("preserves a rename when the first prompt was /model", async () => { + const sessionId = "model-first"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "/model" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("keeps the rename when a later record carries no usable name", async () => { + const sessionId = "malformed-later-record"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + { type: "custom-title", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("ignores a rename recorded on a sidechain transcript", async () => { + const sessionId = "sidechain-rename"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "Subagent title", sessionId, isSidechain: true }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBeUndefined(); + }); + + test("leaves the agent untitled when the session was never renamed", async () => { + const sessionId = "never-renamed"; + await writeSession({ + sessionId, + records: [userRecord({ sessionId, content: "Review this project" })], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBeUndefined(); + }); + + test("does not name the imported agent after a generated ai-title", async () => { + const sessionId = "auto-titled"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Add pagination to the invoice list" }), + { type: "ai-title", aiTitle: "Invoice list pagination", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + // The Import Session list shows it, but agents are named from their first + // prompt line. + expect(imported.config.title).toBeUndefined(); + }); + + test("imports a session that has no transcript on disk", async () => { + const imported = await importSession("no-such-session"); + + expect(imported.config.title).toBeUndefined(); + }); + + test("surfaces an unreadable transcript instead of importing it as never renamed", async () => { + const sessionId = "unreadable-transcript"; + // A directory where the transcript belongs fails the read with EISDIR, the + // deterministic stand-in for a permissions or I/O fault. + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(path.join(projectDir, `${sessionId}.jsonl`), { recursive: true }); + + await expect(importSession(sessionId)).rejects.toThrow(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts index dada35945c..02a09ba44b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts @@ -91,6 +91,17 @@ function buildUsage() { }; } +/** Claude opening a turn of its own. Every main-session turn starts with one. */ +function buildTurnInit(sessionId: string) { + return { + type: "system", + subtype: "init", + session_id: sessionId, + permissionMode: "default", + model: "opus", + }; +} + function buildSuccessResult(sessionId: string) { return { type: "result", @@ -153,13 +164,7 @@ function createScriptedQuery(params: { }, } satisfies ScriptedQuery; - scriptedQuery.emit({ - type: "system", - subtype: "init", - session_id: params.sessionId, - permissionMode: "default", - model: "opus", - }); + scriptedQuery.emit(buildTurnInit(params.sessionId)); void (async () => { for await (const prompt of params.prompt) { @@ -252,6 +257,21 @@ async function waitFor( } } +/** + * Paseo interrupts Claude only while a main-session turn is in flight, which it learns from the + * init. The pump asks for its next frame only after routing the last one, so a second next() call + * proves the init was read. + */ +async function waitForInitRouted( + query: () => { next: ReturnType<typeof vi.fn> } | null | undefined, +) { + await waitFor(() => (query()?.next.mock.calls.length ?? 0) >= 2); +} + +function buildCommandLifecycle(commandUuid: string | null | undefined, state: string) { + return { type: "command_lifecycle", command_uuid: commandUuid, state }; +} + afterEach(() => { queryFactory.mockReset(); }); @@ -282,6 +302,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async () const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); + await waitForInitRouted(() => queries[0]); await session.interrupt(); await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1); @@ -322,6 +343,7 @@ async function startSteeredTurn(sessionId: string): Promise<{ throw new Error("Expected the original Claude turn to start"); } await waitFor(() => query?.prompts.length === 1); + await waitForInitRouted(() => query); const steered = await session.steerActiveTurn!("queued steer", { expectedTurnId: start.value.turnId, @@ -371,11 +393,8 @@ test("interrupt still stops the turn when Claude has already dequeued the steer" test("a steer Claude has already read is no longer discardable on interrupt", async () => { const { session, query, turn } = await startSteeredTurn("queued-steer-completed-session"); - query()?.emit({ - type: "command_lifecycle", - command_uuid: query()?.prompts[1]?.uuid, - state: "completed", - }); + query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started")); + query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "completed")); // Frames are translated in order, so the marker landing proves the lifecycle frame was read. query()?.emit({ type: "assistant", message: { content: "STEER_READ" } }); await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_READ")); @@ -388,6 +407,23 @@ test("a steer Claude has already read is no longer discardable on interrupt", as await session.close(); }); +test("a steer Claude cancelled is no longer withdrawn on interrupt", async () => { + const { session, query, turn } = await startSteeredTurn("queued-steer-cancelled-session"); + + query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started")); + // "cancelled" is terminal: API errors, interrupts and "now" pre-emption all end a message. + query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "cancelled")); + query()?.emit({ type: "assistant", message: { content: "STEER_CANCELLED" } }); + await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_CANCELLED")); + + await session.interrupt(); + await waitFor(() => query()?.interrupt.mock.calls.length === 1); + + expect(query()?.cancelAsyncMessage).not.toHaveBeenCalled(); + await collectUntilTerminal(turn); + await session.close(); +}); + function buildStoppedTaskNotification(sessionId: string) { return { type: "system", @@ -473,6 +509,7 @@ async function startInterruptedToolTurn(sessionId: string): Promise<{ const turn = streamSession(session, "run the slow tool"); await turn.next(); await waitFor(() => query?.prompts.length === 1); + await waitForInitRouted(() => query); await session.interrupt(); await collectUntilTerminal(turn); @@ -523,6 +560,7 @@ test("Claude can still wake into an autonomous turn once the interrupted request query()?.emit(buildStoppedTaskNotification(sessionId)); query()?.emit(buildAbortedResult(sessionId)); + query()?.emit(buildTurnInit(sessionId)); query()?.emit({ type: "assistant", message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, @@ -579,6 +617,7 @@ test("reuses the existing query after interrupt before starting the next prompt" const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); + await waitForInitRouted(() => queries[0]); await session.interrupt(); await collectUntilTerminal(firstTurn); @@ -652,6 +691,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const output = createAsyncQueue<Record<string, unknown>>(); const prompts: PromptRecord[] = []; let throwAbortOnNext = false; + let queryRef: Pick<ScriptedQuery, "next"> | null = null; queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { const scriptedQuery = { @@ -685,13 +725,8 @@ test("recovers when the query pump sees a single interrupt abort before the next }, } satisfies ScriptedQuery; - scriptedQuery.emit({ - type: "system", - subtype: "init", - session_id: "interrupt-abort-recovery-session", - permissionMode: "default", - model: "opus", - }); + queryRef = scriptedQuery; + scriptedQuery.emit(buildTurnInit("interrupt-abort-recovery-session")); void (async () => { for await (const promptMessage of prompt) { @@ -730,6 +765,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); + await waitForInitRouted(() => queryRef); await session.interrupt(); await collectUntilTerminal(firstTurn); @@ -768,6 +804,7 @@ test("stale abort result after replacement start does not poison the new foregro const firstTurn = streamSession(session, "first prompt"); const firstStarted = await firstTurn.next(); await waitFor(() => queryRef?.prompts.length === 1); + await waitForInitRouted(() => queryRef); await session.interrupt(); const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))]; @@ -812,7 +849,7 @@ test("stale abort result after replacement start does not poison the new foregro await session.close(); }); -test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => { +test("creates an autonomous live turn when Claude starts a turn without a foreground run", async () => { const logger = createTestLogger(); let queryRef: ScriptedQuery | null = null; @@ -848,6 +885,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo await collectUntilTerminal(streamSession(session, "seed prompt")); const subscribedEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-live-session")); queryRef?.emit({ type: "assistant", message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, @@ -916,6 +954,7 @@ test("steers an autonomous turn through its existing query without restarting it await collectUntilTerminal(streamSession(session, "seed prompt")); const autonomousEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-steer-session")); queryRef?.emit({ type: "assistant", message: { content: "AUTONOMOUS_RESPONSE" }, @@ -999,6 +1038,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a await collectUntilTerminal(streamSession(session, "seed prompt")); const subscribedEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-handoff-session")); queryRef?.emit({ type: "assistant", message: { content: "BACKGROUND_ONLY_RESPONSE" }, diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 5069c3653f..5393926520 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -327,6 +327,57 @@ test("fails an auto mode turn when Claude Code uses Vertex", async () => { } }); +test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => { + const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "auto", + }); + + try { + await session.setMode("acceptEdits"); + + expect(await session.getCurrentMode()).toBe("acceptEdits"); + expect(sdkQueryFactory).toHaveBeenCalledTimes(1); + expect(sdkQueryFactory.mock.calls[0]?.[0]?.options?.permissionMode).toBe("acceptEdits"); + } finally { + restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock); + await session.close(); + } +}); + +test("does not keep a query started in a mode that Claude Code rejected", async () => { + const launchedModes: Array<string | undefined> = []; + sdkQueryFactory.mockImplementation(({ options }: { options: { permissionMode?: string } }) => { + launchedModes.push(options.permissionMode); + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + if (launchedModes.length === 1) { + queryMock.setPermissionMode.mockRejectedValue(new Error("bypass refused")); + } + return queryMock; + }); + const session = await createSession(); + + try { + await expect(session.setMode("bypassPermissions")).rejects.toThrow("bypass refused"); + await session.listCommands(); + + expect(await session.getCurrentMode()).toBe("default"); + expect(launchedModes).toEqual(["bypassPermissions", "default"]); + } finally { + await session.close(); + } +}); + test("logs redacted query summary and never leaks sentinel secrets", async () => { const envSecret = "PASEO_ENV_SENTINEL_SECRET"; const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET"; @@ -435,6 +486,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a } | null; input: { end: () => void } | null; queryRestartNeeded: boolean; + mainTurnInFlight: boolean; interruptActiveTurn: () => Promise<void>; } = asInternals(session); const interrupt = vi.fn(async () => undefined); @@ -447,6 +499,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a }; internal.input = { end }; internal.queryRestartNeeded = false; + internal.mainTurnInFlight = true; try { await internal.interruptActiveTurn(); @@ -1037,11 +1090,11 @@ test("preserves bypass capability across query restarts triggered by thinking ch expect(capturedOptions).toHaveLength(2); expect(capturedOptions[0]).toMatchObject({ - permissionMode: "default", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, }); expect(capturedOptions[1]).toMatchObject({ - permissionMode: "acceptEdits", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, effort: "high", }); @@ -1140,6 +1193,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); internal.turnState = "idle"; + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); await internal.routeSdkMessageFromPump( { type: "stream_event", @@ -1182,6 +1236,468 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou await session.close(); }); +interface SessionStateInternals { + turnState: "idle" | "foreground" | "autonomous"; + routeSdkMessageFromPump: ( + message: Record<string, unknown>, + activeQuery: QueryMock, + ) => Promise<void>; + autonomousTurn: { id: string } | null; + activeForegroundTurnId: string | null; +} + +function sessionState(state: "idle" | "running" | "requires_action") { + return { + type: "system", + subtype: "session_state_changed", + state, + uuid: `state-${state}`, + session_id: "sess-1", + }; +} + +/** Claude opening a turn of its own. Every main-session turn starts with one. */ +function claudeTurnInit() { + return { + type: "system", + subtype: "init", + session_id: "sess-1", + permissionMode: "default", + model: "opus", + }; +} + +function successResult() { + return { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }; +} + +function commandLifecycle( + commandUuid: string, + state: "queued" | "started" | "completed" | "cancelled", +) { + return { type: "command_lifecycle", command_uuid: commandUuid, state }; +} + +function unboundTextDelta(text: string) { + return { + type: "stream_event", + parent_tool_use_id: null, + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + }; +} + +// What a background subagent emits on the main stream with no parent tool use id, so by frame +// shape alone each looks like main-session work. Verified on the wire: none arrives inside a turn +// Claude opened, and no result follows any of them. +const helperTaskStarted = { + type: "system", + subtype: "task_started", + task_id: "helper-task", + tool_use_id: "toolu_helper", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Background helper", + is_backgrounded: true, + uuid: "uuid-task-started", + session_id: "sess-1", +}; +const helperTaskProgress = { + type: "system", + subtype: "task_progress", + task_id: "helper-task", + tool_use_id: "toolu_helper", + description: "Background helper", + usage: { total_tokens: 1200, tool_uses: 3, duration_ms: 900 }, + last_tool_name: "Bash", + uuid: "uuid-task-progress", + session_id: "sess-1", +}; +// A subagent's own background Bash job completing. +const subagentOwnedTaskNotification = { + type: "system", + subtype: "task_notification", + task_id: "bg-job-of-subagent", + tool_use_id: "toolu_subagent_bash", + status: "completed", + summary: "Background command completed", + output_file: "/tmp/bg-job-of-subagent.output", + uuid: "uuid-task-notification", + session_id: "sess-1", +}; +const helperCompleted = { + type: "system", + subtype: "task_notification", + task_id: "helper-task", + tool_use_id: "toolu_helper", + status: "completed", + summary: "Background helper finished", + output_file: "/tmp/helper-task.output", + uuid: "uuid-helper-completed", + session_id: "sess-1", +}; +const rateLimitEvent = { + type: "rate_limit_event", + rate_limit_info: { status: "allowed" }, + uuid: "uuid-rate-limit", + session_id: "sess-1", +}; + +function createPendingQueryMock(): QueryMock { + return createBaseQueryMock(vi.fn(() => new Promise<never>(() => {}))); +} + +test("a main-session frame opens no turn until Claude starts one", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Main-session output only ever arrives inside a turn Claude opened with system/init, so a + // stray unbound frame is not the start of one. + await internal.routeSdkMessageFromPump(unboundTextDelta("stray"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + // The wake-up case autonomous turns exist for: a completed background subagent makes the main + // session start a turn of its own, with no prompt from the user. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + +test("a background subagent's frames never open a turn while the main session is idle", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Claude Code 2.1.280 reports "running" for as long as any background subagent is alive. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + for (const frame of [ + helperTaskStarted, + helperTaskProgress, + subagentOwnedTaskNotification, + rateLimitEvent, + helperCompleted, + ]) { + await internal.routeSdkMessageFromPump(frame, queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + + // The next user message starts its own foreground turn rather than landing on a phantom one. + const { turnId } = await session.startTurn("PING"); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await session.close(); +}); + +test("Claude waking up after a helper finishes gets an autonomous turn that its result closes", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // The helper's completion arrives outside any turn. Claude then starts one to react to it. + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(helperCompleted, queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + expect(internal.turnState).toBe("autonomous"); + + await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("a steer folded into a turn keeps one turn, and a turn Claude starts after it gets its own", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const { turnId } = await session.startTurn("hello"); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + // A message pushed mid-turn is folded in at the next tool boundary: Claude acknowledges it, but + // starts no new turn. + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "queued"), queryMock); + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "started"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + // A message that arrived while Claude wrote its final answer runs as a turn of its own. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2"); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(2); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(2); + + await session.close(); +}); + +test("session state never opens a turn", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // "running" covers background subagents too, so it says nothing about the main session. + for (const state of ["idle", "running", "requires_action", "idle"] as const) { + await internal.routeSdkMessageFromPump(sessionState(state), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + // State frames are bookkeeping, never timeline content. + expect(events).toEqual([]); + + await session.close(); +}); + +test("an idle session state closes an autonomous turn that no result closed", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // Safety net: Claude says nothing at all is running any more. + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("session state never opens or closes a foreground turn", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + const { turnId } = await session.startTurn("hello"); + + // The send's own running, then an idle that trails the previous turn and lands late. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await internal.routeSdkMessageFromPump( + { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }, + queryMock, + ); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + await session.close(); +}); + +interface InterruptInternals extends SessionStateInternals { + pendingInterruptAbort: boolean; +} + +type InterruptibleQueryMock = QueryMock & { cancelAsyncMessage: ReturnType<typeof vi.fn> }; + +function createInterruptibleQueryMock(): InterruptibleQueryMock { + return Object.assign(createPendingQueryMock(), { + cancelAsyncMessage: vi.fn(async () => true), + }); +} + +/** The uuid Paseo stamped on the first message it pushed into the SDK input. */ +async function readFirstPromptUuid(): Promise<string | null> { + const input = sdkQueryFactory.mock.calls[0]?.[0] as { prompt: AsyncIterable<unknown> }; + return await createPromptUuidReader(input.prompt)(); +} + +function abortedResult() { + return { + type: "result", + subtype: "error_during_execution", + errors: [], + usage: buildUsage(), + total_cost_usd: 0, + }; +} + +test("an interrupt before Claude starts the message withdraws it instead of interrupting", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("hello"); + // Claude has not acknowledged the message yet: no init, no command_lifecycle "started". + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(queryMock.interrupt).not.toHaveBeenCalled(); + // No result follows an interrupt that never happened, so no flag may wait for one. + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_canceled")).toHaveLength(1); + + await session.close(); +}); + +test("an interrupt after Claude took the message but before it started stops it", async () => { + const queryMock = createInterruptibleQueryMock(); + // Claude has dequeued the message for its next turn, so withdrawing it is a no-op. + queryMock.cancelAsyncMessage.mockImplementation(async () => false); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("hello"); + // No command_lifecycle "started" or init has reached Paseo yet. + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(internal.pendingInterruptAbort).toBe(true); + + await session.close(); +}); + +test("a prompt sent while a stopped prompt is being withdrawn is not withdrawn too", async () => { + const queryMock = createInterruptibleQueryMock(); + let settleWithdrawal: (cancelled: boolean) => void = () => {}; + queryMock.cancelAsyncMessage.mockImplementationOnce( + () => new Promise<boolean>((resolve) => (settleWithdrawal = resolve)), + ); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + + await session.startTurn("first"); + const firstUuid = await readFirstPromptUuid(); + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + + await session.startTurn("second"); + settleWithdrawal(false); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(firstUuid); + + await session.close(); +}); + +test("an interrupt while only background subagents run never reaches Claude", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("delegate to a helper"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + // The main session is done; its helper keeps working. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(helperTaskProgress, queryMock); + + // Without perTaskStopAffordance, an interrupt here kills every running helper. + await session.interrupt(); + + expect(queryMock.interrupt).not.toHaveBeenCalled(); + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(false); + + await session.close(); +}); + +test("an interrupt during a main turn interrupts Claude once and drops the aborted result", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("long task"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + // Claude already started the message, so there is nothing to withdraw. + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(true); + + // A replacement starts before the aborted turn reports its result. + const { turnId } = await session.startTurn("replacement"); + await internal.routeSdkMessageFromPump(abortedResult(), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(0); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(queryMock.interrupt).toHaveBeenCalledTimes(1); + + await session.close(); +}); + +test("a new turn clears an interrupt flag no result will consume", async () => { + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // An interrupt that landed just after its turn had ended: Claude emits no result for it. + internal.pendingInterruptAbort = true; + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.pendingInterruptAbort).toBe(false); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // This turn's own failure is reported, not swallowed as the interrupted turn's leftover. + await internal.routeSdkMessageFromPump( + { ...abortedResult(), errors: ["API Error: 500 upstream failure"] }, + queryMock, + ); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(1); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + test("tracks run lifecycle transitions for success, error, and interrupt", async () => { const session = await createSession(); let streamCase: "success" | "error" | "interrupt" = "success"; diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 8701ab87ec..48ddab5973 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -712,4 +712,56 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(timeline.length).toBeGreaterThan(0); expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID }); }); + + test("replays a SubagentHandback report as the subagent's final message", async () => { + const handbackId = "toolu_handback"; + const report = "## Verdict\n\n- **Coherent**"; + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: handbackId, + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: handbackId, + content: [ + { + type: "text", + text: '{"success":true,"message":"Report delivered to your caller."}', + }, + ], + }, + ], + }, + }), + ], + }); + + const items = (await replayDescriptors()).flatMap((event) => + event.event.type === "timeline" ? [event.event.item] : [], + ); + expect(items).toEqual([{ type: "assistant_message", text: report }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index efb452e9bf..84293afa6a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -587,4 +587,75 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { expect(latest.detail.log).toContain("[Read] file-6.md"); expect(latest.detail.log).toContain("[Read] file-205.md"); }); + + test("keeps a long main-thread tool call out of the subagents track", async () => { + // Claude Code sends a heartbeat every 30 s while a tool runs, with the tool's own id as + // parent_tool_use_id. Frames captured from Claude Code 2.1.291 for a 40 s MCP call. + queryFactory.mockImplementation(() => + buildQueryMock([ + { + type: "system", + subtype: "init", + session_id: "heartbeat-session", + permissionMode: "bypassPermissions", + model: "opus", + }, + { + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg-heartbeat-1", + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_slow", name: "mcp__slow__slow_check", input: {} }, + ], + }, + }, + { + type: "tool_progress", + tool_use_id: "toolu_slow-heartbeat-0", + tool_name: "mcp__slow__slow_check", + parent_tool_use_id: "toolu_slow", + elapsed_time_seconds: 30, + heartbeat: true, + }, + { + type: "user", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_slow", + content: [{ type: "text", text: "SLOW_CHECK_OK" }], + is_error: false, + }, + ], + }, + }, + { + type: "result", + subtype: "success", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + total_cost_usd: 0, + }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + + const events = await collectUntilTerminal(streamSession(session, "run the slow check")); + await session.close(); + + expect(events.filter((event) => event.type === "provider_subagent")).toEqual([]); + const toolCallNames = events.flatMap((event) => + event.type === "timeline" && event.item.type === "tool_call" ? [event.item.name] : [], + ); + expect(toolCallNames).toContain("mcp__slow__slow_check"); + expect(toolCallNames).not.toContain("Task"); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts new file mode 100644 index 0000000000..e5e8d117a4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import pino from "pino"; +import { expect, test } from "vitest"; + +import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +const ROOT_PROMPT = `Use Claude Code's native Agent tool exactly once, never Paseo tools, with this complete task: + +Do not use any tools. Reply with exactly this markdown report and nothing else: +## HANDBACK_HEADING +- **HANDBACK_BOLD** item + +Then reply exactly ROOT_DONE.`; + +function subagentItems(events: AgentStreamEvent[]): AgentTimelineItem[] { + return events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event.item] : [], + ); +} + +function expectHandbackAsReport(items: AgentTimelineItem[]): void { + expect(items.filter((item) => item.type === "tool_call")).toEqual([]); + expect( + items.flatMap((item) => (item.type === "assistant_message" ? [item.text] : [])).join(""), + ).toContain("## HANDBACK_HEADING\n- **HANDBACK_BOLD** item"); +} + +test("an auto-mode Claude subagent's handback report shows as its final message", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-subagent-handback-")); + const client = new ClaudeAgentClient({ logger: pino({ level: "warn" }) }); + const session = await client.createSession({ provider: "claude", cwd, modeId: "auto" }); + try { + const events = await collectSessionTurnEvents(session, ROOT_PROMPT); + expectHandbackAsReport(subagentItems(events)); + + const persistence = session.describePersistence(); + expect(persistence).not.toBeNull(); + await session.close(); + const restored = await client.resumeSession(persistence!, { cwd }); + try { + const replayed: AgentStreamEvent[] = []; + for await (const event of restored.streamHistory()) replayed.push(event); + expectHandbackAsReport(subagentItems(replayed)); + } finally { + await restored.close(); + } + } finally { + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 240_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts index d4fc0bcc0c..1223bbc541 100644 --- a/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts @@ -78,7 +78,7 @@ describe("subagent lifecycle across an interrupt", () => { queryFactory.mockReset(); }); - async function startInterruptibleTurn() { + async function startInterruptibleTurn(taskStarted: Record<string, unknown> = {}) { const channel = buildOpenQueryMock(); queryFactory.mockImplementation(() => channel.query); const session = await new ClaudeAgentClient({ @@ -107,6 +107,7 @@ describe("subagent lifecycle across an interrupt", () => { task_type: "local_agent", subagent_type: "general-purpose", description: "Reply with banana", + ...taskStarted, }); await vi.waitFor(() => expect(subagentEvents.length).toBeGreaterThan(0)); @@ -169,6 +170,31 @@ describe("subagent lifecycle across an interrupt", () => { await session.close(); }); + test("a child spawned in the background is not canceled with the turn that spawned it", async () => { + // Claude announces a background spawn on task_started itself and sends no task_updated patch. + const { channel, session, subagentEvents } = await startInterruptibleTurn({ + is_backgrounded: true, + }); + + await session.interrupt(); + channel.push({ + type: "system", + subtype: "task_notification", + task_id: TASK_ID, + tool_use_id: SUBAGENT_ID, + status: "completed", + }); + + await vi.waitFor(() => + expect(subagentEvents).toContainEqual( + expect.objectContaining({ id: SUBAGENT_ID, status: "completed" }), + ), + ); + expect(statuses(subagentEvents, SUBAGENT_ID)).toEqual(["running", "completed"]); + + await session.close(); + }); + test("a foreground child is canceled with the turn that spawned it", async () => { const { session, subagentEvents } = await startInterruptibleTurn(); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index d88d8105cf..d07b0572b7 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -426,6 +426,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => { try { const client = new ClaudeAgentClient({ logger, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ], resolveBinary: async () => "/test/claude/bin", resolveVersion: async () => "2.1.219", runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, @@ -438,6 +446,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { expect(models.map((m) => m.id)).toEqual([ "claude-opus-5", + "claude-mythos-5-1", "claude-fable-5-1", "claude-fable-5", "claude-fable-5[1m]", @@ -472,6 +481,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => { try { const client = new ClaudeAgentClient({ logger, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ], resolveVersion: async () => { throw new Error("unrecognized version output"); }, @@ -495,8 +512,16 @@ describe("ClaudeAgentClient.fetchCatalog", () => { try { const client = new ClaudeAgentClient({ logger, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ], resolveBinary: async () => "/test/claude/bin", - resolveVersion: async () => "2.1.284", + resolveVersion: async () => "2.1.293", runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ @@ -509,12 +534,29 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }; expect(getThinkingIds("claude-opus-5")).toContain("ultracode"); + expect(getThinkingIds("claude-mythos-5-1")).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + "ultracode", + ]); expect(getThinkingIds("claude-fable-5-1")).toContain("ultracode"); expect(getThinkingIds("claude-fable-5")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode"); expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh"); expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode"); + expect(getThinkingIds("claude-haiku-5-5")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + "ultracode", + ]); expect(getThinkingIds("claude-sonnet-5-5")).toContain("xhigh"); expect(getThinkingIds("claude-sonnet-5-5")).not.toContain("off"); expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode"); @@ -1223,6 +1265,8 @@ describe("ClaudeAgentSession features", () => { test.each([ ["supported model", "claude-opus-4-8", { type: "disabled" }, undefined], + ["Mythos 5.1", "claude-mythos-5-1", { type: "adaptive", display: "summarized" }, "high"], + ["Haiku 5.5", "claude-haiku-5-5", { type: "disabled" }, undefined], ["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"], ["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined], ["provider default", null, undefined, undefined], @@ -2091,13 +2135,15 @@ describe("ClaudeAgentSession context window usage", () => { } function createMessageDeltaEvent(outputTokens: number): Record<string, unknown> { + return createMessageDeltaUsageEvent({ output_tokens: outputTokens }); + } + + function createMessageDeltaUsageEvent(usage: Record<string, unknown>): Record<string, unknown> { return { type: "stream_event", event: { type: "message_delta", - usage: { - output_tokens: outputTokens, - }, + usage, }, session_id: "session-1", }; @@ -2759,6 +2805,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 150, }, }), @@ -2794,7 +2841,13 @@ describe("ClaudeAgentSession context window usage", () => { test("selected 1M Claude models seed active context window usage from the catalog", async () => { const session = await createSessionForTurns( - [[createInitMessage(), createMessageStartEvent(), createSuccessResult()]], + [ + [ + { ...createInitMessage(), model: "claude-sonnet-5[1m]" }, + createMessageStartEvent(), + createSuccessResult(), + ], + ], { model: "claude-sonnet-5[1m]" }, ); @@ -2834,6 +2887,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 175, }, }), @@ -2843,6 +2897,121 @@ describe("ClaudeAgentSession context window usage", () => { } }); + test("message_delta input usage replaces an empty message_start count", async () => { + // Translating gateways (LiteLLM in front of an OpenAI-compatible backend) do not know the + // prompt size when message_start is emitted, so they report input_tokens: 0 there and send + // the cumulative counts on message_delta, as the Anthropic Messages API allows. + const session = await createSessionForTurns([ + [ + createInitMessage(), + createMessageStartEvent({ + input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + createMessageDeltaUsageEvent({ input_tokens: 20_236, output_tokens: 64 }), + createSuccessResult({ + usage: { + input_tokens: 20_236, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + output_tokens: 64, + iterations: [], + }, + }), + ], + ]); + + try { + const events = await collectStreamEvents(session); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + provider: "claude", + usage: { + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 20_300, + }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ + inputTokens: 20_236, + outputTokens: 64, + contextWindowUsedTokens: 20_300, + }), + }), + ); + } finally { + await session.close(); + } + }); + + test("message_delta without cache counters keeps the message_start cache usage", async () => { + const session = await createSessionForTurns([ + [ + createInitMessage(), + createMessageStartEvent({ + input_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 1_000, + }), + createMessageDeltaUsageEvent({ input_tokens: 5, output_tokens: 64 }), + createSuccessResult(), + ], + ]); + + try { + const events = await collectStreamEvents(session); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + provider: "claude", + usage: { + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 1_069, + }, + }), + ); + } finally { + await session.close(); + } + }); + + test("message_delta before this request's message_start does not seed usage", async () => { + // A canceled request can leave its terminal message_delta queued behind the next turn. + // Nothing is known about the new request until its message_start, so that straggler + // must not produce a usage_updated event or leak into the next request's counters. + const session = await createSessionForTurns([ + [ + createInitMessage(), + createMessageDeltaUsageEvent({ input_tokens: 20_236, output_tokens: 64 }), + createMessageStartEvent({ + input_tokens: 40, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 10, + }), + createMessageDeltaEvent(7), + createSuccessResult(), + ], + ]); + + try { + const events = await collectStreamEvents(session); + + const usedTokens = events + .filter((event) => event.type === "usage_updated") + .map((event) => event.usage.contextWindowUsedTokens); + expect(usedTokens).toEqual([55, 62]); + } finally { + await session.close(); + } + }); + test("per-request stream usage is not cumulative across API calls in a turn", async () => { const session = await createSessionForTurns([ [ @@ -2867,6 +3036,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 55, }, }), @@ -2876,6 +3046,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 62, }, }), @@ -2913,6 +3084,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 704, }, }), @@ -3160,10 +3332,15 @@ describe("ClaudeAgentSession context window usage", () => { }); test("a compaction abandoned in an autonomous turn does not suppress the next marker", async () => { - // Trailing output after the foreground result opens an autonomous turn, which starts - // compacting and is then ended by the next foreground turn, never reaching a boundary. + // Claude starts a turn of its own after the foreground result, which starts compacting and is + // then ended by the next foreground turn, never reaching a boundary. const session = await createSessionForTurns([ - [createSuccessResult(), createMessageStartEvent(), createCompactingStatus()], + [ + createSuccessResult(), + createInitMessage(), + createMessageStartEvent(), + createCompactingStatus(), + ], [createCompactingStatus(), createSuccessResult()], ]); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index aa60e40462..8c5cc1a41c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -31,7 +31,7 @@ import { readTaskNotificationToolUseIdFromHistoryRecord, } from "./task-notification-tool-call.js"; import { - findClaudeModel, + getClaudeContextWindowMaxTokens, getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId, resolveConfiguredClaudeModel, @@ -44,6 +44,7 @@ import { } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; +import { readClaudeSubagentHandback } from "./subagent-handback.js"; import { ClaudeTaskState } from "./task-state.js"; import { ClaudeTaskProtocolSource, @@ -75,6 +76,7 @@ import { type ClaudeProviderOptions, } from "./options.js"; import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; +import { discoverClaudeModels } from "./model-discovery.js"; import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; import { realClaudeRewindSdk, @@ -83,7 +85,7 @@ import { type ClaudeRewindSdk, } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; -import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js"; +import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js"; import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; import { isProviderImageMarkdown, @@ -201,24 +203,26 @@ function stripClaudeAskUserQuestionUiMetadata(input: AgentMetadata): AgentMetada }; } -export function normalizeClaudeAskUserQuestionUpdatedInput( +/** + * The answers this response will actually deliver, keyed by full question text, + * or null when there is no question list and answers map to work from. + * + * Shared by the normalizer and by the check that rejects an undeliverable + * answer, so the two can never disagree about what "deliverable" means. + */ +function resolveClaudeAskUserQuestionAnswers( updatedInput: AgentMetadata | undefined, fallbackInput: AgentMetadata | undefined, -): AgentMetadata { +): Record<string, string> | null { const fallback = isMetadata(fallbackInput) ? fallbackInput : {}; const base = isMetadata(updatedInput) ? updatedInput : {}; - // Paseo's shared question UI serializes answers by question header, but Claude's - // AskUserQuestion tool expects answer keys to match the full question text. Merge - // the original request payload back in so provider callbacks that only return - // `{ answers }` still satisfy Claude's full tool input schema. - const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base }); const questions = (Array.isArray(base.questions) ? base.questions : null) ?? (Array.isArray(fallback.questions) ? fallback.questions : null); const answers = isMetadata(base.answers) ? base.answers : null; if (!questions || !answers) { - return merged; + return null; } const normalizedAnswers: Record<string, string> = {}; @@ -242,7 +246,23 @@ export function normalizeClaudeAskUserQuestionUpdatedInput( } } - if (Object.keys(normalizedAnswers).length === 0) { + return normalizedAnswers; +} + +export function normalizeClaudeAskUserQuestionUpdatedInput( + updatedInput: AgentMetadata | undefined, + fallbackInput: AgentMetadata | undefined, +): AgentMetadata { + const fallback = isMetadata(fallbackInput) ? fallbackInput : {}; + const base = isMetadata(updatedInput) ? updatedInput : {}; + // Paseo's shared question UI serializes answers by question header, but Claude's + // AskUserQuestion tool expects answer keys to match the full question text. Merge + // the original request payload back in so provider callbacks that only return + // `{ answers }` still satisfy Claude's full tool input schema. + const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base }); + const normalizedAnswers = resolveClaudeAskUserQuestionAnswers(updatedInput, fallbackInput); + + if (!normalizedAnswers || Object.keys(normalizedAnswers).length === 0) { return merged; } @@ -252,6 +272,32 @@ export function normalizeClaudeAskUserQuestionUpdatedInput( }; } +class ClaudeQuestionAnswerRequiredError extends Error { + readonly code = "question_answer_required" as const; + + constructor(readonly requestId: string) { + super( + `Permission request '${requestId}' requires updatedInput.answers keyed by a ` + + `question's full text or header, with a non-empty string value. ` + + `The request is still pending; retry with that shape.`, + ); + this.name = "ClaudeQuestionAnswerRequiredError"; + } +} + +function assertClaudeQuestionAnswerDeliverable( + request: AgentPermissionRequest, + response: AgentPermissionResponse, +): void { + if (request.kind !== "question" || response.behavior !== "allow") { + return; + } + const answers = resolveClaudeAskUserQuestionAnswers(response.updatedInput, request.input); + if (!answers || Object.keys(answers).length === 0) { + throw new ClaudeQuestionAnswerRequiredError(request.id); + } +} + function isObjectRecord(value: unknown): value is Record<string, unknown> { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -411,6 +457,7 @@ export interface ClaudeContentChunk { } interface ClaudeAgentClientOptions { + discoverModels?: typeof discoverClaudeModels; defaults?: { agents?: Record<string, AgentDefinition> }; logger: Logger; runtimeSettings?: ProviderRuntimeSettings; @@ -451,6 +498,15 @@ function errorToMessageString(error: unknown): string { return ""; } +/** + * Claude opens every main-session turn with system/init and closes it with exactly one result: + * a prompt, a wake-up after background work, a slash command, an API error. Background subagents + * never emit either on the main stream. + */ +function isClaudeTurnInit(message: SDKMessage): boolean { + return message.type === "system" && message.subtype === "init"; +} + function firstStringField( input: Record<string, unknown>, primaryKey: string, @@ -1504,6 +1560,7 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { } export class ClaudeAgentClient implements AgentClient { + private readonly discoverModels: typeof discoverClaudeModels; readonly provider = "claude" as const; readonly capabilities = CLAUDE_CAPABILITIES; @@ -1516,6 +1573,7 @@ export class ClaudeAgentClient implements AgentClient { private readonly rewindSdk: ClaudeRewindSdk; constructor(options: ClaudeAgentClientOptions) { + this.discoverModels = options.discoverModels ?? discoverClaudeModels; this.defaults = options.defaults; this.logger = options.logger.child({ module: "agent", provider: "claude" }); this.runtimeSettings = options.runtimeSettings; @@ -1598,8 +1656,35 @@ export class ClaudeAgentClient implements AgentClient { this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog"); } const env = this.buildProviderEnv(); + let discoveredModelIds = new Set<string>(); + try { + const advertisedModels = await runProviderRefreshActivity(context, "models", () => + this.discoverModels({ + resolveBinary: this.resolveBinary, + runtimeSettings: this.runtimeSettings, + env, + signal: context?.signal, + }), + ); + discoveredModelIds = new Set( + advertisedModels + .map((model) => normalizeClaudeRuntimeModelId(model.resolvedModel ?? model.value)) + .filter((id): id is string => id !== null), + ); + } catch (error) { + context?.signal.throwIfAborted(); + this.logger.warn( + { err: error }, + "Claude model discovery failed; omitting discovery-required models", + ); + } const models = await runProviderRefreshActivity(context, "settings", () => - getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion), + getClaudeModelsWithSettings( + this.logger, + claudeConfigDir(env), + claudeCodeVersion, + discoveredModelIds, + ), ); const modeCatalog = claudeModeCatalog(env); return { @@ -1652,11 +1737,21 @@ export class ClaudeAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + // Carry a `/rename` onto the imported agent so the name chosen in the CLI + // survives the import. Only an explicit rename counts: agents are named + // after their first prompt line since #1563, so adopting the generated + // `ai-title` here would put that LLM summary back. + const renamedTitle = await readClaudeRenamedSessionTitle({ + cwd: input.cwd, + sessionId: input.providerHandleId, + configDir: claudeConfigDir(this.buildProviderEnv(context.launchContext?.env)), + }); return importSessionFromPersistence({ provider: "claude", request: input, context, resumeSession: this.resumeSession.bind(this), + ...(renamedTitle ? { config: { title: renamedTitle } } : {}), }); } @@ -1788,56 +1883,77 @@ async function resolveClaudeAuth( } } -function extractContextWindowSize(modelUsage: unknown): number | undefined { +function extractContextWindowSize( + modelUsage: unknown, + modelId: string | null | undefined, +): number | undefined { const usageRecord = toObjectRecord(modelUsage); if (!usageRecord) { return undefined; } - let maxContextWindow: number | undefined; - for (const value of Object.values(usageRecord)) { - const valueRecord = toObjectRecord(value); - if (!valueRecord) { - continue; - } - const contextWindow = valueRecord.contextWindow; - if ( - typeof contextWindow !== "number" || - !Number.isFinite(contextWindow) || - contextWindow <= 0 - ) { - continue; - } - maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow); - } - - return maxContextWindow; + // modelUsage accumulates every model used in the session, including earlier turns + // and subagents. Only the active model describes this turn's context capacity. + const modelUsageEntry = modelId ? usageRecord[modelId] : undefined; + const contextWindow = toObjectRecord(modelUsageEntry)?.contextWindow; + return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + ? contextWindow + : undefined; } -function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined { - const messageUsage = toObjectRecord(toObjectRecord(event.message)?.usage); - if (!messageUsage) { +interface StreamRequestInputUsage { + inputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; +} + +function readUsageCount(usage: Record<string, unknown>, key: string): number | undefined { + const value = usage[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { return undefined; } - const usage = messageUsage; - const inputTokens = - typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens) - ? usage.input_tokens - : undefined; - const cacheCreationInputTokens = - typeof usage.cache_creation_input_tokens === "number" && - Number.isFinite(usage.cache_creation_input_tokens) - ? usage.cache_creation_input_tokens - : 0; - const cacheReadInputTokens = - typeof usage.cache_read_input_tokens === "number" && - Number.isFinite(usage.cache_read_input_tokens) - ? usage.cache_read_input_tokens - : 0; - if (typeof inputTokens !== "number" || inputTokens < 0) { + return value; +} + +function readStreamRequestInputUsage( + event: Record<string, unknown>, +): StreamRequestInputUsage | undefined { + const usage = toObjectRecord(toObjectRecord(event.message)?.usage); + if (!usage) { return undefined; } - return inputTokens + cacheCreationInputTokens + cacheReadInputTokens; + const inputTokens = readUsageCount(usage, "input_tokens"); + if (inputTokens === undefined) { + return undefined; + } + return { + inputTokens, + cacheCreationInputTokens: readUsageCount(usage, "cache_creation_input_tokens") ?? 0, + cacheReadInputTokens: readUsageCount(usage, "cache_read_input_tokens") ?? 0, + }; +} + +/** + * The input counters on `message_delta.usage` are cumulative whole-request totals and are + * optional. Mirror the Anthropic SDK's MessageStream: a counter present on the delta replaces + * the `message_start` value, a missing counter keeps it. Translating gateways report + * `input_tokens: 0` on `message_start` and only know the real count by `message_delta`. + */ +function mergeStreamDeltaInputUsage( + current: StreamRequestInputUsage, + event: Record<string, unknown>, +): StreamRequestInputUsage { + const usage = toObjectRecord(event.usage); + if (!usage) { + return current; + } + return { + inputTokens: readUsageCount(usage, "input_tokens") ?? current.inputTokens, + cacheCreationInputTokens: + readUsageCount(usage, "cache_creation_input_tokens") ?? current.cacheCreationInputTokens, + cacheReadInputTokens: + readUsageCount(usage, "cache_read_input_tokens") ?? current.cacheReadInputTokens, + }; } function readStreamRequestOutputTokens(event: Record<string, unknown>): number | undefined { @@ -1906,13 +2022,18 @@ function readClaudeParentToolUseId(message: SDKMessage): string | null { if (!("parent_tool_use_id" in message)) { return null; } + // Claude Code's liveness tick for a tool still running after 30 s names that tool as its + // parent, so a long main-thread call would otherwise read as a subagent. + if (message.type === "tool_progress" && message.heartbeat === true) { + return null; + } const parentToolUseId = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id; return typeof parentToolUseId === "string" && parentToolUseId.length > 0 ? parentToolUseId : null; } class ClaudeContextUsageState { private contextWindowMaxTokens: number | undefined; - private streamRequestInputTokens: number | undefined; + private streamRequestInputUsage: StreamRequestInputUsage | undefined; private streamRequestOutputTokens: number | undefined; private compactedContextWindowUsedTokens: number | undefined; private completedResultTurns = 0; @@ -1922,7 +2043,7 @@ class ClaudeContextUsageState { } beginTurn(): void { - this.streamRequestInputTokens = undefined; + this.streamRequestInputUsage = undefined; this.streamRequestOutputTokens = undefined; this.compactedContextWindowUsedTokens = undefined; } @@ -1931,8 +2052,8 @@ class ClaudeContextUsageState { this.contextWindowMaxTokens = contextWindowMaxTokens; } - recordModelUsage(modelUsage: unknown): number | undefined { - const contextWindowMaxTokens = extractContextWindowSize(modelUsage); + recordModelUsage(modelUsage: unknown, modelId: string | null | undefined): number | undefined { + const contextWindowMaxTokens = extractContextWindowSize(modelUsage, modelId); if (contextWindowMaxTokens !== undefined) { this.contextWindowMaxTokens = contextWindowMaxTokens; } @@ -1946,11 +2067,11 @@ class ClaudeContextUsageState { } const eventType = readTrimmedString(streamEvent.type); if (eventType === "message_start") { - const inputTokens = readStreamRequestInputTokens(streamEvent); - if (typeof inputTokens !== "number") { + const inputUsage = readStreamRequestInputUsage(streamEvent); + if (!inputUsage) { return null; } - this.streamRequestInputTokens = inputTokens; + this.streamRequestInputUsage = inputUsage; this.streamRequestOutputTokens = 0; } else if (eventType === "message_delta") { const outputTokens = readStreamRequestOutputTokens(streamEvent); @@ -1958,6 +2079,12 @@ class ClaudeContextUsageState { return null; } this.streamRequestOutputTokens = outputTokens; + if (this.streamRequestInputUsage) { + this.streamRequestInputUsage = mergeStreamDeltaInputUsage( + this.streamRequestInputUsage, + streamEvent, + ); + } } else { return null; } @@ -1969,7 +2096,10 @@ class ClaudeContextUsageState { return this.createUsageUpdatedEvent(usedTokens); } - buildResultUsage(message: SDKResultMessage, modelUsage: unknown): AgentUsage | undefined { + buildResultUsage( + message: SDKResultMessage, + modelId: string | null | undefined, + ): AgentUsage | undefined { try { if (!message.usage) { return undefined; @@ -1981,7 +2111,7 @@ class ClaudeContextUsageState { totalCostUsd: message.total_cost_usd, }; - const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage); + const modelContextWindowMaxTokens = this.recordModelUsage(message.modelUsage, modelId); if (this.contextWindowMaxTokens !== undefined) { usage.contextWindowMaxTokens = this.contextWindowMaxTokens; } else if (modelContextWindowMaxTokens !== undefined) { @@ -2004,13 +2134,15 @@ class ClaudeContextUsageState { } private streamUsedTokens(): number | undefined { - if ( - typeof this.streamRequestInputTokens !== "number" || - typeof this.streamRequestOutputTokens !== "number" - ) { + const inputUsage = this.streamRequestInputUsage; + if (!inputUsage || typeof this.streamRequestOutputTokens !== "number") { return undefined; } - const usedTokens = this.streamRequestInputTokens + this.streamRequestOutputTokens; + const usedTokens = + inputUsage.inputTokens + + inputUsage.cacheCreationInputTokens + + inputUsage.cacheReadInputTokens + + this.streamRequestOutputTokens; return usedTokens > 0 ? usedTokens : undefined; } @@ -2029,7 +2161,7 @@ class ClaudeContextUsageState { } buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent { - this.streamRequestInputTokens = undefined; + this.streamRequestInputUsage = undefined; this.streamRequestOutputTokens = undefined; this.compactedContextWindowUsedTokens = postTokens; const usage: AgentUsage = {}; @@ -2069,11 +2201,12 @@ class ClaudeAgentSession implements AgentSession { private activeForegroundQuery: Query | null = null; private activeForegroundInput: AsyncMessageInput<SDKUserMessage> | null = null; /** - * Steers pushed into the live SDK input that Claude may not have read yet. Interrupting the turn - * has to discard them, or the SDK dequeues one and resumes the turn we just stopped. SDK user - * UUIDs are provider-private; never let them escape the adapter boundary. + * Messages pushed into the live SDK input (steers, and a foreground turn's own prompt) whose + * command_lifecycle has not reached "started". Interrupting has to withdraw them, or the SDK + * dequeues one and resumes the turn we just stopped. SDK user UUIDs are provider-private; never + * let them escape the adapter boundary. */ - private readonly queuedSteerUuids = new Set<string>(); + private readonly unstartedMessageUuids = new Set<string>(); /** Human steers whose text has not reached Claude yet and therefore supersede blocking cards. */ private readonly permissionClearingSteerUuids = new Set<string>(); private claudeSessionId: string | null; @@ -2084,6 +2217,7 @@ class ClaudeAgentSession implements AgentSession { private toolUseCache = new Map<string, ToolUseCacheEntry>(); private toolUseIndexToId = new Map<number, string>(); private toolUseInputBuffers = new Map<string, string>(); + private subagentHandbackIds = new Set<string>(); private pendingPermissions = new Map<string, PendingPermission>(); private activeForegroundTurnId: string | null = null; private autonomousTurn: AutonomousTurnState | null = null; @@ -2124,6 +2258,12 @@ class ClaudeAgentSession implements AgentSession { private queryPumpPromise: Promise<void> | null = null; private queryRestartNeeded = false; private pendingInterruptAbort = false; + /** + * Whether Claude's main session is inside one of its own turns: set by system/init and by a + * pushed message's command_lifecycle "started" (/compact works for seconds before its init), + * cleared by the turn's result or an idle session state. See observeMainTurnBracket. + */ + private mainTurnInFlight = false; private foregroundHasVisibleActivity = false; private activeTurnHasAssistantText = false; private readonly contextUsage: ClaudeContextUsageState; @@ -2149,7 +2289,7 @@ class ClaudeAgentSession implements AgentSession { this.resolveBinary = options.resolveBinary; this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk; this.contextUsage = new ClaudeContextUsageState( - findClaudeModel(this.config.model)?.contextWindowMaxTokens, + getClaudeContextWindowMaxTokens(this.config.model), ); const handle = options.handle; @@ -2270,6 +2410,7 @@ class ClaudeAgentSession implements AgentSession { this.contextUsage.beginTurn(); this.transitionTurnState("foreground", "foreground turn started"); this.clearRecentStderr(); + if (sdkUserMessageId) this.unstartedMessageUuids.add(sdkUserMessageId); let cancelIssued = false; const requestCancel = () => { @@ -2280,13 +2421,18 @@ class ClaudeAgentSession implements AgentSession { if (this.cancelCurrentTurn === requestCancel) { this.cancelCurrentTurn = null; } + // Whether Claude has this message in hand. A late idle from the previous turn can clear + // mainTurnInFlight after Claude started it, so the message's own lifecycle counts too. + const claudeStartedTurn = + this.mainTurnInFlight || + (sdkUserMessageId !== null && !this.unstartedMessageUuids.has(sdkUserMessageId)); this.rejectAllPendingPermissions(new Error("Permission request canceled")); this.finishForegroundTurn({ type: "turn_canceled", provider: "claude", reason: "Interrupted", }); - void this.interruptActiveTurn().catch((error) => { + void this.interruptActiveTurn(claudeStartedTurn).catch((error) => { this.logger.warn({ err: error }, "Failed to interrupt during cancel"); }); }; @@ -2296,6 +2442,11 @@ class ClaudeAgentSession implements AgentSession { try { await this.ensureQuery(); + if (cancelIssued) { + // Stopped while Claude was still starting up: withdrawn by never sending it. + if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId); + return { turnId }; + } if (!this.input) { throw new Error("Claude session input stream not initialized"); } @@ -2309,6 +2460,7 @@ class ClaudeAgentSession implements AgentSession { } }, 0); } catch (error) { + if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId); this.finishForegroundTurn( this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"), ); @@ -2357,7 +2509,7 @@ class ClaudeAgentSession implements AgentSession { clearPendingPermissions: boolean, ): void { const uuid = message.uuid; - if (uuid) this.queuedSteerUuids.add(uuid); + if (uuid) this.unstartedMessageUuids.add(uuid); if (uuid && clearPendingPermissions) { this.permissionClearingSteerUuids.add(uuid); } @@ -2368,7 +2520,7 @@ class ClaudeAgentSession implements AgentSession { } } catch (error) { if (uuid) { - this.queuedSteerUuids.delete(uuid); + this.unstartedMessageUuids.delete(uuid); this.permissionClearingSteerUuids.delete(uuid); } throw error; @@ -2439,8 +2591,15 @@ class ClaudeAgentSession implements AgentSession { const normalized = isPermissionMode(modeId) ? modeId : "default"; assertClaudeModeCanRun(normalized, this.harnessEnvironment); const previousMode = this.currentMode; - const activeQuery = await this.ensureQuery(); - await activeQuery.setPermissionMode(normalized); + const launchesQuery = !this.query || this.queryRestartNeeded; + const activeQuery = await this.ensureQuery(normalized); + try { + await activeQuery.setPermissionMode(normalized); + } catch (error) { + // The query was launched in the rejected mode; relaunch in the current mode next time. + if (launchesQuery) this.queryRestartNeeded = true; + throw error; + } if (normalized === "plan") { if (previousMode !== "plan") { this.planResumeMode = previousMode; @@ -2462,7 +2621,7 @@ class ClaudeAgentSession implements AgentSession { await this.applyFastModeFeature(false, activeQuery); } this.contextUsage.setInitialContextWindowMaxTokens( - findClaudeModel(this.config.model)?.contextWindowMaxTokens, + getClaudeContextWindowMaxTokens(this.config.model), ); this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel; this.lastRuntimeModel = null; @@ -2619,6 +2778,12 @@ class ClaudeAgentSession implements AgentSession { if (!pending) { throw new Error(`No pending permission request with id '${requestId}'`); } + + // Before the delete below, so a rejected answer leaves the request pending + // and answerable rather than consuming it for nothing. AgentManager only + // drops its own copy once this resolves, so both maps survive the throw. + assertClaudeQuestionAnswerDeliverable(pending.request, response); + this.pendingPermissions.delete(requestId); pending.cleanup?.(); @@ -2788,9 +2953,11 @@ class ClaudeAgentSession implements AgentSession { this.startFreshConversationSession(); return; } + const currentSessionId = this.claudeSessionId; await revertClaudeConversation({ sdk: this.rewindSdk, - sessionId: this.claudeSessionId, + sessionId: currentSessionId, + transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null, messageId: target.messageId, resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId), setSessionId: (sessionId) => { @@ -3126,7 +3293,7 @@ class ClaudeAgentSession implements AgentSession { return { kind: "fresh-session" }; } - private async ensureQuery(): Promise<Query> { + private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> { if (this.query && !this.queryRestartNeeded) { return this.query; } @@ -3170,9 +3337,11 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; const input = createAsyncMessageInput<SDKUserMessage>(); - const options = await this.buildOptions(); + const options = await this.buildOptions(launchMode); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; + // A fresh Claude process has no turn of its own in flight. + this.mainTurnInFlight = false; this.query = claudeQuery( { prompt: input.iterable, options }, { @@ -3292,11 +3461,16 @@ class ClaudeAgentSession implements AgentSession { return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, - overlays: [this.launchEnv], + overlays: [ + this.launchEnv, + // Makes the CLI announce session_state_changed, whose "idle" is the safety net that closes + // an autonomous turn (see consumeSessionStateChange). + { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" }, + ], }); } - private async buildOptions(): Promise<ClaudeOptions> { + private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> { const { thinking, effort, ultracode } = this.resolveThinkingConfig(); const appendedSystemPrompt = this.buildAppendedSystemPrompt(); const providerOptions = applyClaudeToolPolicy( @@ -3305,7 +3479,7 @@ class ClaudeAgentSession implements AgentSession { ); const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode }); const sdkEnv = this.harnessEnvironment; - assertClaudeModeCanRun(this.currentMode, sdkEnv); + assertClaudeModeCanRun(permissionMode, sdkEnv); const claudeBinary = await this.resolveBinary(); this.logger.debug( @@ -3328,7 +3502,7 @@ class ClaudeAgentSession implements AgentSession { const base: ClaudeOptions = { cwd: this.config.cwd, includePartialMessages: true, - permissionMode: this.currentMode, + permissionMode, // Dynamic mode switching can recreate the underlying Claude query. Keep the // bypass launch capability available so later setPermissionMode("bypassPermissions") // calls do not fail after a model/thinking/rewind-driven restart. @@ -3359,6 +3533,10 @@ class ClaudeAgentSession implements AgentSession { ...settingsOptions, // Provider subagent panes render the child's nested transcript. forwardSubagentText: true, + // Stop and replace abort only the main turn, like Esc in Claude Code, and background + // subagents keep running. Without this declaration an interrupt kills every one of them. + // Claude stops a helper itself with its TaskStop tool. + perTaskStopAffordance: true, hooks: this.buildSubagentEffortHooks(), ...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }), env: sdkEnv, @@ -3889,35 +4067,67 @@ class ClaudeAgentSession implements AgentSession { return false; } - private isAssistantishMessage(message: SDKMessage): boolean { - return ( - message.type === "assistant" || - message.type === "stream_event" || - message.type === "tool_progress" || - (message.type === "system" && message.subtype === "task_notification") - ); + /** + * Claude's account of whether anything in the session is working, which is not the same as the + * main session working: Claude Code 2.1.280 stays "running" for as long as any background + * subagent is alive. So "running" never opens a turn. + * + * "idle" means nothing at all is running, so it closes an autonomous turn whatever opened it. + * It never closes a foreground turn: an idle can trail the previous turn and land just after a + * new send, and it must not end that send. + * + * Returns true when the frame was a state change, which is bookkeeping and never reaches the + * timeline. + */ + private consumeSessionStateChange(message: SDKMessage): boolean { + if (message.type !== "system" || message.subtype !== "session_state_changed") { + return false; + } + if (message.state === "idle") { + this.mainTurnInFlight = false; + this.completeAutonomousTurn(); + } + return true; + } + + /** Tracks mainTurnInFlight from Claude's own turn brackets (isClaudeTurnInit). */ + private observeMainTurnBracket(message: SDKMessage): void { + if (message.type === "result") { + this.mainTurnInFlight = false; + return; + } + if (isClaudeTurnInit(message)) { + this.mainTurnInFlight = true; + // A new turn has begun, so the interrupted one is over. An interrupt that landed after its + // turn had already ended gets no result, and the leftover flag would swallow this turn's. + this.pendingInterruptAbort = false; + return; + } + if (readClaudeCommandLifecycle(message)?.state === "started") { + this.mainTurnInFlight = true; + } } /** - * Claude keeps talking about the request it was told to kill — the notification for the tool it - * just stopped, trailing assistant output. That is not new work, so it must not open a turn: the - * only message that would close that turn is the result shouldSuppressStaleResult drops, leaving - * the agent stuck reporting "running" forever. + * Only the main session's own turn start opens an autonomous turn. Everything a background + * subagent emits (its task frames, its own background jobs' notifications, rate limit events) + * arrives outside one, and no result would ever close a turn it opened. */ - private shouldStartAutonomousTurn(message: SDKMessage): boolean { - if (this.activeForegroundTurnId || this.pendingInterruptAbort) { - return false; - } - return this.isAssistantishMessage(message); + private opensAutonomousTurn(message: SDKMessage): boolean { + return isClaudeTurnInit(message) && !this.activeForegroundTurnId; } private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> { + if (this.consumeSessionStateChange(message)) { + return; + } + this.observeMainTurnBracket(message); if (this.shouldSuppressStaleResult(message)) { return; } const isForeground = Boolean(this.activeForegroundTurnId); - if (this.shouldStartAutonomousTurn(message)) { + if (this.opensAutonomousTurn(message)) { this.startAutonomousTurn(); } if (!isForeground && !this.autonomousTurn && message.type === "result") { @@ -4059,7 +4269,13 @@ class ClaudeAgentSession implements AgentSession { return true; } - private async interruptActiveTurn(): Promise<void> { + /** + * Interrupts Claude only when a turn of its main session is running. With nothing running there + * is nothing to stop but our own queued messages, so those are withdrawn instead: an interrupt + * would reach only background helpers (and kill them on a CLI that ignores + * perTaskStopAffordance), and no result would follow to consume pendingInterruptAbort. + */ + private async interruptActiveTurn(claudeStartedTurn = this.mainTurnInFlight): Promise<void> { const queryToInterrupt = this.query; if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") { this.logger.trace( @@ -4073,8 +4289,16 @@ class ClaudeAgentSession implements AgentSession { ); return; } - this.pendingInterruptAbort = true; - await this.discardQueuedSteers(queryToInterrupt); + if (claudeStartedTurn) { + this.pendingInterruptAbort = true; + await this.withdrawUnstartedMessages(queryToInterrupt); + } else { + // Claude can dequeue a message for its next turn before announcing it started; withdrawing + // it is then a no-op, and only an interrupt stops it. Withdraw once: a prompt sent while + // this withdrawal was in flight belongs to the next turn. + if (await this.withdrawUnstartedMessages(queryToInterrupt)) return; + this.pendingInterruptAbort = true; + } try { await this.awaitWithTimeout( queryToInterrupt.interrupt(), @@ -4086,14 +4310,15 @@ class ClaudeAgentSession implements AgentSession { } /** - * Interrupt means interrupt: a steer Claude never read dies with the turn instead of resuming it. - * A steer already dequeued cannot be recalled, and does not need to be — the interrupt kills it. + * Interrupt means interrupt: a message Claude never started dies with the turn instead of + * resuming it. One already dequeued cannot be recalled, and does not need to be, since the + * interrupt kills it. Returns whether Claude confirmed every message withdrawn. */ - private async discardQueuedSteers(query: Query): Promise<void> { - const uuids = [...this.queuedSteerUuids]; - this.queuedSteerUuids.clear(); + private async withdrawUnstartedMessages(query: Query): Promise<boolean> { + const uuids = [...this.unstartedMessageUuids]; + this.unstartedMessageUuids.clear(); this.permissionClearingSteerUuids.clear(); - if (uuids.length === 0) return; + if (uuids.length === 0) return true; // The SDK runtime supports this, but its public Query type has not caught up. Keep the // compatibility escape hatch inside the Claude adapter. const cancelAsyncMessage = ( @@ -4101,14 +4326,17 @@ class ClaudeAgentSession implements AgentSession { cancelAsyncMessage?: (uuid: string) => Promise<boolean>; } ).cancelAsyncMessage; - if (!cancelAsyncMessage) return; + if (!cancelAsyncMessage) return false; + let withdrewAll = true; for (const uuid of uuids) { try { - await cancelAsyncMessage.call(query, uuid); + if (!(await cancelAsyncMessage.call(query, uuid))) withdrewAll = false; } catch (error) { - this.logger.warn({ err: error }, "Failed to discard a queued Claude steer"); + withdrewAll = false; + this.logger.warn({ err: error }, "Failed to withdraw a queued Claude message"); } } + return withdrewAll; } private translateMessageToEvents( @@ -4157,7 +4385,7 @@ class ClaudeAgentSession implements AgentSession { } } - this.forgetReadSteer(message); + this.forgetStartedMessage(message); switch (message.type) { case "system": @@ -4191,11 +4419,14 @@ class ClaudeAgentSession implements AgentSession { return events; } - /** Once Claude has read a steer there is nothing left to discard on interrupt. */ - private forgetReadSteer(message: unknown): void { + /** + * Once Claude has started a message, or cancelled it, there is nothing left to withdraw on + * interrupt. + */ + private forgetStartedMessage(message: unknown): void { const lifecycle = readClaudeCommandLifecycle(message); if (!lifecycle || lifecycle.state === "queued") return; - this.queuedSteerUuids.delete(lifecycle.commandUuid); + this.unstartedMessageUuids.delete(lifecycle.commandUuid); this.permissionClearingSteerUuids.delete(lifecycle.commandUuid); } @@ -4520,7 +4751,7 @@ class ClaudeAgentSession implements AgentSession { message: Extract<SDKMessage, { type: "result" }>, events: AgentStreamEvent[], ): void { - const usage = this.convertUsage(message, message.modelUsage); + const usage = this.convertUsage(message); if (message.subtype === "success") { events.push(...this.sidechainTracker.finishAll("completed")); // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") @@ -4647,6 +4878,12 @@ class ClaudeAgentSession implements AgentSession { this.observePermissionMode(message.permissionMode); this.persistence = null; if (message.model) { + const runtimeContextWindowMaxTokens = getClaudeContextWindowMaxTokens(message.model); + // Seed usage before the first result, including settings-resolved aliases. + // A repeated init for the same model must retain the SDK's reported limit. + if (message.model !== this.lastRuntimeModel && runtimeContextWindowMaxTokens !== undefined) { + this.contextUsage.setInitialContextWindowMaxTokens(runtimeContextWindowMaxTokens); + } const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model); this.logger.debug( { runtimeModel: message.model, normalizedRuntimeModel }, @@ -4696,8 +4933,8 @@ class ClaudeAgentSession implements AgentSession { return null; } - private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined { - return this.contextUsage.buildResultUsage(message, modelUsage); + private convertUsage(message: SDKResultMessage): AgentUsage | undefined { + return this.contextUsage.buildResultUsage(message, this.lastRuntimeModel ?? this.config.model); } private handlePermissionRequest: CanUseTool = async ( @@ -5115,26 +5352,11 @@ class ClaudeAgentSession implements AgentSession { private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; - const configDir = claudeConfigDir(this.harnessEnvironment); - const candidates = [cwd]; - try { - const realCwd = fs.realpathSync(cwd); - if (realCwd !== cwd) { - candidates.push(realCwd); - } - } catch { - // Fall back to the configured cwd when the path has already disappeared. - } - for (const candidate of candidates) { - const historyPath = path.join( - claudeProjectDirSync(candidate, { configDir }), - `${sessionId}.jsonl`, - ); - if (fs.existsSync(historyPath)) { - return historyPath; - } - } - return path.join(claudeProjectDirSync(cwd, { configDir }), `${sessionId}.jsonl`); + return claudeTranscriptPathSync({ + cwd, + sessionId, + configDir: claudeConfigDir(this.harnessEnvironment), + }); } private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] { @@ -5184,7 +5406,7 @@ class ClaudeAgentSession implements AgentSession { // User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message. const userTextParts: string[] = []; for (const block of content) { - if (!isClaudeContentChunk(block)) { + if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) { continue; } this.mapBlockToTimeline(block, { @@ -5272,6 +5494,24 @@ class ClaudeAgentSession implements AgentSession { } } + // A subagent's handback call is its final message, and the call's result is only an + // acknowledgement, so neither becomes a tool call. + private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean { + if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) { + this.subagentHandbackIds.delete(block.tool_use_id); + return true; + } + const handback = readClaudeSubagentHandback(block); + if (!handback) { + return false; + } + this.subagentHandbackIds.add(handback.callId); + if (handback.report) { + items.push({ type: "assistant_message", text: handback.report }); + } + return true; + } + private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { const entry = this.upsertToolUseEntry(block); if (!entry) { @@ -6423,6 +6663,66 @@ function normalizeClaudeSessionTitle(title: string | null): string | null { return normalized ? normalized : null; } +const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title"; + +/** + * The `custom-title` record is re-emitted every time the session is saved, so + * the current name is the last usable one in the file. Searching backwards finds it without + * walking the transcript, which matters because sessions reach tens of + * megabytes. + */ +function readLastClaudeCustomTitle(content: string): string | null { + const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`; + let at = content.lastIndexOf(needle); + while (at >= 0) { + const lineStart = content.lastIndexOf("\n", at) + 1; + const lineEnd = content.indexOf("\n", at); + const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd); + let parsed: unknown; + try { + parsed = JSON.parse(line.trim()); + } catch { + parsed = undefined; + } + const entry = toObjectRecord(parsed); + if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) { + const title = normalizeClaudeSessionTitle( + typeof entry.customTitle === "string" ? entry.customTitle : null, + ); + if (title) { + return title; + } + } + // The needle also matches a prompt that quotes it, and a record can carry no + // usable name, so keep looking backwards for one that does. + at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1); + } + return null; +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +async function readClaudeRenamedSessionTitle(input: { + cwd: string; + sessionId: string; + configDir: string; +}): Promise<string | null> { + let content: string; + try { + content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8"); + } catch (error) { + // A session with no transcript on disk has no rename to carry over. Every + // other read failure is a real fault and stays explicit. + if (isErrnoException(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + return readLastClaudeCustomTitle(content); +} + function normalizeImportablePromptPreview(text: string): string | null { const normalized = text.trim().replace(/\s+/g, " "); if (!normalized) return null; @@ -6492,7 +6792,8 @@ function extractClaudeUserText(messageRaw: unknown): string | null { interface ClaudeCommandLifecycle { commandUuid: string; - state: "queued" | "started" | "completed"; + /** "cancelled" is terminal too: API errors, interrupts and "now" pre-emption end a message. */ + state: "queued" | "started" | "completed" | "cancelled"; } /** Runtime-only Claude frames are validated here because the SDK's public union omits them. */ @@ -6501,7 +6802,7 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle | if (record?.type !== "command_lifecycle") return null; if ( typeof record.command_uuid !== "string" || - !["queued", "started", "completed"].includes(String(record.state)) + !["queued", "started", "completed", "cancelled"].includes(String(record.state)) ) { return null; } diff --git a/packages/server/src/server/agent/providers/claude/model-discovery.ts b/packages/server/src/server/agent/providers/claude/model-discovery.ts new file mode 100644 index 0000000000..d74898472b --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/model-discovery.ts @@ -0,0 +1,89 @@ +import type { ChildProcess } from "node:child_process"; +import os from "node:os"; +import type { ModelInfo, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { raceProviderRefreshAbort } from "../../provider-refresh-deadline.js"; +import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import { claudeQuery, type ClaudeQueryInput, type ClaudeQueryContext } from "./query.js"; + +interface ClaudeModelDiscoveryOptions { + resolveBinary: () => Promise<string>; + runtimeSettings?: ProviderRuntimeSettings; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; +} + +type OpenDiscoveryQuery = ( + input: ClaudeQueryInput, + context: ClaudeQueryContext, +) => Pick<Query, "supportedModels" | "close">; + +// The curated catalog survives a failed or slow probe. This process never receives +// a prompt and must not persist a session or launch user MCP servers/hooks. +export async function discoverClaudeModels( + options: ClaudeModelDiscoveryOptions, + openQuery: OpenDiscoveryQuery = claudeQuery, +): Promise<ModelInfo[]> { + const controller = new AbortController(); + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal; + const timer = setTimeout( + () => controller.abort(new Error("Claude model discovery timed out")), + 5_000, + ); + let endInput!: () => void; + const ended = new Promise<void>((resolve) => { + endInput = resolve; + }); + const input: AsyncIterable<SDKUserMessage> = { + [Symbol.asyncIterator]() { + return { + next: async () => { + await ended; + return { done: true, value: undefined }; + }, + }; + }, + }; + let query: ReturnType<OpenDiscoveryQuery> | undefined; + let child: ChildProcess | undefined; + try { + signal.throwIfAborted(); + const binary = await raceProviderRefreshAbort(signal, options.resolveBinary()); + query = openQuery( + { + prompt: input, + options: { + pathToClaudeCodeExecutable: binary, + cwd: os.homedir(), + env: options.env, + persistSession: false, + tools: [], + mcpServers: {}, + strictMcpConfig: true, + settingSources: ["user"], + settings: { disableAllHooks: true }, + abortController: controller, + }, + }, + { + runtimeSettings: options.runtimeSettings, + onChildProcess: (process) => { + child = process; + }, + }, + ); + return await raceProviderRefreshAbort(signal, query.supportedModels()); + } finally { + clearTimeout(timer); + endInput(); + try { + query?.close(); + } finally { + if (child) { + await terminateWithTreeKill(child, { gracefulTimeoutMs: 200, forceTimeoutMs: 200 }); + } + } + } +} diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index e2e64f28fd..45a5441109 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -9,6 +9,7 @@ interface ClaudeModelManifestEntry { description: string; defaultPriority?: number; minimumClaudeCodeVersion?: string; + requiresDiscovery?: boolean; contextWindowMaxTokens?: number; effortLevels?: readonly ClaudeEffortLevel[]; defaultThinkingOptionId?: ClaudeEffortLevel; @@ -57,6 +58,14 @@ export const CLAUDE_MODEL_MANIFEST = [ supportsThinkingDisabled: true, supportsFastMode: true, }, + { + id: "claude-mythos-5-1", + label: "Mythos 5.1", + requiresDiscovery: true, + description: "Mythos 5.1 · Requires verified access", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + }, { id: "claude-fable-5-1", label: "Fable 5.1", @@ -170,10 +179,20 @@ export const CLAUDE_MODEL_MANIFEST = [ effortLevels: CLAUDE_EFFORT_LEVELS.standard, supportsThinkingDisabled: true, }, + { + id: "claude-haiku-5-5", + label: "Haiku 5.5", + description: "Haiku 5.5 · Fastest for quick answers", + minimumClaudeCodeVersion: "2.1.293", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsThinkingDisabled: true, + }, { id: "claude-haiku-4-5", label: "Haiku 4.5", - description: "Haiku 4.5 · Fastest for quick answers", + description: "Haiku 4.5 · Previous release", contextWindowMaxTokens: 200_000, }, ] as const satisfies readonly ClaudeModelManifestEntry[]; @@ -207,9 +226,16 @@ function buildThinkingOptions( return options; } -export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelDefinition[] { +export function getClaudeManifestModels( + claudeCodeVersion?: string, + discoveredModelIds?: ReadonlySet<string>, +): AgentModelDefinition[] { const availableModels: readonly ClaudeModelManifestEntry[] = CLAUDE_MODEL_MANIFEST.filter( - (model) => isModelAvailableInClaudeCode(model, claudeCodeVersion), + (model) => + isModelAvailableInClaudeCode(model, claudeCodeVersion) && + (discoveredModelIds === undefined || + !("requiresDiscovery" in model && model.requiresDiscovery) || + discoveredModelIds.has(model.id)), ); const defaultModel = availableModels.reduce<ClaudeModelManifestEntry | undefined>( (selected, candidate) => @@ -349,7 +375,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const singleSegmentMatch = trimmed.match( - /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, + /^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (singleSegmentMatch) { return normalizeSingleSegmentClaudeModelId( @@ -360,7 +386,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const runtimeMatch = trimmed.match( - /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, + /^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (!runtimeMatch) { return null; @@ -393,7 +419,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): } const runtimeMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, + /claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, ); if (runtimeMatch) { const normalizedModelId = normalizeMajorMinorClaudeModelId( @@ -408,7 +434,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): } const singleSegmentMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, + /claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, ); if (!singleSegmentMatch) { return null; diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 0e187734b8..16d13b9d68 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -13,7 +13,29 @@ import { parseClaudeCodeVersion, resolveClaudeDisabledThinkingForModel, } from "./model-manifest.js"; -import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; +import { discoverClaudeModels } from "./model-discovery.js"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; +import { + findClaudeModel, + getClaudeContextWindowMaxTokens, + getClaudeModels, + normalizeClaudeRuntimeModelId, +} from "./models.js"; + +describe("getClaudeContextWindowMaxTokens", () => { + it.each([ + ["glm-5.2[1m]", 1_000_000], + [" glm-5.2[1M] ", 1_000_000], + ["claude-sonnet-4-6", 200_000], + ["claude-sonnet-4-6[1m]", 1_000_000], + ["glm-5.2", undefined], + ["glm-5.2[1m]-other", undefined], + [undefined, undefined], + [null, undefined], + ])("resolves %s to %s without guessing unknown models", (model, expected) => { + expect(getClaudeContextWindowMaxTokens(model)).toBe(expected); + }); +}); const createdClaudeConfigDirs: string[] = []; @@ -39,10 +61,18 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s return configDir; } -function createCatalogClient(claudeCodeVersion = "2.1.284"): ClaudeAgentClient { +function createCatalogClient(claudeCodeVersion = "2.1.293"): ClaudeAgentClient { return new ClaudeAgentClient({ logger: createTestLogger(), resolveVersion: async () => claudeCodeVersion, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ], }); } @@ -52,6 +82,7 @@ describe("getClaudeModels", () => { expect(models.map((m) => m.id)).toEqual([ "claude-opus-5-5", "claude-opus-5", + "claude-mythos-5-1", "claude-fable-5-1", "claude-fable-5", "claude-fable-5[1m]", @@ -66,6 +97,7 @@ describe("getClaudeModels", () => { "claude-opus-4-6", "claude-sonnet-4-6[1m]", "claude-sonnet-4-6", + "claude-haiku-5-5", "claude-haiku-4-5", ]); }); @@ -86,6 +118,7 @@ describe("getClaudeModels", () => { new Map([ ["claude-opus-5-5", 1_000_000], ["claude-opus-5", 1_000_000], + ["claude-mythos-5-1", 1_000_000], ["claude-fable-5-1", 1_000_000], ["claude-fable-5", 1_000_000], ["claude-fable-5[1m]", 1_000_000], @@ -100,6 +133,7 @@ describe("getClaudeModels", () => { ["claude-opus-4-6", 200_000], ["claude-sonnet-4-6[1m]", 1_000_000], ["claude-sonnet-4-6", 200_000], + ["claude-haiku-5-5", 1_000_000], ["claude-haiku-4-5", 200_000], ]), ); @@ -123,6 +157,12 @@ describe("getClaudeModels", () => { expect(getClaudeModels("2.1.284").map((model) => model.id)).toContain("claude-sonnet-5-5"); }); + it("gates Haiku 5.5 on Claude Code 2.1.293 without changing the default", () => { + expect(getClaudeModels("2.1.292").map((model) => model.id)).not.toContain("claude-haiku-5-5"); + expect(getClaudeModels("2.1.293").map((model) => model.id)).toContain("claude-haiku-5-5"); + expect(getClaudeModels("2.1.293").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); + }); + it("derives thinking options from model effort capabilities", () => { const models = new Map(getClaudeModels().map((model) => [model.id, model])); @@ -192,6 +232,8 @@ describe("getClaudeModels", () => { ["claude-sonnet-5-20260101", true, "high"], ["claude-fable-5", false, "high"], ["claude-fable-5-1", false, "high"], + ["claude-haiku-5-5", true, "medium"], + ["claude-haiku-5-5-20261007", true, "medium"], ["claude-haiku-4-5", false, undefined], ["openrouter/anthropic/claude-opus-4-8", false, undefined], [null, false, undefined], @@ -211,6 +253,118 @@ describe("getClaudeModels", () => { }); describe("ClaudeAgentClient.fetchCatalog", () => { + it.each([ + { value: "mythos", resolvedModel: "claude-mythos-5-1", expected: true }, + { value: "claude-mythos-5-1", expected: true }, + { value: "claude-mythos-5-1[1m]", expected: true }, + { value: "global.anthropic.claude-mythos-5-1", expected: true }, + { value: "mythos", expected: false }, + { value: "mythos", resolvedModel: "claude-mythos-5-2", expected: false }, + { value: "claude-mythos-5-1", resolvedModel: "claude-fable-5-1", expected: false }, + ])( + "uses advertised identity $value / $resolvedModel to gate Mythos", + async ({ value, resolvedModel, expected }) => { + const configDir = await createClaudeConfigDir({}); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => [ + { value, resolvedModel, displayName: "Untrusted label", description: "" }, + ], + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual( + expected ? findClaudeModel("claude-mythos-5-1") : undefined, + ); + }, + ); + + it("preserves the curated catalog and explicitly configured models after discovery failure", async () => { + const configDir = await createClaudeConfigDir({ model: "claude-mythos-5-1" }); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => { + throw new Error("discovery unavailable"); + }, + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.filter((model) => model.id !== "claude-mythos-5-1")).toEqual( + getClaudeModels("2.1.293").filter((model) => model.id !== "claude-mythos-5-1"), + ); + expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual({ + ...findClaudeModel("claude-mythos-5-1"), + label: "claude-mythos-5-1", + description: "From Claude settings.json model", + }); + }); + + it("omits unconfirmed Mythos after a failed probe and retries on refresh", async () => { + const configDir = await createClaudeConfigDir({}); + let fail = true; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => { + if (fail) throw new Error("discovery unavailable"); + return [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ]; + }, + }); + const options = { scope: "workspace" as const, cwd: configDir, force: true }; + expect( + (await client.fetchCatalog(options)).models.some((model) => model.id === "claude-mythos-5-1"), + ).toBe(false); + fail = false; + expect( + (await client.fetchCatalog(options)).models.find((model) => model.id === "claude-mythos-5-1"), + ).toEqual(findClaudeModel("claude-mythos-5-1")); + }); + + it("keeps the curated catalog when discovery does not advertise Mythos", async () => { + const configDir = await createClaudeConfigDir({}); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => [ + { + value: "opus", + resolvedModel: "claude-opus-5-5", + displayName: "Opus", + description: "Latest", + }, + ], + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.map((model) => model.id)).toEqual( + getClaudeModels("2.1.293") + .filter((model) => model.id !== "claude-mythos-5-1") + .map((model) => model.id), + ); + }); + it("appends concrete models from Claude settings.json", async () => { const configDir = await createClaudeConfigDir({ model: "us.anthropic.claude-opus-4-7[1m]", @@ -288,6 +442,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { const client = new ClaudeAgentClient({ logger: createTestLogger(), resolveVersion: async () => "2.1.280", + discoverModels: async () => [], runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } }, }); @@ -653,6 +808,51 @@ describe("Claude Sonnet 5.5 catalog", () => { }); }); +describe("Claude Haiku 5.5 catalog", () => { + it("offers one Haiku 5.5 entry with a 1M context window", () => { + const haiku55Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-haiku-5-5")) + .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); + + expect(haiku55Models).toEqual([ + { id: "claude-haiku-5-5", label: "Haiku 5.5", contextWindowMaxTokens: 1_000_000 }, + ]); + }); + + it("offers Off and every effort level with medium as the default", () => { + const haiku55 = getClaudeModels().find((model) => model.id === "claude-haiku-5-5"); + + expect(haiku55?.thinkingOptions?.map((option) => option.id)).toEqual([ + CLAUDE_DISABLED_THINKING_OPTION_ID, + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(haiku55?.defaultThinkingOptionId).toBe("medium"); + expect( + haiku55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["medium"]); + }); + + it("normalizes provider-prefixed runtime IDs", () => { + expect(normalizeClaudeRuntimeModelId("anthropic/claude-haiku-5-5")).toBe("claude-haiku-5-5"); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-haiku-5-5-v1:0")).toBe( + "claude-haiku-5-5", + ); + expect(claudeManifestModelSupportsFastMode("claude-haiku-5-5")).toBe(false); + }); + + it("resolves suffixed and dated Haiku 5.5 IDs to the single catalog entry", () => { + expect(findClaudeModel("claude-haiku-5-5[1m]")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5-20261007")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5-20261007[1m]")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000); + }); +}); + describe("claudeManifestModelSupportsFastMode", () => { it("keeps fast mode strict to first-party manifest model IDs", () => { expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull(); @@ -669,3 +869,124 @@ describe("claudeManifestModelSupportsFastMode", () => { expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false); }); }); + +describe("Claude Mythos 5.1 catalog", () => { + it("offers one 1M model with high default effort and always-on thinking", () => { + const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos")); + expect(models).toHaveLength(1); + expect(models[0]).toMatchObject({ + id: "claude-mythos-5-1", + label: "Mythos 5.1", + contextWindowMaxTokens: 1_000_000, + defaultThinkingOptionId: "high", + }); + expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect( + models[0].thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["high"]); + expect(resolveClaudeDisabledThinkingForModel("claude-mythos-5-1")).toEqual({ + supported: false, + fallbackThinkingOptionId: "high", + }); + expect(claudeManifestModelSupportsFastMode("claude-mythos-5-1")).toBe(false); + expect(getClaudeModels().find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); + }); + + it.each([ + "claude-mythos-5-1[1m]", + "claude-mythos-5-1-20260901", + "claude-mythos-5-1-20260901[1m]", + "Mythos 5.1", + ])("resolves %s to the single catalog entry", (id) => { + expect(findClaudeModel(id)?.id).toBe("claude-mythos-5-1"); + }); + + it.each(["anthropic/claude-mythos-5-1", "us.anthropic.claude-mythos-5-1-v1:0"])( + "normalizes runtime identity without granting first-party capabilities: %s", + (id) => { + expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-mythos-5-1"); + expect(normalizeClaudeManifestModelId(id)).toBeNull(); + }, + ); +}); + +describe("Claude model discovery process", () => { + it("does not send a prompt or persist a session and closes after discovery", async () => { + let closed = false; + let promptEnded: Promise<IteratorResult<unknown>> | undefined; + const advertised: ModelInfo[] = [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ]; + const models = await discoverClaudeModels( + { resolveBinary: async () => "/claude", env: { CLAUDE_CONFIG_DIR: "/provider-config" } }, + (input) => { + expect(input.options).toMatchObject({ + pathToClaudeCodeExecutable: "/claude", + env: { CLAUDE_CONFIG_DIR: "/provider-config" }, + persistSession: false, + tools: [], + mcpServers: {}, + strictMcpConfig: true, + settingSources: ["user"], + settings: { disableAllHooks: true }, + }); + if (typeof input.prompt === "string") throw new Error("unexpected inference prompt"); + promptEnded = input.prompt[Symbol.asyncIterator]().next(); + return { + supportedModels: async () => advertised, + close: () => { + closed = true; + }, + }; + }, + ); + expect(models).toEqual(advertised); + expect(closed).toBe(true); + await expect(promptEnded).resolves.toEqual({ done: true, value: undefined }); + }); + + it("times out and closes an unresponsive discovery process", async () => { + let closed = false; + await expect( + discoverClaudeModels({ resolveBinary: async () => "/claude", env: {} }, () => ({ + supportedModels: () => new Promise(() => {}), + close: () => { + closed = true; + }, + })), + ).rejects.toThrow("Claude model discovery timed out"); + expect(closed).toBe(true); + }, 10_000); + + it("closes a stalled probe when its refresh is canceled", async () => { + const controller = new AbortController(); + const failure = new Error("refresh canceled"); + let closed = false; + const result = discoverClaudeModels( + { resolveBinary: async () => "/claude", env: {}, signal: controller.signal }, + () => ({ + supportedModels: () => { + controller.abort(failure); + return new Promise(() => {}); + }, + close: () => { + closed = true; + }, + }), + ); + await expect(result).rejects.toBe(failure); + expect(closed).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 6b341fa9ea..2a9b801fdb 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -48,12 +48,22 @@ export function findClaudeModel( return getClaudeModels().find((model) => model.id === normalizedModelId); } +export function getClaudeContextWindowMaxTokens( + modelId: string | null | undefined, +): number | undefined { + if (modelId?.trim().toLowerCase().endsWith("[1m]")) { + return 1_000_000; + } + return findClaudeModel(modelId)?.contextWindowMaxTokens; +} + export async function getClaudeModelsWithSettings( logger: Logger, configDir: string, claudeCodeVersion?: string, + discoveredModelIds: ReadonlySet<string> = new Set(), ): Promise<AgentModelDefinition[]> { - const hardcodedModels = getClaudeModels(claudeCodeVersion); + const hardcodedModels = getClaudeManifestModels(claudeCodeVersion, discoveredModelIds); const settingsModels = await readClaudeSettingsModels(logger, configDir); if (settingsModels.length === 0) { return hardcodedModels; @@ -70,7 +80,10 @@ export async function getClaudeModelsWithSettings( } continue; } - models.push(model); + const manifestModel = getClaudeModels(claudeCodeVersion).find( + (candidate) => candidate.id === model.id, + ); + models.push({ ...manifestModel, ...model }); } return models; diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts index ccac0841de..030a54c813 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -1,4 +1,4 @@ -import { realpathSync } from "node:fs"; +import { existsSync, readdirSync, realpathSync } from "node:fs"; import { realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -30,6 +30,46 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti return join(projectsRoot, encode(canonical)); } +// Claude Code resumes a session by id from any working directory and keeps appending to the +// transcript in the project folder where the session started. That folder differs from the +// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the +// cwd under another name (a Windows binary under WSL). Returns the expected path when no +// transcript exists anywhere. +export function claudeTranscriptPathSync(input: { + cwd: string; + sessionId: string; + configDir?: string; +}): string { + const options = { configDir: input.configDir }; + const fileName = `${input.sessionId}.jsonl`; + const expected = join(claudeProjectDirSync(input.cwd, options), fileName); + if (existsSync(expected)) { + return expected; + } + const projectsRoot = join(resolveConfigDir(options), "projects"); + let projectDirs: string[]; + try { + projectDirs = readdirSync(projectsRoot); + } catch (error) { + if (isMissingDirectoryError(error)) { + return expected; + } + throw error; + } + for (const projectDir of projectDirs) { + const candidate = join(projectsRoot, projectDir, fileName); + if (existsSync(candidate)) { + return candidate; + } + } + return expected; +} + +function isMissingDirectoryError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} + async function canonicalize(input: string): Promise<string> { try { return normalizeProjectPath(await realpath(input)); diff --git a/packages/server/src/server/agent/providers/claude/question-response.test.ts b/packages/server/src/server/agent/providers/claude/question-response.test.ts new file mode 100644 index 0000000000..26909faab7 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/question-response.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "vitest"; +import type { CanUseTool, Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { ClaudeAgentClient } from "./agent.js"; +import type { ClaudeQueryFactory } from "./query.js"; +import type { AgentPermissionResponse } from "../../agent-sdk-types.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; + +// The SDK dependency receives the same permission callback as a real Claude process. +// Keep its stream open until close; permission state belongs entirely to the session. +function createSdkFake() { + let canUseTool: CanUseTool | undefined; + let finish!: () => void; + const finished = new Promise<void>((resolve) => { + finish = resolve; + }); + const queryFactory: ClaudeQueryFactory = ({ options }) => { + canUseTool = options.canUseTool; + return { + close: finish, + return: async () => { + finish(); + return { value: undefined, done: true }; + }, + [Symbol.asyncIterator]() { + return { + next: async (): Promise<IteratorResult<SDKMessage, void>> => { + await finished; + return { value: undefined, done: true }; + }, + }; + }, + } as Query; + }; + return { + queryFactory, + ask: (input: Record<string, unknown>) => { + if (!canUseTool) throw new Error("SDK permission callback was not configured"); + return canUseTool("AskUserQuestion", input, { + signal: new AbortController().signal, + toolUseID: "question-tool-call", + }); + }, + }; +} + +const questionInput = { + questions: [{ question: "Which colour?", header: "Colour", options: [], multiSelect: false }], +}; + +const invalidResponses: Array<[string, AgentPermissionResponse]> = [ + ["selectedActionId", { behavior: "allow", selectedActionId: "Viridian" }], + ["missing answers", { behavior: "allow", updatedInput: {} }], + ["unknown question", { behavior: "allow", updatedInput: { answers: { Hue: "Viridian" } } }], + ["blank answer", { behavior: "allow", updatedInput: { answers: { Colour: " " } } }], + ["non-string answer", { behavior: "allow", updatedInput: { answers: { Colour: 1 } } }], + [ + "empty echoed questions", + { behavior: "allow", updatedInput: { questions: [], answers: { Colour: "Viridian" } } }, + ], +]; + +describe("Claude question permission responses", () => { + test.each(invalidResponses)( + "rejects %s and delivers a corrected retry", + async (_label, response) => { + const sdk = createSdkFake(); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdk.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + try { + await session.startTurn("Ask which colour"); + const answer = sdk.ask(questionInput); + void answer.catch(() => undefined); + const request = session.getPendingPermissions()[0]!; + expect(request.kind).toBe("question"); + await expect(session.respondToPermission(request.id, response)).rejects.toMatchObject({ + code: "question_answer_required", + requestId: request.id, + }); + expect(session.getPendingPermissions()).toEqual([request]); + await session.respondToPermission(request.id, { + behavior: "allow", + updatedInput: { answers: { Colour: "Viridian" } }, + }); + await expect(answer).resolves.toMatchObject({ + behavior: "allow", + updatedInput: { answers: { "Which colour?": "Viridian" } }, + }); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }, + ); + + test.each([ + ["header", questionInput, { Colour: "Viridian" }, { "Which colour?": "Viridian" }], + ["question text", questionInput, { "Which colour?": "Ochre" }, { "Which colour?": "Ochre" }], + [ + "padded question text", + { questions: [{ question: " Which colour? ", header: " Colour " }] }, + { " Which colour? ": "Ochre" }, + { " Which colour? ": "Ochre" }, + ], + [ + "padded header", + { questions: [{ question: " Which colour? ", header: " Colour " }] }, + { " Colour ": "Viridian" }, + { " Which colour? ": "Viridian" }, + ], + [ + "partial answer", + { questions: [...questionInput.questions, { question: "Ship it?", header: "Ship" }] }, + { Ship: "Yes" }, + { "Ship it?": "Yes" }, + ], + ])("delivers answers keyed by %s", async (_label, input, answers, expected) => { + const sdk = createSdkFake(); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdk.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + try { + await session.startTurn("Ask which colour"); + const answer = sdk.ask(input); + void answer.catch(() => undefined); + const request = session.getPendingPermissions()[0]!; + await session.respondToPermission(request.id, { + behavior: "allow", + updatedInput: { answers }, + }); + await expect(answer).resolves.toMatchObject({ + behavior: "allow", + updatedInput: { answers: expected }, + }); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); + + test("allows denying a question without answers", async () => { + const sdk = createSdkFake(); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdk.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + try { + await session.startTurn("Ask which colour"); + const answer = sdk.ask(questionInput); + void answer.catch(() => undefined); + const request = session.getPendingPermissions()[0]!; + await session.respondToPermission(request.id, { behavior: "deny", message: "No answer" }); + await expect(answer).resolves.toMatchObject({ behavior: "deny", message: "No answer" }); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/rewind.test.ts b/packages/server/src/server/agent/providers/claude/rewind.test.ts index 99243bb9c4..2d6d3853c2 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.test.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, test } from "vitest"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { revertClaudeConversation, revertClaudeConversationAndFiles, revertClaudeFiles, + realClaudeRewindSdk, } from "./rewind.js"; import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js"; @@ -16,6 +21,7 @@ describe("Claude rewind", () => { await revertClaudeConversation({ sdk: claude, sessionId, + transcriptPath: "/claude/projects/repo/original-session.jsonl", messageId: "user-message-1", setSessionId: (nextSessionId) => { sessionId = nextSessionId; @@ -33,6 +39,7 @@ describe("Claude rewind", () => { await revertClaudeConversation({ sdk: claude, sessionId, + transcriptPath: "/claude/projects/repo/original-session.jsonl", messageId: "timeline-message-1", resolveMessageId: () => "claude-jsonl-message-1", setSessionId: (nextSessionId) => { @@ -76,6 +83,7 @@ describe("Claude rewind", () => { sdk: claude, query: claude.createQuery() as Query, sessionId, + transcriptPath: "/claude/projects/repo/original-session.jsonl", messageId: "user-message-1", setSessionId: (nextSessionId) => { sessionId = nextSessionId; @@ -87,3 +95,126 @@ describe("Claude rewind", () => { expect(sessionId).toBe("forked-before-rehydrate"); }); }); + +interface TranscriptLine { + type: "user" | "assistant"; + uuid: string; + parentUuid: string | null; + text: string; +} + +function writeTranscript( + transcriptPath: string, + sessionId: string, + lines: Array<TranscriptLine | string>, +): void { + const entries = lines.map((line) => { + if (typeof line === "string") return line; + const { type, uuid, parentUuid, text } = line; + return JSON.stringify({ + type, + uuid, + parentUuid, + sessionId, + cwd: "/repo", + timestamp: new Date().toISOString(), + message: { role: type, content: text }, + }); + }); + writeFileSync(transcriptPath, `${entries.join("\n")}\n`, "utf8"); +} + +function readMessageTexts(transcriptPath: string): string[] { + const entries = readFileSync(transcriptPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { message?: { content: string } }); + return entries.flatMap((entry) => (entry.message ? [entry.message.content] : [])); +} + +describe("Claude rewind against the Claude SDK", () => { + let tempRoot: string | null = null; + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + + afterEach(() => { + if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = null; + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + }); + + test("forks a session that lives in a provider's own CLAUDE_CONFIG_DIR", async () => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-")); + // The provider sets CLAUDE_CONFIG_DIR for Claude Code; the daemon's own environment does not. + delete process.env.CLAUDE_CONFIG_DIR; + const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo"); + mkdirSync(projectDir, { recursive: true }); + const sessionId = randomUUID(); + const firstUser = randomUUID(); + const firstReply = randomUUID(); + const secondUser = randomUUID(); + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`); + writeTranscript(transcriptPath, sessionId, [ + { type: "user", uuid: firstUser, parentUuid: null, text: "first" }, + { type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" }, + { type: "user", uuid: secondUser, parentUuid: firstReply, text: "second" }, + { type: "assistant", uuid: randomUUID(), parentUuid: secondUser, text: "second reply" }, + ]); + let currentSessionId = sessionId; + + await revertClaudeConversation({ + sdk: realClaudeRewindSdk, + sessionId, + transcriptPath, + messageId: firstReply, + setSessionId: (nextSessionId) => { + currentSessionId = nextSessionId; + }, + }); + + expect(currentSessionId).not.toBe(sessionId); + expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([ + "first", + "first reply", + ]); + }); + + test("skips transcript lines that are not entries when forking", async () => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-")); + delete process.env.CLAUDE_CONFIG_DIR; + const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo"); + mkdirSync(projectDir, { recursive: true }); + const sessionId = randomUUID(); + const firstUser = randomUUID(); + const firstReply = randomUUID(); + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`); + writeTranscript(transcriptPath, sessionId, [ + "null", + { type: "user", uuid: firstUser, parentUuid: null, text: "first" }, + "[1, 2]", + '"text"', + "{}", + { type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" }, + { type: "user", uuid: randomUUID(), parentUuid: firstReply, text: "second" }, + ]); + let currentSessionId = sessionId; + + await revertClaudeConversation({ + sdk: realClaudeRewindSdk, + sessionId, + transcriptPath, + messageId: firstReply, + setSessionId: (nextSessionId) => { + currentSessionId = nextSessionId; + }, + }); + + expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([ + "first", + "first reply", + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/rewind.ts b/packages/server/src/server/agent/providers/claude/rewind.ts index 9c3e3d708d..3b228aacc6 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -1,29 +1,90 @@ -import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk"; +import { appendFile, readFile } from "node:fs/promises"; +import path from "node:path"; +import { + forkSession as claudeForkSession, + type Query, + type SessionStore, + type SessionStoreEntry, +} from "@anthropic-ai/claude-agent-sdk"; export interface ClaudeRewindSdk { forkSession( sessionId: string, - options: { upToMessageId: string }, + options: { upToMessageId: string; transcriptPath: string }, ): Promise<{ sessionId: string }>; } +// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a +// provider can point Claude Code at another one. Forking through a store rooted at the +// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it. export const realClaudeRewindSdk: ClaudeRewindSdk = { - forkSession: claudeForkSession, + forkSession: (sessionId, options) => + claudeForkSession(sessionId, { + upToMessageId: options.upToMessageId, + sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)), + }), }; +function projectFolderSessionStore(projectFolder: string): SessionStore { + const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`); + return { + async load({ sessionId }) { + let text: string; + try { + text = await readFile(transcriptPath(sessionId), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + return parseTranscriptEntries(text); + }, + async append({ sessionId }, entries) { + const lines = entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""); + await appendFile(transcriptPath(sessionId), lines, "utf8"); + }, + }; +} + +function parseTranscriptEntries(text: string): SessionStoreEntry[] { + const entries: SessionStoreEntry[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // Skip a line that is not JSON, as the SDK's own transcript reader does. + continue; + } + if (isSessionStoreEntry(parsed)) entries.push(parsed); + } + return entries; +} + +function isSessionStoreEntry(value: unknown): value is SessionStoreEntry { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof (value as { type?: unknown }).type === "string" + ); +} + export async function revertClaudeConversation(input: { sdk: ClaudeRewindSdk; sessionId: string | null; + transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise<string>; setSessionId: (sessionId: string) => void; }): Promise<void> { - if (!input.sessionId) { + if (!input.sessionId || !input.transcriptPath) { throw new Error("Claude session is not ready for rewind"); } const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId; const fork = await input.sdk.forkSession(input.sessionId, { upToMessageId: messageId, + transcriptPath: input.transcriptPath, }); input.setSessionId(fork.sessionId); } @@ -44,6 +105,7 @@ export async function revertClaudeConversationAndFiles(input: { sdk: ClaudeRewindSdk; query: Query; sessionId: string | null; + transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise<string>; setSessionId: (sessionId: string) => void; diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index 615ca81f3a..f762557879 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => { }, }); }); + + it("shows a SubagentHandback report as the subagent's final message", () => { + const tracker = new ClaudeSidechainTracker({ getToolInput: () => null }); + const report = " indented code\n\n## Verdict\n\n- **Coherent**"; + const frames = [ + { + type: "stream_event", + parent_tool_use_id: "task-1", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: {}, + }, + }, + }, + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { + id: "msg-1", + content: [ + { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "task-1", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "handback-1", + tool_name: "SubagentHandback", + content: [{ type: "text", text: '{"success":true}' }], + }, + ], + }, + }, + ]; + + const childItems = frames + .flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1")) + .flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" + ? [event.event.item] + : [], + ); + + expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 6973d1db5d..195e21f857 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -6,6 +6,10 @@ import { mapClaudeRunningToolCall, } from "./tool-call-mapper.js"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; +import { + isClaudeSubagentHandbackToolName, + readClaudeSubagentHandback, +} from "./subagent-handback.js"; import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; @@ -91,7 +95,7 @@ export class ClaudeSidechainTracker { const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); const actionCandidates = this.extractSubAgentActionCandidates(message); const childTimelineItems = [ - ...this.extractSubAgentTimelineItems(message), + ...this.extractSubAgentTimelineItems(message, state), ...this.extractSubAgentToolResults(message, state), ]; let actionUpdated = false; @@ -233,7 +237,10 @@ export class ClaudeSidechainTracker { this.activeSidechains.clear(); } - private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] { + private extractSubAgentTimelineItems( + message: SDKMessage, + state: SubAgentActivityState, + ): AgentTimelineItem[] { if (message.type !== "assistant" || !Array.isArray(message.message?.content)) { return []; } @@ -253,6 +260,18 @@ export class ClaudeSidechainTracker { } else if (block.type === "thinking") { const text = readTrimmedString(block.thinking); if (text) items.push({ type: "reasoning", text }); + } else { + const handback = readClaudeSubagentHandback(block); + if (!handback) continue; + // The handback's result is only an acknowledgement, never a tool card. + state.completedActionKeys.add(handback.callId); + if (handback.report) { + items.push({ + type: "assistant_message", + text: handback.report, + ...(messageId ? { messageId } : {}), + }); + } } } return items; @@ -391,6 +410,12 @@ export class ClaudeSidechainTracker { } private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { + return this.extractToolActionCandidates(message).filter( + (action) => !isClaudeSubagentHandbackToolName(action.toolName), + ); + } + + private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { if (message.type === "assistant") { return this.extractAssistantMessageActions(message); } diff --git a/packages/server/src/server/agent/providers/claude/subagent-handback.ts b/packages/server/src/server/agent/providers/claude/subagent-handback.ts new file mode 100644 index 0000000000..2a8613ed31 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagent-handback.ts @@ -0,0 +1,27 @@ +/** + * Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a + * `SubagentHandback` tool call instead of its final text. The report is the subagent's final + * message, so Paseo shows it as one and hides the call and its acknowledgement. + */ +const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback"; + +export interface ClaudeSubagentHandback { + callId: string; + /** Null while the call is still streaming its input. */ + report: string | null; +} + +export function isClaudeSubagentHandbackToolName(name: unknown): boolean { + return name === SUBAGENT_HANDBACK_TOOL_NAME; +} + +export function readClaudeSubagentHandback(block: { + type: string; + [key: string]: unknown; +}): ClaudeSubagentHandback | null { + if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null; + if (typeof block.id !== "string" || block.id.length === 0) return null; + const input = block.input as { message?: unknown } | null | undefined; + const report = typeof input?.message === "string" ? input.message : ""; + return { callId: block.id, report: report.trim().length > 0 ? report : null }; +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts index 1eca4cedc2..b0bfc3d755 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts @@ -511,6 +511,33 @@ describe("ClaudeTaskProtocolSource", () => { expect(source.cancelRunningForegroundTasks()).toEqual([]); }); + it("does not cancel a subagent announced as backgrounded at spawn", () => { + // Claude sets is_backgrounded on task_started for a child spawned in the background, and + // sends no task_updated patch for it. + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("does not cancel a resumed subagent, which Claude always runs in the background", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ tool_use_id: "toolu_original", is_backgrounded: false })); + source.observe(taskUpdated("completed")); + source.observe(taskStarted({ tool_use_id: "toolu_resumed", is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("still cancels a subagent announced in the foreground", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: false })); + + expect(source.cancelRunningForegroundTasks()).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "canceled" }, + ]); + }); + it("still routes a backgrounded subagent that settles after the interrupt", () => { // The headline case: interrupt, continue, and the child that was told to outlive the turn // reports completion later. Wiping the routing table on cancel drops this on the floor and diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index 1a44520366..2149fae139 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -16,7 +16,8 @@ import { * * Verified on the wire (Claude Code 2.1.220, Paseo's own query options): * - * task_started task_id, tool_use_id, description, subagent_type, task_type + * task_started task_id, tool_use_id, description, subagent_type, task_type, + * is_backgrounded * task_updated task_id, patch.status, patch.is_backgrounded * task_notification task_id, tool_use_id, status * @@ -39,6 +40,11 @@ interface TaskStartedMessage { task_type?: string; prompt?: string; skip_transcript?: boolean; + /** + * Set at spawn: true for a task registered in the background. A resumed subagent is always + * registered in the background. + */ + is_backgrounded?: boolean; } /** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */ @@ -71,8 +77,8 @@ interface TaskUpdatedMessage { task_id: string; /** * `is_backgrounded` is declared on `SDKTaskUpdatedMessage["patch"]`: it flips when a foreground - * task is backgrounded, which is the only signal that separates a child that dies with its turn - * from one that was explicitly told to outlive it. + * task is moved to the background after `task_started`. Together with `task_started`'s own + * flag it separates a child that dies with its turn from one told to outlive it. */ patch?: { status?: string; is_backgrounded?: boolean }; } @@ -171,8 +177,8 @@ export class ClaudeTaskProtocolSource { /** Workflow invocations already own a real Workflow card in the parent timeline. */ private readonly idsWithExistingParentToolCard = new Set<string>(); /** - * Declared subagents that were moved to the background. They outlive the turn that spawned - * them, so a turn ending is not evidence that they stopped. + * Declared subagents running in the background, whether spawned there or moved there later. + * They outlive the turn that spawned them, so a turn ending is not evidence that they stopped. */ private readonly backgroundedIds = new Set<string>(); /** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */ @@ -331,6 +337,7 @@ export class ClaudeTaskProtocolSource { this.sawTaskStarted = true; const existingId = this.subagentIdByTaskId.get(message.task_id); + this.recordBackgrounded(existingId ?? id, message.is_backgrounded); if (existingId) { return this.observeExistingTaskStart(message, id, existingId); } @@ -414,14 +421,15 @@ export class ClaudeTaskProtocolSource { private observeTaskUpdated(message: TaskUpdatedMessage): SubagentObservation[] { const id = this.subagentIdByTaskId.get(message.task_id); - const backgrounded = message.patch?.is_backgrounded; - if (id && typeof backgrounded === "boolean") { - if (backgrounded) this.backgroundedIds.add(id); - else this.backgroundedIds.delete(id); - } + if (id) this.recordBackgrounded(id, message.patch?.is_backgrounded); return this.observeStatus(message.task_id, message.patch?.status); } + private recordBackgrounded(id: string, backgrounded: boolean | undefined): void { + if (backgrounded === true) this.backgroundedIds.add(id); + else if (backgrounded === false) this.backgroundedIds.delete(id); + } + private observeTaskNotification(message: TaskNotificationMessage): SubagentObservation[] { const observations = this.observeWorkflowResult(message); observations.push(...this.observeUsage(message.task_id, message.usage)); diff --git a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts index 496d3bc209..d607b5c063 100644 --- a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts +++ b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts @@ -12,7 +12,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk { async forkSession( _sessionId: string, - options: { upToMessageId: string }, + options: { upToMessageId: string; transcriptPath: string }, ): Promise<{ sessionId: string }> { this.recordedForks.push({ upToMessageId: options.upToMessageId }); return { sessionId: this.nextSessionId }; diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index 5c9dbe956c..cc32a3c013 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -22,6 +22,44 @@ describe("Codex app-server transport", () => { child.stdin.end(); }); + test("keeps a JSON message intact when it contains raw U+2028/U+2029", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + + const request = client.request("thread/resume", {}); + const text = "a\u2028b\u2029c\u0085d"; + // Codex does not escape U+2028/U+2029 in JSON output, so write them raw. + const message = `{"id":1,"result":{"text":"${text}"}}\n`; + const bytes = Buffer.from(message, "utf8"); + // Split mid-way (inside a multi-byte character) to exercise chunk boundaries. + child.stdout.write(bytes.subarray(0, 28)); + child.stdout.write(bytes.subarray(28)); + + await expect(request).resolves.toEqual({ text }); + child.stdout.end(); + child.stderr.end(); + child.stdin.end(); + }); + + test("reads a multi-megabyte JSON message arriving in 64 KB chunks", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + + const request = client.request("thread/resume", {}); + // A resumed long thread arrives as one large line, split into 64 KB stdout chunks. + const text = "x".repeat(32 * 1024 * 1024); + const bytes = Buffer.from(`{"id":1,"result":{"text":"${text}"}}\n`, "utf8"); + const chunkSize = 64 * 1024; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + child.stdout.write(bytes.subarray(offset, offset + chunkSize)); + } + + await expect(request).resolves.toEqual({ text }); + child.stdout.end(); + child.stderr.end(); + child.stdin.end(); + }); + test("dispose rejects pending requests instead of leaving them hanging", async () => { const child = createCodexAppServerChildProcess(); const client = new CodexAppServerClient(child, createTestLogger()); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index 1720118c63..acb5e76341 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -1,5 +1,5 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import readline from "node:readline"; +import { StringDecoder } from "node:string_decoder"; import type { Logger } from "pino"; import { z } from "zod"; @@ -167,8 +167,41 @@ function readProviderTurnId(params: unknown): string | undefined { return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined; } +/** + * Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029 + * as line terminators, but Codex emits those characters raw inside JSON strings, so readline + * would cut a single JSON-RPC message into pieces that fail to parse. + */ +function createNewlineLineReader( + input: NodeJS.ReadableStream, + onLine: (line: string) => void, +): { close(): void } { + const decoder = new StringDecoder("utf8"); + // Text after the last "\n", kept as parts and joined once its line ends, so a large line + // arriving in many chunks is scanned and copied once instead of once per chunk. + let unterminated: string[] = []; + const onData = (chunk: Buffer | string): void => { + const text = typeof chunk === "string" ? chunk : decoder.write(chunk); + let lineStart = 0; + let index = text.indexOf("\n"); + while (index !== -1) { + unterminated.push(text.slice(lineStart, index)); + const line = unterminated.join(""); + unterminated = []; + onLine(line.endsWith("\r") ? line.slice(0, -1) : line); + lineStart = index + 1; + index = text.indexOf("\n", lineStart); + } + if (lineStart < text.length) { + unterminated.push(text.slice(lineStart)); + } + }; + input.on("data", onData); + return { close: () => void input.off("data", onData) }; +} + export class CodexAppServerClient { - private readonly rl: readline.Interface; + private readonly stdoutLineReader: { close(): void }; private readonly pending = new Map<number, PendingRequest>(); private readonly requestHandlers = new Map<string, RequestHandler>(); private notificationHandler: NotificationHandler | null = null; @@ -182,8 +215,7 @@ export class CodexAppServerClient { private readonly logger: Logger, private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}), ) { - this.rl = readline.createInterface({ input: child.stdout }); - this.rl.on("line", (line) => { + this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => { void this.handleLine(line).catch((error) => { this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line"); }); @@ -259,7 +291,7 @@ export class CodexAppServerClient { async dispose(): Promise<void> { this.disposed = true; this.unexpectedTerminationHandler = null; - this.rl.close(); + this.stdoutLineReader.close(); this.rejectPending(new Error("Codex app-server client is closed")); try { this.child.stdin.end(); @@ -286,7 +318,7 @@ export class CodexAppServerClient { return; } this.disposed = true; - this.rl.close(); + this.stdoutLineReader.close(); this.rejectPending(error); const handler = this.unexpectedTerminationHandler; this.unexpectedTerminationHandler = null; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts new file mode 100644 index 0000000000..fdddb4941c --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts @@ -0,0 +1,213 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient archived history", () => { + test("replays an archived session whose working directory was removed", async () => { + await withFakeACPAgent(async ({ command, removedCwd, loadRequestsPath }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + const loadRequests = JSON.parse(await readFile(loadRequestsPath, "utf8")); + expect(loadRequests).toEqual([{ sessionId: "archived-session", cwd: removedCwd }]); + } finally { + await session.close(); + } + }); + }); + + test("replays an archived session whose working directory path is now a file", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + await writeFile(removedCwd, "not a directory", "utf8"); + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + } finally { + await session.close(); + } + }); + }); + + test("still requires the working directory to resume a session that will run", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + await expect( + client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "interactive" }, + ), + ).rejects.toThrow("ENOENT"); + }); + }); + + test("releases the provider process once an archived session's history is replayed", async () => { + await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + const pid = Number(await readFile(pidPath, "utf8")); + expect(isProcessAlive(pid)).toBe(false); + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + } finally { + await session.close(); + } + }); + }); + + test("keeps the provider process for an interactive resume", async () => { + await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => { + await mkdir(removedCwd, { recursive: true }); + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "interactive" }, + ); + try { + const pid = Number(await readFile(pidPath, "utf8")); + expect(isProcessAlive(pid)).toBe(true); + } finally { + await session.close(); + } + }); + }); +}); + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> { + const text: string[] = []; + for await (const event of events) { + if (event.type !== "timeline") { + continue; + } + if (event.item.type === "user_message" || event.item.type === "assistant_message") { + text.push(`${event.item.type}:${event.item.text}`); + } + } + return text; +} + +async function withFakeACPAgent( + run: (input: { + command: [string, ...string[]]; + removedCwd: string; + loadRequestsPath: string; + pidPath: string; + }) => Promise<void>, +): Promise<void> { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + const loadRequestsPath = path.join(testDir, "load-requests.json"); + const pidPath = path.join(testDir, "agent.pid"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run({ + command: [process.execPath, scriptPath, loadRequestsPath, pidPath], + removedCwd: path.join(testDir, "removed-worktree"), + loadRequestsPath, + pidPath, + }); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +const fakeACPAgentScript = ` +const fs = require("node:fs"); +const readline = require("node:readline"); + +const loadRequestsPath = process.argv[2]; +const pidPath = process.argv[3]; +if (pidPath) { + fs.writeFileSync(pidPath, String(process.pid)); +} +const loadRequests = []; +const rl = readline.createInterface({ input: process.stdin }); + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +function replay(sessionId, sessionUpdate, text) { + write({ + method: "session/update", + params: { sessionId, update: { sessionUpdate, content: { type: "text", text } } }, + }); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + write({ + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? 1, + agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } }, + }, + }); + return; + } + + if (message.method === "session/load") { + const { sessionId, cwd } = message.params; + loadRequests.push({ sessionId, cwd }); + fs.writeFileSync(loadRequestsPath, JSON.stringify(loadRequests)); + replay(sessionId, "user_message_chunk", "What is in the README?"); + replay(sessionId, "agent_message_chunk", "It describes the project."); + write({ id: message.id, result: {} }); + return; + } + + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts index 2411773a7e..3655cccb5b 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts @@ -8,7 +8,7 @@ import { createTestLogger } from "../../../test-utils/test-logger.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js"; describe("GenericACPAgentClient slash commands", () => { - test("lists commands an agent advertises right after session/new", async () => { + test("preserves argument hints in commands advertised right after session/new", async () => { await withFakeACPAgent("commands-after-session-new", async (command, cwd) => { const client = new GenericACPAgentClient({ logger: createTestLogger(), @@ -17,9 +17,17 @@ describe("GenericACPAgentClient slash commands", () => { }); const session = await client.createSession({ provider: "acp", cwd }); try { - await expect(session.listCommands?.()).resolves.toEqual([ - { name: "review", description: "Review the diff", argumentHint: "", kind: "command" }, - ]); + const commands = [ + { + name: "review", + description: "Review the diff", + argumentHint: "query", + kind: "command", + }, + { name: "status", description: "Show status", argumentHint: "", kind: "command" }, + ]; + await expect(session.listCommands?.()).resolves.toEqual(commands); + await expect(session.listCommands?.()).resolves.toEqual(commands); } finally { await session.close(); } @@ -91,7 +99,10 @@ rl.on("line", (line) => { sessionId: "session-1", update: { sessionUpdate: "available_commands_update", - availableCommands: [{ name: "review", description: "Review the diff" }], + availableCommands: [ + { name: "review", description: "Review the diff", input: { hint: "query" } }, + { name: "status", description: "Show status" }, + ], }, }, }, diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 14733e0601..ce9714c5f2 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -232,6 +232,7 @@ type SteeringReplayShape = "claude" | "codex"; interface MockQuestionOption { label: string; description?: string; + preview?: string; } interface MockQuestionPromptQuestion { @@ -271,6 +272,16 @@ function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string | return match?.[1] ?? null; } +const ROLLOUT_FLAG_PREVIEW = [ + "Ship it dark behind the new-surface flag, then turn it on for internal users before everyone else.", + "", + 'if (flags.enabled("new-surface")) {', + " render(<NewSurface />);", + "}", + ...Array.from({ length: 40 }, (_, i) => `// rollout step ${i + 1}`), + "// LAST PREVIEW LINE", +].join("\n"); + function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null { const text = promptToText(prompt); if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) { @@ -309,7 +320,10 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe { question: "Which rollout should we use?", header: "rollout", - options: [{ label: "Immediately" }, { label: "Behind feature flag" }], + options: [ + { label: "Immediately" }, + { label: "Behind feature flag", preview: ROLLOUT_FLAG_PREVIEW }, + ], multiSelect: false, }, { diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 537c94f2a6..a1dc9130a8 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -215,6 +215,55 @@ function createToolCatalog(): PaseoToolCatalog { } describe("OMP agent client and session", () => { + test("failed manual compact closes its progress row and shows the error", async () => { + const omp = new OmpHarness(); + await omp.start(); + + const events = await omp.compact({}, new Error("compaction refused")); + + expect(events).toEqual([ + expect.objectContaining({ + item: { type: "compaction", status: "loading", trigger: "manual" }, + }), + expect.objectContaining({ + item: { type: "compaction", status: "completed", trigger: "manual" }, + }), + expect.objectContaining({ + item: { + type: "assistant_message", + text: "[Error] Failed to compact context: compaction refused", + }, + }), + ]); + }); + + test("manual compact shows its progress and publishes the compacted context usage", async () => { + const omp = new OmpHarness(); + await omp.start(); + + const events = await omp.compact({ + contextUsage: { tokens: 13_447, contextWindow: 272_000 }, + }); + + expect(events).toEqual([ + expect.objectContaining({ + type: "timeline", + item: { type: "compaction", status: "loading", trigger: "manual" }, + }), + expect.objectContaining({ + type: "timeline", + item: { type: "compaction", status: "completed", trigger: "manual" }, + }), + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ + contextWindowUsedTokens: 13_447, + contextWindowMaxTokens: 272_000, + }), + }), + ]); + }); + test("owns launch configuration and registers native host tools", async () => { const omp = new OmpHarness(); await omp.start({ modeId: "ask" }, createToolCatalog()); @@ -459,7 +508,9 @@ describe("OMP agent client and session", () => { test("fails a turn when the provider idle gate passes its deadline", async () => { const scheduler = new ManualIdleScheduler(); - const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 }); + // Long enough that the gate's first check, made right after the turn ends, cannot already + // be past it on a slow runner. + const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 50 }); await omp.start(); const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", { isStreaming: true, @@ -473,7 +524,7 @@ describe("OMP agent client and session", () => { args: { command: "sleep 30" }, }); expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]); - await new Promise((resolve) => setTimeout(resolve, 2)); + await new Promise((resolve) => setTimeout(resolve, 60)); scheduler.retry(); await expect(completion).rejects.toThrow(/provider idle/i); expect(omp.runningToolCallIds()).toEqual([]); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 2dc6c58dea..5413364cdb 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -714,9 +714,7 @@ export class OmpAgentSession implements AgentSession { private readonly pendingPromptResults = new Map<string, OmpPromptResult>(); private pendingNoTurnCompletionAbort: AbortController | null = null; private lastKnownThinkingOptionId: string | null; - private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; - private outOfBandCompactionStarted = false; - private outOfBandCompactionCompleted = false; + private manualCompactionRunning = false; private commandCache: AgentSlashCommand[] | null = null; private readonly subagentIndex = new OmpSubagentIndex(); private readonly subagentCardTracker: OmpSubagentCardTracker; @@ -1407,30 +1405,24 @@ export class OmpAgentSession implements AgentSession { customInstructions: string | undefined, emit: (event: AgentStreamEvent) => void, ): Promise<void> { - if (this.outOfBandCompactionEmit) { + if (this.manualCompactionRunning) { throw new Error("An OMP compact command is already running"); } - this.outOfBandCompactionEmit = emit; - this.outOfBandCompactionStarted = false; - this.outOfBandCompactionCompleted = false; + // OMP answers a manual compact with its RPC response alone, without + // compaction_start or compaction_end, so the request owns both rows. + this.manualCompactionRunning = true; + const emitCompaction = (status: "loading" | "completed") => + emit({ + type: "timeline", + provider: this.provider, + item: { type: "compaction", status, trigger: "manual" }, + }); + emitCompaction("loading"); try { await this.runtimeSession.compact(customInstructions); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if ( - this.outOfBandCompactionEmit === emit && - this.outOfBandCompactionStarted && - !this.outOfBandCompactionCompleted - ) { - this.emitCompactionTimeline({ - turnId: undefined, - item: { - type: "compaction", - status: "completed", - trigger: "manual", - }, - }); - } + emitCompaction("completed"); emit({ type: "timeline", provider: this.provider, @@ -1439,13 +1431,12 @@ export class OmpAgentSession implements AgentSession { text: `[Error] Failed to compact context: ${message}`, }, }); + return; } finally { - if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) { - this.outOfBandCompactionEmit = null; - this.outOfBandCompactionStarted = false; - this.outOfBandCompactionCompleted = false; - } + this.manualCompactionRunning = false; } + emitCompaction("completed"); + await this.usagePoller.refresh(); } private async executeAutoCompactCommand( @@ -1853,24 +1844,8 @@ export class OmpAgentSession implements AgentSession { return; } case "compaction_start": - this.emitCompactionTimeline({ - turnId, - item: { - type: "compaction", - status: "loading", - trigger: event.reason === "manual" ? "manual" : "auto", - }, - }); - return; case "compaction_end": - this.emitCompactionTimeline({ - turnId, - item: { - type: "compaction", - status: "completed", - trigger: event.reason === "manual" ? "manual" : "auto", - }, - }); + this.handleCompactionEvent(event, turnId); return; case "agent_end": { const messages = event.messages ?? []; @@ -1924,35 +1899,24 @@ export class OmpAgentSession implements AgentSession { } } - private emitCompactionTimeline(input: { - turnId: string | undefined; - item: Extract<AgentStreamEvent, { type: "timeline" }>["item"]; - }): void { - const emitOutOfBand = this.outOfBandCompactionEmit; - if (emitOutOfBand && input.item.type === "compaction") { - if (input.item.status === "loading") { - this.outOfBandCompactionStarted = true; - } - if (input.item.status === "completed") { - this.outOfBandCompactionCompleted = true; - } - } - const event: AgentStreamEvent = { - type: "timeline", - provider: this.provider, - ...(emitOutOfBand ? {} : { turnId: input.turnId }), - item: input.item, - }; - if (emitOutOfBand) { - emitOutOfBand(event); - if (input.item.type === "compaction" && input.item.status === "completed") { - this.outOfBandCompactionEmit = null; - this.outOfBandCompactionStarted = false; - this.outOfBandCompactionCompleted = false; - } + private handleCompactionEvent( + event: Extract<OmpAgentSessionEvent, { type: "compaction_start" | "compaction_end" }>, + turnId: string | undefined, + ): void { + // A manual compact emits its own rows around the request. + if (this.manualCompactionRunning) { return; } - this.emit(event); + this.emit({ + type: "timeline", + provider: this.provider, + turnId, + item: { + type: "compaction", + status: event.type === "compaction_start" ? "loading" : "completed", + trigger: event.reason === "manual" ? "manual" : "auto", + }, + }); } private handleMessageUpdate( diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index a747d3bee0..e64a93fd64 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -252,6 +252,90 @@ describe("OMP history mapper", () => { ]); }); + test("replays a rule-violation run with the same rows the live timeline showed", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-rule-reminder-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root" }, + { + type: "message", + id: "prompt", + parentId: "root", + message: { + role: "user", + content: "Read @notes.md, then write tiny.ts", + }, + }, + { + type: "message", + id: "mention", + parentId: "prompt", + message: { + role: "fileMention", + files: [ + { + path: "notes.md", + content: "[notes.md#468D]\n1:# notes", + lineCount: 1, + }, + ], + }, + }, + { + type: "ttsr_injection", + id: "injection", + parentId: "mention", + injectedRules: ["ts-no-tiny-functions"], + }, + { + type: "message", + id: "rule-reminder", + parentId: "injection", + message: { + role: "developer", + content: [ + { + type: "text", + text: '<system-reminder reason="rule_violation" rule="ts-no-tiny-functions" path="builtin-defaults:ts-no-tiny-functions">\nAvoid tiny functions.\n</system-reminder>', + }, + ], + }, + }, + { + type: "message", + id: "answer", + parentId: "rule-reminder", + message: { + role: "assistant", + content: [{ type: "text", text: "FINAL_ANSWER" }], + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ + sessionFile, + provider: "omp", + })) + events.push(event); + expect(events.map((event) => event.item)).toEqual([ + { + type: "user_message", + text: "Read @notes.md, then write tiny.ts", + messageId: "prompt", + }, + { + type: "assistant_message", + text: "FINAL_ANSWER", + messageId: "omp-history-assistant-1", + }, + ]); + }); + test("coalesces replayed subagent poll calls by target set", async () => { const events = await collectHistory([ { @@ -745,6 +829,64 @@ describe("OMP history mapper", () => { ]); }); + test("replays a compaction entry as a completed compaction row", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-compaction-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root", parentId: null }, + { + type: "message", + id: "answer-1", + parentId: "root", + message: { + role: "assistant", + content: [{ type: "text", text: "Before." }], + responseId: "resp-1", + }, + }, + { + type: "compaction", + id: "compaction-1", + parentId: "answer-1", + timestamp: "2026-10-05T04:31:19.537Z", + summary: "## Goal\nSummary", + shortSummary: "Summary", + firstKeptEntryId: "answer-1", + tokensBefore: 77611, + tokensAfter: 37526, + method: "snapcompact", + details: { readFiles: [], modifiedFiles: [] }, + fromExtension: false, + }, + { + type: "message", + id: "answer-2", + parentId: "compaction-1", + message: { + role: "assistant", + content: [{ type: "text", text: "After." }], + responseId: "resp-2", + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) { + events.push(event); + } + expect(events.map((event) => event.item)).toEqual([ + { type: "assistant_message", text: "Before.", messageId: "resp-1" }, + { type: "compaction", status: "completed", preTokens: 77611 }, + { type: "assistant_message", text: "After.", messageId: "resp-2" }, + ]); + expect(events[1]).toMatchObject({ type: "timeline", timestamp: "2026-10-05T04:31:19.537Z" }); + }); + test("maps omp 18.1 custom_message entries like live custom messages", async () => { const dir = mkdtempSync(join(tmpdir(), "omp-custom-message-history-")); const sessionFile = join(dir, "session.jsonl"); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index adca06a45d..2c818dfc5a 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -20,6 +20,10 @@ interface OmpSessionEntry { [key: string]: unknown; } +type OmpReplayEntry = + | { entry: OmpSessionEntry; message: OmpAgentMessage } + | { entry: OmpSessionEntry; item: AgentTimelineItem }; + export async function readOmpHistoryTodoState( sessionFile: string, ): Promise<AgentTimelineItem | null> { @@ -90,13 +94,17 @@ export async function* streamOmpHistory(input: { throw error; } const messages: OmpAgentMessage[] = []; - const messageEntries: OmpSessionEntry[] = []; + const replayEntries: OmpReplayEntry[] = []; const userEntries: OmpCapturedUserMessageEntry[] = []; for (const entry of entries) { + if (entry.type === "compaction") { + replayEntries.push({ entry, item: mapCompactionEntry(entry) }); + continue; + } const mapped = mapEntryMessage(entry); if (!mapped) continue; messages.push(mapped); - messageEntries.push(entry); + replayEntries.push({ entry, message: mapped }); if (mapped.role === "user" && entry.id) { userEntries.push({ id: entry.id, text: textOf(mapped.content) }); } @@ -107,9 +115,13 @@ export async function* streamOmpHistory(input: { OMP_HISTORY_MAPPER_HOOKS, input.bridgedTools, ); - for (let index = 0; index < messages.length; index += 1) { - const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp); - for (const event of mapper.mapMessages([messages[index]!])) { + for (const replayEntry of replayEntries) { + const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp); + const events: AgentStreamEvent[] = + "item" in replayEntry + ? [{ type: "timeline", provider: input.provider, item: replayEntry.item }] + : mapper.mapMessages([replayEntry.message]); + for (const event of events) { yield timestamp && event.type === "timeline" ? { ...event, timestamp } : event; } } @@ -360,8 +372,7 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { content.length === 1 && content[0]?.type === "text" && typeof content[0].text === "string" && - content[0].text.trimStart().startsWith("<system-reminder>") && - content[0].text.trimEnd().endsWith("</system-reminder>") + isSystemReminder(content[0].text) ) { return null; } @@ -370,6 +381,19 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { if (["user", "assistant", "toolResult", "custom", "bashExecution"].includes(message.role)) { return message as unknown as OmpAgentMessage; } + // OMP roles the live timeline does not render either, such as the fileMention that carries + // an @-mentioned file's content. + if ( + [ + "pythonExecution", + "hookMessage", + "branchSummary", + "compactionSummary", + "fileMention", + ].includes(message.role) + ) { + return null; + } return visibleFallback(message.role, message); } if (entry.type === "custom_message") { @@ -381,6 +405,16 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { return visibleFallback(entry.type, entry); } +// OMP persists a compaction as a top-level entry; the live timeline shows it as a compaction row. +// The entry does not record whether a manual /compact or auto-compaction produced it. +function mapCompactionEntry(entry: OmpSessionEntry): AgentTimelineItem { + return { + type: "compaction", + status: "completed", + ...(typeof entry.tokensBefore === "number" ? { preTokens: entry.tokensBefore } : {}), + }; +} + // omp 18.1+ persists injected rows (skill prompts, hub messages, job notices) as top-level // custom_message entries without a message object; replay them as live custom messages function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null { @@ -399,6 +433,13 @@ function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null { } as OmpAgentMessage; } +// OMP's rule-violation reminder carries attributes: <system-reminder reason="rule_violation" ...> +function isSystemReminder(text: string): boolean { + return ( + /^<system-reminder[\s>]/.test(text.trimStart()) && text.trimEnd().endsWith("</system-reminder>") + ); +} + function isControlEntryType(type: string): boolean { return ( type === "session" || @@ -410,6 +451,7 @@ function isControlEntryType(type: string): boolean { type === "system_prompt" || type === "model_change" || type === "thinking_level_change" || + type === "ttsr_injection" || type === "tool_execution" || type.startsWith("tool_execution_") ); diff --git a/packages/server/src/server/agent/providers/omp/question-ui.ts b/packages/server/src/server/agent/providers/omp/question-ui.ts index 4fdb37f61c..2a90d7288e 100644 --- a/packages/server/src/server/agent/providers/omp/question-ui.ts +++ b/packages/server/src/server/agent/providers/omp/question-ui.ts @@ -3,6 +3,7 @@ import type { AgentPermissionResponse, AgentProvider, } from "../../agent-sdk-types.js"; +import { splitMultiSelectAnswer } from "../question-card-answer.js"; import type { OmpAgentMessage, OmpRuntimeEvent } from "./rpc-types.js"; type UiRequest = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>; @@ -176,21 +177,7 @@ function selectedAnswer( return labels.includes(answer) ? { selected: [answer], custom: null } : { selected: [], custom: answer }; - - // The shared question UI sends a comma-joined answer in click order. Consume - // exact option labels from the front; any remaining text is the Other answer. - let remaining = answer; - const selected: string[] = []; - while (remaining.length > 0) { - const label = labels - .filter((candidate) => !selected.includes(candidate)) - .sort((left, right) => right.length - left.length) - .find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `)); - if (!label) break; - selected.push(label); - remaining = remaining === label ? "" : remaining.slice(label.length + 2); - } - return { selected, custom: remaining || null }; + return splitMultiSelectAnswer(answer, labels); } /** Owns OMP question mapping and the ask tool's multi-RPC answer sequence. */ diff --git a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts index 403a389647..58688c245d 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts @@ -168,7 +168,6 @@ export class FakeOmpSession implements OmpRuntimeSession { subagents: FakeOmpSubagentSnapshot[] = []; readonly subagentSubscriptionErrors = new Map<FakeOmpSubagentSubscriptionLevel, Error>(); compactError: Error | null = null; - emitCompactEnd = true; getStateError: Error | null = null; promptAck: OmpPromptAck = {}; branchResponse: { text?: string; cancelled?: boolean } = { text: "" }; @@ -266,11 +265,8 @@ export class FakeOmpSession implements OmpRuntimeSession { } async compact(customInstructions?: string): Promise<void> { + // Like OMP, answer a manual compact with the response alone, no compaction events. this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); - this.emit({ type: "compaction_start", reason: "manual" }); - if (this.emitCompactEnd) { - this.emit({ type: "compaction_end", reason: "manual" }); - } if (this.compactError) { throw this.compactError; } diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 1820c187c9..8be53f1742 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -19,7 +19,12 @@ import { type OmpProviderIdleScheduler, } from "../agent.js"; import type { OmpUsagePollScheduler } from "../usage-poller.js"; -import type { OmpAgentMessage, OmpRpcSlashCommand, OmpRuntimeEvent } from "../rpc-types.js"; +import type { + OmpAgentMessage, + OmpRpcSlashCommand, + OmpRuntimeEvent, + OmpSessionStats, +} from "../rpc-types.js"; import { FakeOmp } from "./fake-omp.js"; const CWD = "/tmp/paseo-omp-agent-test"; @@ -169,6 +174,18 @@ export class OmpHarness { return events; } + async compact(statsAfter: OmpSessionStats, error?: Error): Promise<AgentStreamEvent[]> { + const handler = this.requireSession().tryHandleOutOfBand("/compact"); + if (!handler) throw new Error("OMP session did not handle /compact out-of-band"); + const runtime = this.omp.latestSession(); + const sessionEventCount = this.events.length; + const emitted: AgentStreamEvent[] = []; + runtime.stats = statsAfter; + runtime.compactError = error ?? null; + await handler.run({ emit: (event) => emitted.push(event) }); + return [...emitted, ...this.events.slice(sessionEventCount)]; + } + async runPrompt( input: string, output: string, diff --git a/packages/server/src/server/agent/providers/omp/usage-poller.test.ts b/packages/server/src/server/agent/providers/omp/usage-poller.test.ts index 9750ec59ef..56b9dc667a 100644 --- a/packages/server/src/server/agent/providers/omp/usage-poller.test.ts +++ b/packages/server/src/server/agent/providers/omp/usage-poller.test.ts @@ -36,6 +36,27 @@ function deferred<T>() { } describe("OMP usage poller", () => { + test("refresh publishes usage without ending an active turn's polling", async () => { + const scheduler = new ManualPollScheduler(); + const used: Array<number | undefined> = []; + let stats: OmpSessionStats = { contextUsage: { contextWindow: 200_000, tokens: 9_000 } }; + const poller = new OmpUsagePoller({ + scheduler, + readStats: async () => stats, + onUsage: (update) => used.push(update.contextWindowUsedTokens), + onPollError: (error) => { + throw error; + }, + }); + + poller.startTurn(); + stats = { contextUsage: { contextWindow: 200_000, tokens: 2_000 } }; + await poller.refresh(); + + expect(used).toEqual([2_000]); + expect(scheduler.activePollCount()).toBe(1); + }); + test("emits only changed usage while active", async () => { const scheduler = new ManualPollScheduler(); const updates: AgentUsage[] = []; diff --git a/packages/server/src/server/agent/providers/omp/usage-poller.ts b/packages/server/src/server/agent/providers/omp/usage-poller.ts index 4e41708b5a..bc3bc1c968 100644 --- a/packages/server/src/server/agent/providers/omp/usage-poller.ts +++ b/packages/server/src/server/agent/providers/omp/usage-poller.ts @@ -105,6 +105,17 @@ export class OmpUsagePoller { this.publishUsage(usage, turnId); } + async refresh(): Promise<void> { + let usage: AgentUsage | undefined; + try { + usage = toAgentUsage(await this.options.readStats()); + } catch (error) { + if (!this.closed) this.options.onPollError(error); + return; + } + if (!this.closed) this.publishUsage(usage); + } + close(): void { this.closed = true; this.active = false; diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 13cd6aa4d8..076cbe94bd 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -9,6 +9,10 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; +import { createPaseoToolCatalog } from "../../tools/paseo-tools.js"; +import { AgentManager } from "../../agent-manager.js"; +import { AgentStorage } from "../../agent-storage.js"; +import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js"; import { OpenCodeBridge, loadOpenCodeBridgePluginArtifact, @@ -291,7 +295,10 @@ describe("OpenCodeBridge", () => { ).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] }); await expect( tools.get("paseo_echo_context")!.execute({ value: "blocked" }, { sessionID: "disabled" }), - ).rejects.toThrow("HTTP 403"); + ).rejects.toMatchObject({ + message: "Paseo tools are disabled for this session", + status: 403, + }); await dispose(); } finally { release(); @@ -300,6 +307,148 @@ describe("OpenCodeBridge", () => { } }); + test("v2 plugin preserves create_agent validation errors from the daemon", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-errors-")); + temporaryDirectories.push(paseoHome); + const logger = createTestLogger(); + const providerSnapshotManager = new ProviderSnapshotManager({ logger }); + const catalog = createPaseoToolCatalog({ + agentManager: new AgentManager({ logger }), + agentStorage: new AgentStorage(paseoHome, logger), + providerSnapshotManager, + logger, + }); + const bridge = new OpenCodeBridge({ paseoHome, logger }); + bridge.setManifestCatalog(catalog); + await bridge.start(); + const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog }); + try { + const env = await bridge.decorateV2ServerEnv({}); + const { plugins } = JSON.parse(env.OPENCODE_CONFIG_CONTENT); + const plugin = plugins[0]; + const module = await import( + pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href + ); + const tools = new Map<string, V2TestTool>(); + const dispose = await module.default.setup({ + options: plugin.options, + tool: { + transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => { + transform({ add: (tool) => tools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "" }), + hook: async () => ({ dispose: async () => undefined }), + }, + } satisfies V2TestPluginContext); + try { + const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" }; + const response = await fetch( + `${plugin.options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`, + { + method: "POST", + headers: { + Authorization: `Bearer ${plugin.options.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }, + ); + expect(response.status).toBe(500); + const payload = await response.json(); + expect(payload.error).toContain("provider must be provider/model"); + await expect( + tools.get("paseo_create_agent")!.execute(input, { sessionID: "caller" }), + ).rejects.toMatchObject({ message: payload.error, status: 500 }); + } finally { + await dispose(); + } + } finally { + release(); + await bridge.close(); + await providerSnapshotManager.shutdown(); + } + }); + + test("v2 plugin returns a tool image as OpenCode file content", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-image-")); + temporaryDirectories.push(paseoHome); + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const screenshot = { + name: "browser_screenshot", + title: "Browser screenshot", + description: "Captures the page.", + inputSchema: {}, + async handler() { + return { + content: [ + { type: "text", text: "Captured browser screenshot." }, + { type: "image", data: png, mimeType: "image/png" }, + ], + }; + }, + }; + const tools = new Map([[screenshot.name, screenshot]]); + const catalog: PaseoToolCatalog = { + tools, + getTool: (name) => tools.get(name), + executeTool: async (name, input, context) => + await tools.get(name)!.handler(input, context ?? {}), + }; + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + bridge.setManifestCatalog(catalog); + await bridge.start(); + const release = bridge.bindSession({ sessionId: "session", env: {}, tools: catalog }); + try { + const env = await bridge.decorateV2ServerEnv({}); + const plugin = z + .object({ + plugins: z.array( + z.object({ + package: z.string(), + options: z.object({ baseUrl: z.string(), token: z.string() }), + }), + ), + }) + .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)).plugins[0]!; + const pluginTools = new Map<string, V2TestTool>(); + const module: { + default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> }; + } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); + const dispose = await module.default.setup({ + options: plugin.options, + tool: { + transform: async (transform) => { + transform({ add: (tool) => pluginTools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: undefined }), + hook: async () => ({ dispose: async () => undefined }), + }, + }); + const result = await pluginTools + .get("paseo_browser_screenshot")! + .execute({}, { sessionID: "session" }); + expect(result).toMatchObject({ + content: [ + { type: "text", text: "Captured browser screenshot." }, + { type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` }, + ], + }); + await dispose(); + } finally { + release(); + await bridge.close(); + } + }); + test("v2 structured output validates values and clears its tool on ordinary turns", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-structured-")); temporaryDirectories.push(root); diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts new file mode 100644 index 0000000000..d9d254d9de --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts @@ -0,0 +1,48 @@ +import { TimelineProjection } from "../../../timeline-projection.js"; +import { V2Timeline } from "../v2/timeline.js"; + +// Streams one reasoning part through the timeline into the daemon's projection and +// prints the bytes still held once the part is complete. +const mode = process.argv[2]; +const chunks = Number(process.argv[3]); +if (mode !== "delta" && mode !== "snapshot") throw new Error("Mode must be delta or snapshot"); +if (!global.gc) throw new Error("Run with --expose-gc"); + +global.gc(); +const before = process.memoryUsage().heapUsed; +const timeline = new V2Timeline(); +const projection = new TimelineProjection(); +const part = { assistantMessageID: "answer", type: "reasoning" as const, ordinal: 0 }; +timeline.startPart(part); +let text = ""; +for (let index = 0; index < chunks; index++) { + const delta = `chunk-${index}:`.padEnd(64, String.fromCharCode(65 + (index % 26))); + text += delta; + const event = + mode === "delta" + ? timeline.delta({ ...part, delta }) + : timeline.messages([ + { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "reasoning", text }], + }, + ])[0]; + if (event?.type !== "timeline") throw new Error("Expected a timeline event"); + projection.append({ + seq: index + 1, + timestamp: "2026-10-01T00:00:00.000Z", + turnId: "turn", + item: event.item, + }); +} +global.gc(); +const retainedBytes = process.memoryUsage().heapUsed - before; +const [row] = projection.getRows(); +if (row?.item.type !== "reasoning" || row.item.text !== text) { + throw new Error("Projection lost the streamed text"); +} +process.stdout.write(String(retainedBytes)); diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs index b14540a1c3..6b845d19ed 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs @@ -13,7 +13,16 @@ export default { ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); if (allowMissing && response.status === 404) return null; - if (!response.ok) throw new Error(`Paseo tool bridge returned HTTP ${response.status}`); + if (!response.ok) { + const payload = await response.json().catch(() => null); + const message = + typeof payload?.error === "string" && payload.error + ? payload.error + : `Paseo tool bridge returned HTTP ${response.status}`; + const error = new Error(message); + error.status = response.status; + throw error; + } return response.json(); } async function scope(sessionID) { @@ -45,7 +54,10 @@ export default { `/sessions/${encodeURIComponent(binding.sessionID)}/tools/${encodeURIComponent(definition.name)}`, input, ); - return { content: result.content, metadata: { paseoTool: definition.name } }; + return { + content: result.content.map(toOpenCodeContent), + metadata: { paseoTool: definition.name }, + }; }, }); } @@ -65,3 +77,11 @@ export default { }; }, }; + +// OpenCode tool content is text or a file; MCP images arrive as base64 data. +function toOpenCodeContent(part) { + if (part.type === "image") { + return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType }; + } + return part; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts index 50a6d98549..69da960c5c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts @@ -109,7 +109,7 @@ describe("OpenCode v2 question tool", () => { { value: "Explore", label: "Explore", description: "Map the codebase" }, { value: "Fix a bug", label: "Fix a bug", description: "Track down a bug" }, ], - multiple: false, + multiSelect: false, allowOther: true, }, ], @@ -129,6 +129,99 @@ describe("OpenCode v2 question tool", () => { await session.close(); } }); + + test("answers a multi-select question with the checked options and a typed answer", async () => { + const harness = new V2Harness(); + harness.api.session.form.list = async () => [ + { + id: "question", + sessionID: "session", + title: "Questions", + fields: [ + { + key: "q0", + title: "Fixes", + description: "Which fixes should I make?", + type: "multiselect", + options: [ + { value: "delete", label: "Delete both" }, + { value: "delete-and-fix", label: "Delete both, fix code" }, + { value: "email", label: "Email the user" }, + ], + custom: true, + }, + ], + }, + ]; + const answers: Parameters<V2Api["session"]["form"]["reply"]>[0][] = []; + harness.api.session.form.reply = async (input) => { + answers.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const [request] = session.getPendingPermissions(); + expect(request.input).toMatchObject({ questions: [{ header: "Fixes", multiSelect: true }] }); + await session.respondToPermission("question", { + behavior: "allow", + updatedInput: { + answers: { Fixes: "Delete both, fix code, Email the user, Also check the logs" }, + }, + }); + expect(answers).toEqual([ + { + sessionID: "session", + formID: "question", + answer: { q0: ["delete-and-fix", "email", "Also check the logs"] }, + }, + ]); + expect(session.getPendingPermissions()).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("rejects typed text on a multi-select question that has no typed answer", async () => { + const harness = new V2Harness(); + harness.api.session.form.list = async () => [ + { + id: "question", + sessionID: "session", + title: "Questions", + fields: [ + { + key: "q0", + title: "Fixes", + type: "multiselect", + options: [{ value: "delete", label: "Delete both" }], + }, + ], + }, + ]; + const answers: Parameters<V2Api["session"]["form"]["reply"]>[0][] = []; + harness.api.session.form.reply = async (input) => { + answers.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await expect( + session.respondToPermission("question", { + behavior: "allow", + updatedInput: { answers: { Fixes: "Delete both, Something else" } }, + }), + ).rejects.toThrow("Invalid answer for OpenCode question q0"); + expect(answers).toEqual([]); + } finally { + await session.close(); + } + }); }); describe("OpenCode v2 permission routing", () => { diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts index cc13b99e4b..cb33e0652e 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -7,6 +7,7 @@ import type { AgentSessionConfig, AgentStreamEvent, } from "../../../agent-sdk-types.js"; +import { splitMultiSelectAnswer } from "../../question-card-answer.js"; export class SessionPermissions { private readonly pending = new Map<string, AgentPermissionRequest>(); @@ -109,7 +110,7 @@ export class SessionPermissions { header: field.title ?? field.key, question: field.description ?? field.title ?? field.key, options: "options" in field ? field.options : undefined, - multiple: field.type === "multiselect", + multiSelect: field.type === "multiselect", allowOther: "custom" in field && field.custom === true, })), }, @@ -143,8 +144,8 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu ); }; if (field.type === "multiselect") { - if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string")) - return value.map(labelValue); + const selected = multiSelectAnswer(field, value); + if (selected) return selected.map(labelValue); } else if (field.type === "string" && typeof value === "string") { return labelValue(value); } else if (field.type === "boolean") { @@ -162,3 +163,17 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu } throw new Error(`Invalid answer for OpenCode question ${field.key}`); } + +type MultiSelectField = Extract<FormInfo["fields"][number], { type: "multiselect" }>; + +function multiSelectAnswer(field: MultiSelectField, value: unknown): string[] | undefined { + if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string")) + return value; + if (typeof value !== "string") return undefined; + const { selected, custom } = splitMultiSelectAnswer( + value, + field.options.map((option) => option.label), + ); + if (custom === null) return selected; + return field.custom === true ? [...selected, custom] : undefined; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts index 1bbd86490a..82236de2b8 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts @@ -1,4 +1,6 @@ import type { SessionMessageAssistant } from "@opencode/client"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; import { V2Timeline } from "./timeline.js"; @@ -54,3 +56,79 @@ test("a completed edit carries the replaced and replacement text", () => { }, ]); }); + +test("a tool's image output is shown as an image, not as base64 in the tool output", () => { + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + const screenshot = assistant([ + { + type: "tool", + id: "call-screenshot", + name: "paseo_browser_screenshot", + executed: false, + state: { + status: "completed", + input: {}, + content: [ + { type: "text", text: "Captured browser screenshot (1x1)." }, + { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }, + ], + metadata: {}, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ]); + const timeline = new V2Timeline(); + + const events = timeline.messages([screenshot]); + + expect(JSON.stringify(events)).not.toContain(png); + expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([ + { + type: "tool_call", + name: "paseo_browser_screenshot", + status: "completed", + detail: { output: "Captured browser screenshot (1x1).\n[image]" }, + }, + { + type: "assistant_message", + text: expect.stringMatching(/^!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)$/), + }, + ]); + expect(timeline.messages([screenshot])).toEqual([]); +}); + +test("a surrogate pair split across deltas streams back intact", () => { + const timeline = new V2Timeline(); + const part = { assistantMessageID: "answer", type: "text" as const, ordinal: 0 }; + timeline.startPart(part); + const chunks = ["a\uD83D", "\uDE00b"].map((delta) => { + const event = timeline.delta({ ...part, delta }); + return event?.type === "timeline" && event.item.type === "assistant_message" + ? event.item.text + : null; + }); + expect(chunks).toEqual(["a\uD83D", "\uDE00b"]); + expect(chunks.join("")).toBe("a😀b"); +}); + +function retainedBytesAfterStreaming(mode: "delta" | "snapshot", chunks: number): number { + const fixture = fileURLToPath( + new URL("../test-utils/v2-streamed-text-memory.ts", import.meta.url), + ); + const output = execFileSync( + process.execPath, + ["--expose-gc", "--import", "tsx", fixture, mode, String(chunks)], + { encoding: "utf8" }, + ); + return Number(output); +} + +test.each(["delta", "snapshot"] as const)( + "a long %s-streamed part holds memory in proportion to its text", + (mode) => { + const chunks = 2000; + const textBytes = chunks * 64; + expect(retainedBytesAfterStreaming(mode, chunks)).toBeLessThan(textBytes * 32); + }, +); diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts index 3bc0a5bf76..0fdd6f9f40 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts @@ -1,4 +1,8 @@ import { mapOpencodeToolCall } from "../tool-call-mapper.js"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../../provider-image-output.js"; import type { SessionMessageAssistantTool } from "@opencode/client"; import { STRUCTURED_OUTPUT_TOOL } from "./structured-output.js"; import type { SessionMessageInfo } from "@opencode/client"; @@ -8,6 +12,13 @@ function textPartKey(messageID: string, part: "text" | "reasoning", ordinal: num return `${messageID}:${part}:${ordinal}`; } +// A `slice` can share its source's storage, so every streamed chunk kept by the timeline +// store would hold the whole text it was cut from. Copying the UTF-16 code units makes +// the chunk independent; UTF-8 would corrupt a surrogate pair split across chunks. +function detachedSuffix(text: string, start: number): string { + return Buffer.from(text.slice(start), "utf16le").toString("utf16le"); +} + // Text and reasoning each number parts from zero, independent of snapshot position. export class V2Timeline { private readonly content = new Map<string, string>(); @@ -36,7 +47,7 @@ export class V2Timeline { if (!text.startsWith(emitted)) throw new Error("OpenCode changed previously emitted message content"); this.content.set(key, text); - const suffix = text.slice(emitted.length); + const suffix = detachedSuffix(text, emitted.length); const item: AgentTimelineItem = event.type === "text" ? { type: "assistant_message", text: suffix, messageId: event.assistantMessageID } @@ -139,7 +150,7 @@ export class V2Timeline { if (!part.text.startsWith(previous)) throw new Error("OpenCode changed previously emitted message content"); this.content.set(key, part.text); - const suffix = part.text.slice(previous.length); + const suffix = detachedSuffix(part.text, previous.length); if (part.type === "text") push({ type: "assistant_message", text: suffix, messageId: messageID }); else push({ type: "reasoning", text: suffix }); @@ -158,8 +169,21 @@ export class V2Timeline { const serialized = JSON.stringify(part); if (serialized === this.content.get(key)) return; this.content.set(key, serialized); - const item = toolFromV2(part); + const { output, images } = splitToolContent(part.state); + const item = toolFromV2(part, output); if (item) push(item); + // Images follow their tool call as markdown, as Claude and Codex emit them, + // so base64 never reaches the tool output. + images.forEach((image, index) => { + const imageKey = `${key}:image:${index}`; + if (this.content.has(imageKey)) return; + this.content.set(imageKey, ""); + const imageItem = renderProviderImageOutputAsAssistantMarkdown( + { url: image.uri, mimeType: image.mime }, + { materialize: materializeProviderImage }, + ); + if (imageItem) push(imageItem); + }); } private compactionMessage( @@ -187,12 +211,28 @@ interface TextPartIdentity { interface TextPartDelta extends TextPartIdentity { delta: string; } -function toolFromV2(tool: SessionMessageAssistantTool): AgentTimelineItem | null { +function splitToolContent(state: SessionMessageAssistantTool["state"]): { + output: string | undefined; + images: { uri: string; mime: string }[]; +} { + if (!("content" in state) || !state.content) return { output: undefined, images: [] }; + const images: { uri: string; mime: string }[] = []; + const output = state.content + .map((part) => { + if (part.type === "text") return part.text; + if (!part.uri.startsWith("data:image/")) return part.uri; + images.push(part); + return "[image]"; + }) + .join("\n"); + return { output, images }; +} + +function toolFromV2( + tool: SessionMessageAssistantTool, + output: string | undefined, +): AgentTimelineItem | null { const state = tool.state; - const output = - "content" in state - ? state.content?.map((part) => (part.type === "text" ? part.text : part.uri)).join("\n") - : undefined; return mapOpencodeToolCall({ toolName: tool.name, callId: tool.id, diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index c6384e1fa2..927a9defd2 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -1943,6 +1943,48 @@ describe("PiRpcAgentSession", () => { expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("off"); }); + test.each(["high", "off", "max"] as const)( + "publishes an extension's thinking change to %s before refreshing state", + async (level) => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const changes: AgentStreamEvent[] = []; + session.subscribe((event) => { + if (event.type === "thinking_option_changed") { + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level); + changes.push(event); + } + }); + const runtime = pi.latestSession(); + runtime.state = { ...runtime.state, thinkingLevel: level }; + runtime.emit({ type: "thinking_level_changed", level }); + + expect(changes).toEqual([ + { type: "thinking_option_changed", provider: "pi", thinkingOptionId: level }, + ]); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: level }); + }, + ); + + test.each([undefined, null, "invalid", 123])( + "ignores a thinking change with invalid level %s", + async (level) => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const initialThinking = session.describePersistence()?.metadata?.thinkingOptionId; + const changes: AgentStreamEvent[] = []; + session.subscribe((event) => { + if (event.type === "thinking_option_changed") changes.push(event); + }); + + pi.latestSession().emit({ type: "thinking_level_changed", level }); + + expect(changes).toEqual([]); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(initialThinking); + }, + ); + test("surfaces state refresh failures after a Pi thinking update", async () => { const { pi, session } = await createSession(); onTestFinished(() => session.close()); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 38a1f9815f..146b56b36f 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1005,6 +1005,8 @@ function isProcessExitEvent( function isPiAgentSessionEvent(event: PiRuntimeEvent): event is PiAgentSessionEvent { switch (event.type) { + case "thinking_level_changed": + return typeof event.level === "string" && normalizePiThinkingOption(event.level) !== null; case "agent_start": case "turn_start": case "message_start": @@ -2136,6 +2138,15 @@ export class PiRpcAgentSession implements AgentSession { } switch (event.type) { + case "thinking_level_changed": + this.state = { ...this.state, thinkingLevel: event.level }; + this.config.thinkingOptionId = event.level; + this.emit({ + type: "thinking_option_changed", + provider: this.provider, + thinkingOptionId: event.level, + }); + return; case "agent_start": this.activeTurnStarted = true; this.clearNoTurnBuffers(); diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts index b03c17c6ec..ad666bb4d3 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { fileURLToPath } from "node:url"; import { createPiExtensionHost } from "../index.js"; import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; +import type { PiAgentMessage } from "../../rpc-types.js"; describe("@tintinweb/pi-subagents adapter", () => { test("exposes the background output file while the child is running", () => { @@ -22,6 +23,27 @@ describe("@tintinweb/pi-subagents adapter", () => { ]); expect(mapping?.childSessions).toEqual([{ id: "call-1", file: "/tmp/child.output" }]); }); + test("marks a foreground child that wrapped up at its turn limit as completed", () => { + const mapping = createPiExtensionHost().mapToolCall({ + callId: "call-1", + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { + details: { agentId: "native-1", status: "steered" }, + content: [{ type: "text", text: "Wrapped up (turn limit)" }], + }, + }); + expect(mapping?.subagents).toEqual([ + expect.objectContaining({ id: "call-1", status: "completed" }), + ]); + expect(mapping?.detail).toEqual({ + type: "sub_agent", + subAgentType: "Explore", + description: "Inspect", + log: "Wrapped up (turn limit)", + }); + }); test("maps captured foreground lifecycle live and on replay", async () => { const events = await verifySubagentFixture( readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)), @@ -65,6 +87,134 @@ describe("@tintinweb/pi-subagents adapter", () => { call_277179: "completed", }); }); + test.each(["subagent-notification", "subagent-update"])( + "completes turn-limited children in a grouped %s", + (customType) => { + const host = createPiExtensionHost(); + for (const id of ["1", "2", "3"]) { + host.mapToolCall({ + callId: `call-${id}`, + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: `native-${id}`, status: "background" } }, + }); + } + const mapping = host.mapCustomMessage({ + role: "custom", + customType, + content: "Wrapped up (turn limit)", + display: true, + timestamp: 0, + details: { + id: "native-1", + status: "steered", + others: [ + { id: "native-2", status: "steered" }, + { id: "native-3", status: "running" }, + ], + }, + }); + expect(mapping?.subagents).toEqual([ + expect.objectContaining({ id: "call-1", status: "completed" }), + expect.objectContaining({ id: "call-2", status: "completed" }), + expect.objectContaining({ id: "call-3", status: "running" }), + ]); + }, + ); + test.each(["get_subagent_result", "steer_subagent"])( + "completes a turn-limited child when collected through %s", + (toolName) => { + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "call-1", + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status: "background" } }, + }); + const mapping = host.mapToolCall({ + callId: "followup-1", + toolName, + args: { agent_id: "native-1" }, + status: "completed", + result: { + details: { agentId: "native-1", status: "steered" }, + content: [{ type: "text", text: "Wrapped up (turn limit)" }], + }, + }); + expect(mapping?.subagents).toEqual([{ type: "upsert", id: "call-1", status: "completed" }]); + expect(mapping?.detail).toEqual({ + type: "sub_agent", + description: undefined, + log: "Wrapped up (turn limit)", + }); + }, + ); + test.each([ + ["background", "running"], + ["queued", "running"], + ["running", "running"], + ["completed", "completed"], + ["error", "failed"], + ["aborted", "canceled"], + ["stopped", "canceled"], + ["unknown", "running"], + ])("preserves the mapping of %s to %s", (status, expected) => { + const mapping = createPiExtensionHost().mapToolCall({ + callId: "call-1", + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status } }, + }); + expect(mapping?.subagents).toEqual([ + expect.objectContaining({ id: "call-1", status: expected }), + ]); + }); + test("completes a turn-limited background child live and on history replay", async () => { + const path = new URL("./fixtures/background.json", import.meta.url); + const source = readSubagentFixture(path); + const notification = source.messages.find((message) => message.role === "custom"); + const notificationDetails = notification?.details; + if ( + !notificationDetails || + typeof notificationDetails !== "object" || + !("outputFile" in notificationDetails) || + typeof notificationDetails.outputFile !== "string" + ) { + throw new Error("Background fixture is missing its child output file"); + } + const fixture = readSubagentFixture(path, { + from: notificationDetails.outputFile, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + function wrapUp(message: PiAgentMessage): PiAgentMessage { + if (message.role !== "custom") return message; + const details = message.details; + if (!details || typeof details !== "object") { + throw new Error("Background fixture is missing its notification details"); + } + return { + ...message, + content: "Wrapped up (turn limit)", + details: { ...details, status: "steered" }, + }; + } + const events = await verifySubagentFixture({ + ...fixture, + events: fixture.events.map((event) => + event.type === "message_end" ? { ...event, message: wrapUp(event.message) } : event, + ), + messages: fixture.messages.map(wrapUp), + }); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toEqual(["running", "running", "completed"]); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); test("declines foreign Agent results", () => { expect( createPiExtensionHost().mapToolCall({ diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts index 9c6ef9917e..56b5935172 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts @@ -33,7 +33,8 @@ const Notification = NotificationEntry.extend({ others: z.array(NotificationEntry).optional(), }); const status = (value: string): "running" | "completed" | "failed" | "canceled" => { - if (value === "completed") return "completed"; + // A soft turn-limit wrap-up has finished; it is not an in-flight steering request. + if (value === "completed" || value === "steered") return "completed"; if (value === "error") return "failed"; if (value === "aborted" || value === "stopped") return "canceled"; return "running"; diff --git a/packages/server/src/server/agent/providers/pi/rpc-types.ts b/packages/server/src/server/agent/providers/pi/rpc-types.ts index cfa09daeb6..3e488c028c 100644 --- a/packages/server/src/server/agent/providers/pi/rpc-types.ts +++ b/packages/server/src/server/agent/providers/pi/rpc-types.ts @@ -160,6 +160,7 @@ export type PiAssistantMessageEvent = | { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" }; export type PiAgentSessionEvent = + | { type: "thinking_level_changed"; level: PiThinkingLevel } | { type: "agent_start" } | { type: "turn_start" } | { type: "message_start"; message: PiAgentMessage } diff --git a/packages/server/src/server/agent/providers/question-card-answer.test.ts b/packages/server/src/server/agent/providers/question-card-answer.test.ts new file mode 100644 index 0000000000..c7049fa7dc --- /dev/null +++ b/packages/server/src/server/agent/providers/question-card-answer.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; + +import { splitMultiSelectAnswer } from "./question-card-answer.js"; + +describe("splitMultiSelectAnswer", () => { + const labels = ["Delete both", "Delete both, fix code", "Email the user"]; + + test("splits checked labels in click order", () => { + expect(splitMultiSelectAnswer("Email the user, Delete both", labels)).toEqual({ + selected: ["Email the user", "Delete both"], + custom: null, + }); + }); + + test("keeps a label that contains a comma as one selection", () => { + expect(splitMultiSelectAnswer("Delete both, fix code, Email the user", labels)).toEqual({ + selected: ["Delete both, fix code", "Email the user"], + custom: null, + }); + }); + + test("returns trailing text as the typed answer", () => { + expect(splitMultiSelectAnswer("Delete both, Also check the logs", labels)).toEqual({ + selected: ["Delete both"], + custom: "Also check the logs", + }); + expect(splitMultiSelectAnswer("Only typed text", labels)).toEqual({ + selected: [], + custom: "Only typed text", + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/question-card-answer.ts b/packages/server/src/server/agent/providers/question-card-answer.ts new file mode 100644 index 0000000000..01513869c9 --- /dev/null +++ b/packages/server/src/server/agent/providers/question-card-answer.ts @@ -0,0 +1,23 @@ +/** + * The shared question card sends a multi-select answer as one string: the checked option + * labels in click order, then any typed answer, joined with ", ". Consume exact option + * labels from the front, longest first so a label containing ", " wins over its prefix; + * any remaining text is the typed answer. + */ +export function splitMultiSelectAnswer( + answer: string, + labels: string[], +): { selected: string[]; custom: string | null } { + let remaining = answer; + const selected: string[] = []; + while (remaining.length > 0) { + const label = labels + .filter((candidate) => !selected.includes(candidate)) + .sort((left, right) => right.length - left.length) + .find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `)); + if (!label) break; + selected.push(label); + remaining = remaining === label ? "" : remaining.slice(label.length + 2); + } + return { selected, custom: remaining || null }; +} diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index d8128a6a2b..1f72cdc498 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -87,7 +87,8 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail) return incoming; } -function mergeToolCallItems( +/** Fold a later update for the same tool call into the one it follows. */ +export function mergeToolCallItems( existing: Extract<AgentTimelineItem, { type: "tool_call" }>, incoming: Extract<AgentTimelineItem, { type: "tool_call" }>, ): Extract<AgentTimelineItem, { type: "tool_call" }> { diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 07f9f21098..dae9c16f4f 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,4 @@ +import { AgentDirectory } from "../directory.js"; import { stat } from "node:fs/promises"; import { z } from "zod"; import { ensureValidJson } from "../../json-utils.js"; @@ -59,9 +60,9 @@ import { sanitizePermissionRequest, serializeSnapshotWithMetadata, toScheduleSummary, - waitForAgentWithTimeout, } from "../mcp-shared.js"; import { + type PromptDispatchDisposition, sendPromptToAgent, setupFinishNotification, waitForAgentRunStartWithTimeout, @@ -111,7 +112,7 @@ export interface PaseoToolHostDependencies { github?: ForgeService; workspaceGitService?: Pick< WorkspaceGitService, - "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" | "invalidateWorktrees" >; findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; @@ -123,6 +124,7 @@ export interface PaseoToolHostDependencies { cwd: string, title?: string | null, projectId?: string, + context?: { background?: boolean; callerWorkspaceId?: string }, ) => Promise<PersistedWorkspaceRecord>; workspaceScripts?: Pick<WorkspaceScriptsService, "list" | "launch" | "stop">; markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; @@ -132,6 +134,7 @@ export interface PaseoToolHostDependencies { ensureWorkspaceForCreate?: ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ) => Promise<string>; browserToolsEnabled?: boolean; browserToolsBroker?: BrowserToolsBroker | null; @@ -189,6 +192,7 @@ const WorkspaceAutomationSummarySchema = z.object({ isolation: z.enum(["local", "worktree"]), kind: z.enum(["directory", "local_checkout", "worktree"]), title: z.string().nullable(), + background: z.boolean().optional(), }); function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { @@ -199,6 +203,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { isolation: workspace.kind === "worktree" ? ("worktree" as const) : ("local" as const), kind: workspace.kind, title: workspace.title, + background: workspace.background, }; } @@ -1047,13 +1052,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const canonicalTopLevelInputSchema = { ...canonicalCreateAgentFields, - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), notifyOnFinish: z .boolean() .optional() @@ -1071,7 +1069,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...commonCreateAgentFields, relationship: legacyCreateAgentPlacementFields.relationship.optional(), workspace: legacyCreateAgentPlacementFields.workspace.optional(), - background: canonicalTopLevelInputSchema.background, notifyOnFinish: canonicalTopLevelInputSchema.notifyOnFinish, cwd: z .string() @@ -1129,13 +1126,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const agentToAgentSendAgentPromptInputSchema = { ...commonSendAgentPromptInputSchema, - background: z - .boolean() - .optional() - .default(true) - .describe( - "Run agent in background. Agent-scoped default is true so you can continue until the finish notification arrives. Set false only when you need a blocking response.", - ), notifyOnFinish: z .boolean() .optional() @@ -1146,13 +1136,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const topLevelSendAgentPromptInputSchema = { ...commonSendAgentPromptInputSchema, - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), notifyOnFinish: z .boolean() .optional() @@ -1249,6 +1232,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ), projectId: z.string().optional().describe("Existing project id to own the workspace."), title: z.string().trim().min(1).optional(), + background: z + .boolean() + .optional() + .describe( + "True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks.", + ), mode: z .enum(["branch-off", "checkout-branch", "checkout-pr"]) .optional() @@ -1287,6 +1276,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase path, projectId, title, + background, mode, worktreeSlug, branchName, @@ -1316,7 +1306,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (!options.createDirectoryWorkspace) { throw new Error("Workspace provisioning is not configured"); } - workspace = await options.createDirectoryWorkspace(cwd, title, projectId); + workspace = await options.createDirectoryWorkspace(cwd, title, projectId, { + background, + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }); } else { let cwd = path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null; @@ -1347,6 +1340,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(worktreeSlug ? { worktreeSlug } : {}), ...worktreeTarget, ...(title ? { title } : {}), + background, + callerWorkspaceId: resolveCallerAgent()?.workspaceId, }, ); if (!result.ok) { @@ -1367,15 +1362,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { title: "List workspaces", description: "List active workspaces.", - inputSchema: {}, + inputSchema: { includeBackground: z.boolean().optional().default(false) }, outputSchema: { workspaces: z.array(WorkspaceAutomationSummarySchema) }, }, - async () => { + async ({ includeBackground = false }) => { if (!options.workspaceRegistry) { throw new Error("Workspace registry is not configured"); } const workspaces = (await options.workspaceRegistry.list()) - .filter((workspace) => !workspace.archivedAt) + .filter( + (workspace) => !workspace.archivedAt && (includeBackground || !workspace.background), + ) .map(toWorkspaceAutomationSummary); return { content: [], @@ -1442,30 +1439,21 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase workspaceId: z.string().optional(), currentModeId: z.string().nullable(), availableModes: z.array(ProviderModeSchema), - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), guidance: z.string().optional(), }, }, async (args: unknown) => { const resolvedArgs = await resolveCreateAgentToolArgs(args); const { parsedArgs, worktree } = resolvedArgs; - let requestedBackground: boolean; let notifyOnFinish: boolean; if (resolvedArgs.kind === "agent-scoped") { - requestedBackground = true; notifyOnFinish = parsedArgs.notifyOnFinish; } else { - requestedBackground = resolvedArgs.parsedArgs.background; notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; } const selectedProvider = resolveRequiredProviderModel(parsedArgs.provider).provider; const inheritedConfig = resolveInheritedProviderConfig(selectedProvider); - const { - snapshot, - background: createdInBackground, - initialPromptStarted, - } = await createAgentCommand( + const { snapshot, initialPromptStarted } = await createAgentCommand( { agentManager, agentStorage, @@ -1491,7 +1479,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase features: parsedArgs.settings?.features, labels: parsedArgs.labels, mode: parsedArgs.settings?.modeId, - background: requestedBackground, + promptFailure: "throw", notifyOnFinish, detached: resolvedArgs.detached, callerAgentId, @@ -1500,43 +1488,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); - try { - if (!createdInBackground && initialPromptStarted) { - const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { - waitForActive: true, - }); - - const liveSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; - const responseData = { - agentId: snapshot.id, - type: snapshot.provider, - status: result.status, - cwd: liveSnapshot.cwd, - ...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}), - currentModeId: liveSnapshot.currentModeId, - availableModes: liveSnapshot.availableModes, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } - } catch (error) { - childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); - throw error; - } - // Return immediately for async creation. const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; const guidance = callerAgentId && notifyOnFinish && initialPromptStarted ? "You will get notified when the created agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives." - : undefined; + : "Creation acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs."; const response = { content: [], structuredContent: ensureValidJson({ @@ -1547,8 +1504,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}), currentModeId: currentSnapshot.currentModeId, availableModes: currentSnapshot.availableModes, - lastMessage: null, - permission: null, ...(guidance ? { guidance } : {}), }), }; @@ -1682,7 +1637,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const cwd = process.cwd(); return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }), }; } const caller = resolveCallerAgent(); @@ -1850,7 +1807,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }), worktree: undefined, }; } @@ -1897,88 +1856,62 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { title: "Send agent prompt", description: - "Send a task to a running agent. Agent-scoped callers run in background by default; top-level callers wait by default.", + "Send a task and return after dispatch acknowledgement. Calling agents receive finish notifications by default.", inputSchema: sendAgentPromptInputSchema, outputSchema: { success: z.boolean(), + disposition: z.enum(["turn_started", "out_of_band"]).optional(), status: AgentStatusEnum, - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), guidance: z.string().optional(), }, }, - async ({ - agentId, - prompt, - sessionMode, - background = Boolean(callerAgentId), - notifyOnFinish = Boolean(callerAgentId), - }) => { - function armFinishNotification(): boolean { + async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => { + let stopNotification: (() => void) | undefined; + function armFinishNotification(disposition: PromptDispatchDisposition): void { if (!callerAgentId || !notifyOnFinish) { - return false; + return; } - setupFinishNotification({ + stopNotification = setupFinishNotification({ agentManager, agentStorage, childAgentId: agentId, callerAgentId, + waitForTurnStart: disposition === "turn_started", logger: childLogger, }); - return true; } + const notifying = Boolean(callerAgentId && notifyOnFinish); const { disposition } = await sendPromptToAgent({ agentManager, agentStorage, agentId, prompt, + source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined, sessionMode, + onDispatch: armFinishNotification, logger: childLogger, + }).catch((error) => { + stopNotification?.(); + throw error; }); - // If not running in background, wait for completion - if (!background) { - const result = await waitForAgentWithTimeout(agentManager, agentId, { - waitForActive: true, - }); - // The wait ran out while the agent keeps working, so its result arrives as a - // finish notification instead of in this response. - const notifying = - result.timedOut && - agentManager.getAgent(agentId)?.lifecycle === "running" && - armFinishNotification(); - - const responseData = { - success: true, - status: result.status, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - ...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } - - const notifying = armFinishNotification(); - // Return once the provider has accepted the turn, so the status reports it running. if (disposition === "turn_started") { - await waitForAgentRunStartWithTimeout(agentManager, agentId); + await waitForAgentRunStartWithTimeout(agentManager, agentId).catch((error) => { + stopNotification?.(); + throw error; + }); } const currentSnapshot = agentManager.getAgent(agentId); const responseData = { success: true, + disposition, status: currentSnapshot?.lifecycle ?? "idle", - lastMessage: null, - permission: null, - ...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}), + guidance: notifying + ? PROMPTED_AGENT_NOTIFICATION_GUIDANCE + : "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.", }; const validJson = ensureValidJson(responseData); @@ -2046,6 +1979,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase title: "List agents", description: "List recent agents as compact metadata.", inputSchema: { + includeBackground: z.boolean().optional().default(false), includeArchived: z.boolean().optional().default(false), cwd: z.string().optional(), sinceHours: z @@ -2062,29 +1996,38 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase agents: z.array(AgentListItemPayloadSchema), }, }, - async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => { + async ({ + includeBackground = false, + includeArchived = false, + cwd, + sinceHours = 48, + statuses, + limit = 50, + }) => { const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined; const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd; const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000; - const liveSnapshots = agentManager.listAgents(); - const liveAgents = await Promise.all( - liveSnapshots.map((snapshot) => + if (!options.workspaceRegistry) throw new Error("Workspace registry is not configured"); + const directory = new AgentDirectory({ + manager: agentManager, + storage: agentStorage, + workspaces: options.workspaceRegistry, + projectLive: (snapshot) => serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), - ), - ); - const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); - const storedRecords = await agentStorage.list(); - const registeredProviderIds = new Set(providerSnapshotManager.listRegisteredProviderIds()); - const storedAgents = storedRecords - .filter((record) => !record.internal && !liveIds.has(record.id)) - .filter((record) => includeArchived || !record.archivedAt) - .filter( - (record) => - includeArchived || isStoredAgentProviderAvailable(record, registeredProviderIds), - ) - .map((record) => buildStoredAgentPayload(record, registeredProviderIds)); - const agents = [...liveAgents, ...storedAgents] + projectStored: (record) => + buildStoredAgentPayload( + record, + new Set(providerSnapshotManager.listRegisteredProviderIds()), + ), + isProviderVisible: () => true, + isStoredProviderAvailable: (record) => + isStoredAgentProviderAvailable( + record, + new Set(providerSnapshotManager.listRegisteredProviderIds()), + ), + }); + const agents = (await directory.list({ includeBackground, includeArchived })) .map(toAgentListItemPayload) .filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd)) .filter((agent) => !statusFilter || statusFilter.has(agent.status)) diff --git a/packages/server/src/server/bootstrap-web-ui.test.ts b/packages/server/src/server/bootstrap-web-ui.test.ts index 3822cfaa55..81afc3c166 100644 --- a/packages/server/src/server/bootstrap-web-ui.test.ts +++ b/packages/server/src/server/bootstrap-web-ui.test.ts @@ -134,6 +134,44 @@ describe("daemon web UI bootstrap", () => { }); }); + test("spells out the scheme default port when the Host header omits it", async () => { + const distDir = await createWebUiDist(); + + daemonHandle = await createTestPaseoDaemon({ + mcpEnabled: false, + webUi: { + enabled: true, + distDir, + }, + }); + + // A browser reaching a proxy that terminates TLS on 443 sends a Host header + // with no port at all. The app rejects a portless `listen`, so the daemon + // has to supply the default itself. + const httpsHint = readInjectedConnectionHint( + await fetchDaemonWebUi({ + port: daemonHandle.port, + headers: { host: "paseo.example.test", "x-forwarded-proto": "https" }, + }), + ); + const httpHint = readInjectedConnectionHint( + await fetchDaemonWebUi({ + port: daemonHandle.port, + headers: { host: "paseo.example.test" }, + }), + ); + const ipv6Hint = readInjectedConnectionHint( + await fetchDaemonWebUi({ + port: daemonHandle.port, + headers: { host: "[::1]", "x-forwarded-proto": "https" }, + }), + ); + + expect(httpsHint.listen).toBe("paseo.example.test:443"); + expect(httpHint.listen).toBe("paseo.example.test:80"); + expect(ipv6Hint.listen).toBe("[::1]:443"); + }); + test("ignores forwarded HTTPS when proxy trust is disabled", async () => { const distDir = await createWebUiDist(); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e63ce89fb3..727c7b7899 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -126,6 +126,7 @@ import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-wo import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { createPaseoWorktreeWorkflow } from "./worktree-session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; +import { formatAttachmentContentDisposition } from "./file-download/content-disposition.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; @@ -414,7 +415,6 @@ export interface PaseoDaemonConfig { plugins?: Record<string, PluginSource>; pluginRegistries?: PluginRegistries; pluginRegistryUrl?: string; - pluginRegistryEnabled?: boolean; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -625,7 +625,6 @@ export async function createPaseoDaemon( usageSession: (id) => agentManager.usageSession(id), }, managedSources: new ManagedPluginSources(config.paseoHome, { - enabled: config.pluginRegistryEnabled ?? false, registries: config.pluginRegistries, defaultUrl: config.pluginRegistryUrl, }), @@ -833,18 +832,21 @@ export async function createPaseoDaemon( try { fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS); const fileStats = await fileHandle.stat(); + if (res.destroyed) { + return; + } if (!fileStats.isFile()) { res.status(404).json({ error: "File not found" }); return; } - const safeFileName = entry.fileName.replace(/["\r\n]/g, "_"); res.setHeader("Content-Type", entry.mimeType); - res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`); + res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName)); res.setHeader("Content-Length", fileStats.size.toString()); const stream = fileHandle.createReadStream(); fileHandle = null; + res.once("close", () => stream.destroy()); stream.on("error", (err) => { logger.error({ err }, "Failed to stream download"); if (!res.headersSent) { @@ -1033,10 +1035,13 @@ export async function createPaseoDaemon( const ensureWorkspaceForCreateExternal = async ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ): Promise<string> => { const workspace = await workspaceProvisioning.createWorkspaceForDirectory( cwd, resolveFirstAgentPromptTitle(firstAgentContext), + undefined, + context, ); if (firstAgentContext) { workspaceAutoName.scheduleForDirectory({ @@ -1083,8 +1088,9 @@ export async function createPaseoDaemon( const ensureWorkspaceForCreateAndBroadcastExternal = async ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ): Promise<string> => { - const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext); + const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext, context); await emitWorkspaceUpdatesExternal([workspaceId]); return workspaceId; }; @@ -1401,11 +1407,12 @@ export async function createPaseoDaemon( emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, workspaceRegistry, projectRegistry, - createDirectoryWorkspace: async (cwd, title, projectId) => { + createDirectoryWorkspace: async (cwd, title, projectId, context) => { const workspace = await workspaceProvisioning.createWorkspaceForDirectory( cwd, title, projectId, + context, ); await emitWorkspaceUpdatesExternal([workspace.workspaceId]); return workspace; @@ -1876,7 +1883,7 @@ export async function createPaseoDaemon( const AGENT_CLOSE_TIMEOUT_MS = 5_000; async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promise<void> { - const agents = agentManager.listAgents(); + const agents = agentManager.listAgents({ includeInternal: true }); await Promise.all( agents.map(async (agent) => { try { diff --git a/packages/server/src/server/cli-agent-logs.test.ts b/packages/server/src/server/cli-agent-logs.test.ts new file mode 100644 index 0000000000..634d0615c0 --- /dev/null +++ b/packages/server/src/server/cli-agent-logs.test.ts @@ -0,0 +1,283 @@ +import { test } from "vitest"; +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import type { Readable } from "node:stream"; +import { join } from "node:path"; +import { createDaemonTestContext } from "./test-utils/daemon-test-context.js"; +import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; +import type { AgentStreamEvent } from "@getpaseo/protocol/agent-types"; + +interface CliResult { + exitCode: number | null; + stdout: string; + stderr: string; +} +type CliProcess = Promise<CliResult> & { stdout: Readable; kill: (signal: NodeJS.Signals) => void }; + +function waitForCliOutput(child: CliProcess, text: string): Promise<void> { + return new Promise((resolve, reject) => { + let output = ""; + const onData = (chunk: Buffer) => { + output += chunk.toString(); + if (output.includes(text)) { + cleanup(); + resolve(); + } + }; + const cleanup = () => { + clearTimeout(timer); + child.stdout.off("data", onData); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`CLI did not print ${text}:\n${output}`)); + }, 20_000); + child.stdout.on("data", onData); + void child.then( + (result) => { + cleanup(); + return reject(new Error(`CLI exited before ${text}:\n${result.stderr}`)); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + +test("logs --since filters history and live output through the CLI and daemon", async () => { + const cliHome = await mkdtemp(join(tmpdir(), "paseo-logs-cli-home-")); + const port = 1; // Invalid dates must fail before trying this unavailable endpoint. + const runLocalPaseo = ( + args: string[], + env: NodeJS.ProcessEnv = {}, + cwd = cliHome, + ): CliProcess => { + const child = spawn( + process.execPath, + [ + "--no-warnings", + "--conditions=source", + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)), + ...args, + ], + { + cwd, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + ...env, + HOME: cliHome, + USERPROFILE: cliHome, + }, + stdio: ["ignore", "pipe", "pipe"], + timeout: 25_000, + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const done = new Promise<CliResult>((resolve, reject) => { + child.on("error", reject); + child.on("close", (exitCode) => resolve({ exitCode, stdout, stderr })); + }); + return Object.assign(done, { + stdout: child.stdout, + kill: (signal: NodeJS.Signals) => { + child.kill(signal); + }, + }); + }; + // Follow assertions check output; Windows forcibly terminates Node on SIGINT. + try { + console.log("--since filters logs through the real CLI and daemon"); + const historyEvents: AgentStreamEvent[] = []; + const ctx = await createDaemonTestContext({ + agentClients: createTestAgentClients({ historyEvents }), + }); + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd: ctx.daemon.paseoHome, + title: "logs since", + }); + const manager = ctx.daemon.daemon.agentManager; + await manager.appendTimelineItem(agent.id, { type: "user_message", text: "OLD_USER" }); + await manager.appendTimelineItem(agent.id, { type: "assistant_message", text: "OLD_REPLY" }); + const host = `127.0.0.1:${ctx.daemon.port}`; + const logs = (args: string[]) => + runLocalPaseo(["--host", host, ...args], {}, ctx.daemon.paseoHome); + + const baseline = await logs(["logs", agent.id]); + assert.strictEqual(baseline.exitCode, 0, baseline.stderr); + assert.strictEqual(baseline.stdout, "[User] OLD_USER\nOLD_REPLY\n"); + + for (const command of [["logs"], ["agent", "logs"]]) { + const result = await logs([...command, agent.id, "--since", "2100-01-01T00:00:00Z"]); + console.log(`paseo ${command.join(" ")} <id> --since 2100-01-01T00:00:00Z`); + console.log(`exit=${result.exitCode}\nstdout:\n${result.stdout}stderr:\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.strictEqual(result.stdout, "No activity to display.\n"); + } + + const timeline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const firstTimestamp = timeline.entries[0]?.timestamp; + assert(firstTimestamp !== undefined, "the fixture should have timestamped activity"); + for (const since of ["2000-01-01T00:00:00Z", firstTimestamp]) { + const result = await logs(["logs", agent.id, "--since", since]); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.strictEqual(result.stdout, baseline.stdout); + } + + const offset = await logs(["logs", agent.id, "--since", "2100-01-01T08:00:00+08:00"]); + assert.strictEqual(offset.exitCode, 0, offset.stderr); + assert.strictEqual(offset.stdout, "No activity to display.\n"); + + const tail = await logs([ + "logs", + agent.id, + "--since", + "2000-01-01T00:00:00Z", + "--filter", + "text", + "--tail", + "1", + ]); + assert.strictEqual(tail.exitCode, 0, tail.stderr); + assert.strictEqual(tail.stdout, "OLD_REPLY\n"); + + const empty = await logs(["logs", agent.id, "--since", firstTimestamp, "--tail", "0"]); + assert.strictEqual(empty.exitCode, 0, empty.stderr); + assert.strictEqual(empty.stdout, ""); + + for (const followArgs of [[], ["--follow"]]) { + const invalid = await runLocalPaseo([ + "--host", + `127.0.0.1:${port}`, + "logs", + "missing-agent", + "--since", + "not-a-date", + "--json", + ...followArgs, + ]); + assert.strictEqual(invalid.exitCode, 1, invalid.stderr); + assert.strictEqual(invalid.stdout, ""); + assert.deepStrictEqual(JSON.parse(invalid.stderr), { + error: { + code: "INVALID_TIMESTAMP", + message: "Invalid --since value: not-a-date", + details: "Use a timestamp such as 2026-01-01T00:00:00Z.", + }, + }); + } + + console.log("--since filters initial history in follow mode"); + const follow = logs(["logs", agent.id, "--follow", "--since", "2100-01-01T00:00:00Z"]); + try { + await waitForCliOutput(follow, "--- Following logs"); + } finally { + await follow.kill("SIGINT"); + } + const followed = await follow; + console.log(`follow exit=${followed.exitCode}\nstdout:\n${followed.stdout}`); + assert.strictEqual(followed.stdout.includes("OLD_USER"), false); + assert.strictEqual(followed.stdout.includes("OLD_REPLY"), false); + + console.log("--since filters live events in follow mode"); + const futureFollow = logs([ + "agent", + "logs", + agent.id, + "-f", + "--tail", + "0", + "--since", + "2100-01-01T00:00:00Z", + ]); + try { + await waitForCliOutput(futureFollow, "--- Following logs"); + // A timestamped history replay follows the live item on the same socket. + // Its visible output proves the CLI consumed the earlier item too. + await Promise.all([ + waitForCliOutput(futureFollow, "AFTER_CUTOFF"), + (async () => { + await manager.appendTimelineItem(agent.id, { + type: "user_message", + text: "LIVE_BEFORE_CUTOFF", + }); + historyEvents.push({ + type: "timeline", + provider: "codex", + timestamp: "2110-01-01T00:00:00Z", + item: { type: "user_message", text: "AFTER_CUTOFF" }, + }); + await ctx.client.refreshAgent(agent.id); + })(), + ]); + } finally { + await futureFollow.kill("SIGINT"); + } + const futureOutput = await futureFollow; + console.log(`future follow exit=${futureOutput.exitCode}\nstdout:\n${futureOutput.stdout}`); + assert.strictEqual(futureOutput.stdout.includes("LIVE_BEFORE_CUTOFF"), false); + + console.log("follow still prints live events after --since"); + const pastFollow = logs([ + "logs", + agent.id, + "--follow", + "--tail", + "0", + "--filter", + "text", + "--since", + "2000-01-01T00:00:00Z", + ]); + try { + await waitForCliOutput(pastFollow, "--- Following logs"); + await Promise.all([ + // Wait for a later visible item to close the streamed reply. + // Windows SIGINT forcibly exits without running shutdown handlers. + waitForCliOutput(pastFollow, "[User] LIVE_AFTER_REPLY\n"), + (async () => { + await manager.appendTimelineItem(agent.id, { type: "user_message", text: "LIVE_USER" }); + await manager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: "LIVE_REPLY", + }); + await manager.appendTimelineItem(agent.id, { + type: "user_message", + text: "LIVE_AFTER_REPLY", + }); + })(), + ]); + } finally { + await pastFollow.kill("SIGINT"); + } + const pastOutput = await pastFollow; + console.log(`past follow exit=${pastOutput.exitCode}\nstdout:\n${pastOutput.stdout}`); + assert(pastOutput.stdout.includes("[User] LIVE_USER\nLIVE_REPLY\n"), pastOutput.stdout); + } finally { + await ctx.cleanup(); + } + } finally { + await rm(cliHome, { recursive: true, force: true }); + } +}, 120_000); diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts index 2442ee5dc5..24134035de 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -30,7 +30,6 @@ export const DAEMON_SETTING_ENV_KEYS = [ "PASEO_LOG_ROTATE_SIZE", "PASEO_PASSWORD", "PASEO_PLUGIN_REGISTRY", - "PASEO_PLUGIN_REGISTRY_ENABLED", "PASEO_RELAY_ENABLED", "PASEO_RELAY_ENDPOINT", "PASEO_RELAY_PUBLIC_ENDPOINT", diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 02a315c9a5..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -211,15 +211,6 @@ test("loads private plugin registry settings through the configuration boundary" }); expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); expect(config.pluginRegistries).toEqual(pluginRegistries); - expect(config.pluginRegistryEnabled).toBe(false); - await writeFile( - path.join(home, "config.json"), - JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }), - ); - expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true); - expect( - loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled, - ).toBe(false); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 61481ce1f9..7b2d06560e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -628,10 +628,6 @@ export function resolveConfigFromPersisted( plugins: persisted.plugins, pluginRegistries: persisted.pluginRegistries, pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, - pluginRegistryEnabled: - parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ?? - persisted.pluginRegistryEnabled ?? - false, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/creation/creation.e2e.test.ts b/packages/server/src/server/creation/creation.e2e.test.ts index f0d21be18f..f4d35bd3f3 100644 --- a/packages/server/src/server/creation/creation.e2e.test.ts +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import type { z } from "zod"; +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import { WebSocket } from "ws"; import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; import { randomUUID } from "node:crypto"; @@ -9,10 +10,23 @@ import { join } from "node:path"; import { expect, test, vi } from "vitest"; import pino from "pino"; import type { CreationSnapshot, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +function createCreationDaemon(options: Parameters<typeof createTestPaseoDaemon>[0] = {}) { + const agentClients = options.agentClients ?? createTestAgentClients(); + return createTestPaseoDaemon({ + ...options, + agentClients, + // Installed provider CLIs must not change when background naming starts. + providerOverrides: Object.fromEntries( + BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: id in agentClients }]), + ), + }); +} + function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { @@ -29,7 +43,7 @@ test("creation progresses before agent readiness and continues after the disconn const directory = await mkdtemp(join(tmpdir(), "creation-wire-")); let agents = 0; let prompts = 0; - const daemon = await createTestPaseoDaemon({ + const daemon = await createCreationDaemon({ logger: pino( { level: "trace" }, { @@ -41,11 +55,13 @@ test("creation progresses before agent readiness and continues after the disconn }, ), agentClients: createTestAgentClients({ - beforeCreateSession: async () => { + beforeCreateSession: async (config) => { + if (config.internal) return; agents++; await provider.promise; }, - onStartTurn: () => { + onStartTurn: (_prompt, config) => { + if (config.internal) return; prompts++; }, }), @@ -122,11 +138,12 @@ test("creation progresses before agent readiness and continues after the disconn await client.close(); await observer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000); async function connectCreationPeer(port: number) { + const timeout = 30_000; const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); const frames: SessionOutboundMessage[] = []; socket.on("message", (data) => { @@ -147,7 +164,9 @@ async function connectCreationPeer(port: number) { }), ); await expect - .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info")) + .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info"), { + timeout, + }) .toBe(true); return { close: () => socket.close(), @@ -160,7 +179,7 @@ async function connectCreationPeer(port: number) { (m) => "payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId, ); - await expect.poll(response).toBeDefined(); + await expect.poll(response, { timeout }).toBeDefined(); return response()!; }, }; @@ -170,7 +189,7 @@ test.each([false, true])( "workspace identity does not depend on subscribing (first subscribe=%s)", async (subscribe) => { const directory = await mkdtemp(join(tmpdir(), "creation-identity-")); - const daemon = await createTestPaseoDaemon(); + const daemon = await createCreationDaemon(); const peer = await connectCreationPeer(daemon.port); try { const request = { @@ -188,7 +207,7 @@ test.each([false, true])( } finally { peer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000, @@ -199,10 +218,13 @@ test.each(["create_agent_request", "agent.create.request"] as const)( async (type) => { const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-")); let creations = 0; - const daemon = await createTestPaseoDaemon({ + const daemon = await createCreationDaemon({ agentClients: createTestAgentClients({ - beforeCreateSession: async () => { + beforeCreateSession: async (config) => { + if (config.internal) return; creations++; + // A real provider or Git checkout may take longer than Vitest's default 1s poll budget. + await new Promise((resolve) => setTimeout(resolve, 1_200)); }, }), }); @@ -241,7 +263,7 @@ test.each(["create_agent_request", "agent.create.request"] as const)( } finally { peer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000, @@ -264,7 +286,7 @@ test("legacy keyed creation preserves checkout error codes", async () => { ], { cwd: directory, stdio: "pipe" }, ); - const daemon = await createTestPaseoDaemon({ agentClients: createTestAgentClients() }); + const daemon = await createCreationDaemon({ agentClients: createTestAgentClients() }); const peer = await connectCreationPeer(daemon.port); try { const result = await peer.request({ @@ -281,6 +303,95 @@ test("legacy keyed creation preserves checkout error codes", async () => { } finally { peer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000); + +test.each(["agent", "workspace"] as const)( + "%s creation succeeds when the provider rejects its initial prompt", + async (kind) => { + const directory = await mkdtemp(join(tmpdir(), "creation-prompt-rejected-")); + const rejection = "Input exceeds the maximum length of 1048576 characters."; + let sessions = 0; + let prompts = 0; + const daemon = await createCreationDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async (config) => { + if (config.internal) return; + sessions++; + }, + onStartTurn: (_prompt, config) => { + if (config.internal) return; + prompts++; + throw new Error(rejection); + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.1", + }); + const snapshots: CreationSnapshot[] = []; + const agent = { + config: { provider: "codex", cwd: directory }, + initialPrompt: "Continue the attached conversation.", + clientMessageId: "draft:initial-message", + }; + const streamed: AgentTimelineItem[] = []; + const unsubscribe = daemon.daemon.agentManager.subscribe((event) => { + if (event.type === "agent_stream" && event.event.type === "timeline") { + streamed.push(event.event.item); + } + }); + try { + await client.connect(); + const submit = async () => { + const common = { + idempotencyKey: "rejected-first-prompt", + onEvent: (snapshot: CreationSnapshot) => snapshots.push(snapshot), + }; + if (kind === "agent") return client.createAgent({ ...common, ...agent }); + const result = await client.createWorkspace({ + ...common, + source: { kind: "directory", path: directory }, + agent, + }); + expect(result.error).toBeNull(); + return result.agent!; + }; + const created = await submit(); + expect(created).toMatchObject({ status: "error", lastError: rejection }); + expect(snapshots.map((snapshot) => snapshot.phase)).not.toContain("prompt_started"); + expect(snapshots.at(-1)).toMatchObject({ + phase: "completed", + error: null, + agent: { id: created.id }, + }); + const expectedTimeline = [ + { + type: "user_message", + text: agent.initialPrompt, + clientMessageId: agent.clientMessageId, + messageId: agent.clientMessageId, + }, + { type: "assistant_message", text: `[System Error] ${rejection}` }, + ]; + expect( + (await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item), + ).toEqual(expectedTimeline); + expect(streamed).toEqual(expectedTimeline); + expect((await submit()).id).toBe(created.id); + expect( + (await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item), + ).toEqual(expectedTimeline); + expect((await client.fetchAgents()).entries).toHaveLength(1); + expect({ sessions, prompts }).toEqual({ sessions: 1, prompts: 1 }); + } finally { + unsubscribe(); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + }, + 60000, +); diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index 1eaf4a7fd9..a165110ef8 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -93,7 +93,7 @@ async function fixture() { calls.push("agent"); await onReady(agent); calls.push("prompt"); - return agent; + return { agent, initialPromptStarted: true }; }, }; const updates: CreationSnapshot[] = []; @@ -129,7 +129,7 @@ test("a failed agent startup retries only that stage with the reserved IDs", asy expect(readyWorkspace).toEqual(workspace); if (attempts === 1) throw new Error("provider unavailable"); await onReady(agent); - return agent; + return { agent, initialPromptStarted: true }; }; expect(await f.service.create(f.input)).toMatchObject({ phase: "failed", @@ -210,7 +210,7 @@ test("omitted resource IDs are generated once and preserved on replay", async () f.input.createAgent = async (id, createdWorkspace, onReady) => { const created = { ...agent, id, workspaceId: createdWorkspace!.id }; await onReady(created); - return created; + return { agent: created, initialPromptStarted: true }; }; const result = await f.service.create(f.input); expect(result.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/); @@ -231,7 +231,7 @@ test("restart after a committed agent without a prompt completes without recreat await onReady(agent); // Capture the durable state at the exact milestone a process could stop after. await cp(f.directory, recoveredDirectory, { recursive: true }); - return agent; + return { agent, initialPromptStarted: false }; }; await f.service.create(f.input); const recovered = await new CreationService(recoveredDirectory, silentLogger).create(f.input); diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts index 3d7a42efdb..e0b28b8069 100644 --- a/packages/server/src/server/creation/index.ts +++ b/packages/server/src/server/creation/index.ts @@ -13,6 +13,10 @@ import { writeFileAtomic } from "../atomic-file.js"; import { generateWorkspaceId } from "../workspace-registry-model.js"; type Observer = (snapshot: CreationSnapshot) => void; +export interface CreatedAgent { + agent: AgentSnapshotPayload; + initialPromptStarted: boolean; +} interface CreationRequest { key: string; request: { [key: string]: unknown }; @@ -28,7 +32,7 @@ interface CreationRequest { agentId: string, workspace: WorkspaceDescriptorPayload | undefined, onReady: (agent: AgentSnapshotPayload) => Promise<void>, - ) => Promise<AgentSnapshotPayload>; + ) => Promise<CreatedAgent>; } export type CreationInput = CreationRequest & ( @@ -167,7 +171,7 @@ export class CreationService { if (input.hasAgent && input.createAgent && !record.snapshot.agent) { record.inFlight = "agent"; await this.write(identity, record); - const agent = await input.createAgent( + const { agent, initialPromptStarted } = await input.createAgent( record.snapshot.agentId!, record.snapshot.workspace, async (readyAgent) => { @@ -180,7 +184,7 @@ export class CreationService { }, ); record.inFlight = null; - if (input.hasPrompt) + if (initialPromptStarted) await this.publish(identity, record, { phase: "prompt_started", agent }); else record.snapshot = { ...record.snapshot, agent }; } diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index f429cb672c..b6518d773d 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -157,15 +157,19 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => { chunkSize: 5, }); + const uploadId = result.file?.id ?? ""; + expect(uploadId).toMatch( + /^upload_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); expect(result).toEqual({ requestId: "req-upload-e2e", file: { type: "uploaded_file", - id: "upload_req-upload-e2e", + id: uploadId, fileName: "notes.txt", mimeType: "text/plain", size: 11, - path: path.join(daemon.paseoHome, "uploads", "upload_req-upload-e2e", "notes.txt"), + path: path.join(daemon.paseoHome, "uploads", uploadId, "notes.txt"), }, error: null, }); @@ -831,6 +835,29 @@ test("send_agent_message auto-unarchives archived agents", async () => { } }, 180000); +test("send_agent_message leaves an archived agent archived when its directory is gone", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + const archived = await ctx.client.archiveAgent(created.id); + rmSync(cwd, { recursive: true, force: true }); + + await expect(ctx.client.sendMessage(created.id, "hello")).rejects.toThrow( + "Working directory does not exist", + ); + + const afterSend = await ctx.client.fetchAgent({ agentId: created.id }); + expect(afterSend?.agent.archivedAt).toBe(archived.archivedAt); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("refresh_agent auto-unarchives archived agents", async () => { const cwd = tmpCwd(); try { diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts new file mode 100644 index 0000000000..75302d4168 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -0,0 +1,160 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); +} + +test.each(["uncommitted", "base", "commit"] as const)( + "%s diff preserves removed SQL comments and their deletion counts", + async (mode) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-content-")); + + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "query.sql"), "-- old comment\nSELECT 1;\n"); + writeFileSync(join(cwd, "control.sql"), "SELECT 1;\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + writeFileSync(join(cwd, "query.sql"), "-- new comment\nSELECT 1;\n"); + writeFileSync(join(cwd, "control.sql"), "SELECT 2;\n"); + + expect(git(cwd, "diff", "--numstat", "HEAD", "--", "query.sql").trim()).toBe( + "1\t1\tquery.sql", + ); + + if (mode !== "uncommitted") { + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + } + + const files = + mode === "commit" + ? [ + ( + await ctx.client.getCommitFileDiff( + cwd, + git(cwd, "rev-parse", "HEAD").trim(), + "query.sql", + ) + ).file, + ( + await ctx.client.getCommitFileDiff( + cwd, + git(cwd, "rev-parse", "HEAD").trim(), + "control.sql", + ) + ).file, + ] + : (await ctx.client.getCheckoutDiff(cwd, { mode, baseRef: "main" })).files; + + const control = files.find((file) => file?.path === "control.sql"); + expect(control).toMatchObject({ additions: 1, deletions: 1 }); + expect(control?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "SELECT 1;" }), + expect.objectContaining({ type: "add", content: "SELECT 2;" }), + ]), + ); + + const query = files.find((file) => file?.path === "query.sql"); + expect + .soft(query) + .toMatchObject({ additions: 1, deletions: 1, isNew: false, isDeleted: false }); + expect + .soft(query?.hunks.flatMap((hunk) => hunk.lines)) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "-- old comment" }), + expect.objectContaining({ type: "add", content: "-- new comment" }), + expect.objectContaining({ type: "context", content: "SELECT 1;" }), + ]), + ); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +); + +test.each( + (["uncommitted", "base"] as const).flatMap((mode) => + (["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })), + ), +)( + "$mode $shape diff includes hunks from a repository subdirectory", + async ({ mode, shape }) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-")); + + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "core.autocrlf", "false"); + const subdirectory = join(cwd, "sub"); + mkdirSync(subdirectory); + const unchangedLines = "unchanged\n".repeat(10); + writeFileSync(join(subdirectory, "normal.txt"), `before\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "old sibling\n"); + } + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + if (mode === "base") { + git(cwd, "checkout", "-b", "feature"); + } + const filename = shape === "rename" ? "renamed.txt" : "normal.txt"; + if (shape === "rename") { + git(cwd, "mv", "sub/normal.txt", `sub/${filename}`); + } + writeFileSync(join(subdirectory, filename), `after\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "new sibling\n"); + } + if (mode === "base") { + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + } + + const compare = { mode, baseRef: "main" }; + const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); + const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); + const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); + const liveDiff = await subscription.ready; + await subscription.release(); + + expect(rootDiff.error).toBeNull(); + expect(subdirectoryDiff.error).toBeNull(); + expect(liveDiff.error).toBeNull(); + expect(liveDiff.files).toEqual(rootDiff.files); + expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1); + const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`); + expect(file).toMatchObject({ + additions: 1, + deletions: 1, + ...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}), + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + expect(subdirectoryDiff.files).toEqual(rootDiff.files); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts new file mode 100644 index 0000000000..ad703a3885 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +// Claude Code resumes a session by id from any working directory and keeps writing to the +// transcript under the project folder it started in. The agent's cwd can name a different +// folder: a project that moved, a daemon migrated between machines or containers, or Claude +// Code running under WSL interop and seeing the cwd as a Windows path. +// The test daemon fakes the built-in providers; a provider that extends `claude` runs the real one. +const PROVIDER_ID = "claude-real"; +const SESSION_ID = "6f0c5d8e-4c3a-4f7b-9a51-2d8e1b7c9f10"; + +function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string { + return entries + .filter( + ( + entry, + ): entry is { + item: { type: "user_message" | "assistant_message"; text: string }; + } => entry.item.type === "user_message" || entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); +} + +describe("daemon E2E - Claude history stored under another project folder", () => { + let tempRoot: string; + let paseoHomeRoot: string; + let configDir: string; + let transcriptCwd: string; + let agentCwd: string; + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-history-other-project-")); + paseoHomeRoot = path.join(tempRoot, "paseo-home"); + configDir = path.join(tempRoot, "claude-config"); + transcriptCwd = path.join(tempRoot, "where-claude-started"); + agentCwd = path.join(tempRoot, "agent-cwd"); + mkdirSync(paseoHomeRoot, { recursive: true }); + mkdirSync(agentCwd, { recursive: true }); + + const projectDir = claudeProjectDirSync(transcriptCwd, { configDir }); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + path.join(projectDir, `${SESSION_ID}.jsonl`), + [ + { + type: "user", + uuid: "user-uuid-1", + sessionId: SESSION_ID, + cwd: transcriptCwd, + message: { role: "user", content: "hello from the original folder" }, + }, + { + type: "assistant", + sessionId: SESSION_ID, + cwd: transcriptCwd, + message: { + role: "assistant", + content: "reply from the original folder", + }, + }, + { type: "custom-title", customTitle: "Renamed moved session", sessionId: SESSION_ID }, + ] + .map((entry) => `${JSON.stringify(entry)}\n`) + .join(""), + "utf8", + ); + }); + + afterEach(async () => { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + client = undefined; + daemon = undefined; + rmSync(tempRoot, { recursive: true, force: true }); + }, 60_000); + + async function startDaemon(): Promise<DaemonClient> { + daemon = await createTestPaseoDaemon({ + paseoHomeRoot, + cleanup: false, + providerOverrides: { + [PROVIDER_ID]: { + extends: "claude", + label: "Claude", + env: { CLAUDE_CONFIG_DIR: configDir }, + }, + }, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return client; + } + + async function stopDaemon(): Promise<void> { + await client?.close(); + await daemon?.close(); + client = undefined; + daemon = undefined; + } + + test("an agent's timeline survives a daemon restart", async () => { + const firstClient = await startDaemon(); + const agent = await firstClient.importAgent({ + provider: PROVIDER_ID, + sessionId: SESSION_ID, + cwd: agentCwd, + }); + expect(agent.title).toBe("Renamed moved session"); + await stopDaemon(); + + const restartedClient = await startDaemon(); + const timeline = await restartedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + + const text = timelineText(timeline.entries); + expect(text).toContain("hello from the original folder"); + expect(text).toContain("reply from the original folder"); + }, 60_000); +}); diff --git a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts index 29779fd1ef..1715fabec9 100644 --- a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts @@ -55,6 +55,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR" cwd, message: { role: "assistant", content: "reply from the custom config dir" }, }, + { type: "custom-title", customTitle: "Renamed provider session", sessionId: SESSION_ID }, ] .map((entry) => `${JSON.stringify(entry)}\n`) .join(""), @@ -111,6 +112,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR" sessionId: SESSION_ID, cwd, }); + expect(agent.title).toBe("Renamed provider session"); await stopDaemon(); const restartedClient = await startDaemon(); diff --git a/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts new file mode 100644 index 0000000000..d8388f9dd2 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts @@ -0,0 +1,89 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +// A real daemon and CLI with a deterministic provider emitting two text chunks. +// The live Claude reproduction is recorded in the PR; this locks its CLI behavior. +test("logs --follow preserves the same assistant text as one-shot logs", async () => { + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + let follow: ReturnType<typeof spawn> | undefined; + let stdout = ""; + let stderr = ""; + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ + provider: "claude", + cwd: daemon.paseoHome, + title: "Follow transcript regression", + modeId: "bypassPermissions", + }); + follow = spawn( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${daemon.port}`, + "logs", + "--follow", + agent.id, + "--tail", + "0", + "--filter", + "assistant_message", + ], + { + env: { ...process.env, PASEO_HOME: daemon.paseoHome }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const exited = once(follow, "exit"); + follow.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + follow.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + await expect + .poll( + () => { + if (follow?.exitCode !== null) { + throw new Error(`CLI exited before following logs: ${stderr}`); + } + return stdout; + }, + { timeout: 20_000 }, + ) + .toContain("Following logs"); + await client.sendMessage(agent.id, "respond with exactly: paseo-pr-cycle 한국어 보고서"); + await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle", 20_000); + const transcript = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 100, + projection: "projected", + }); + const assistantText = transcript.entries + .filter((row) => row.item.type === "assistant_message") + .map((row) => (row.item.type === "assistant_message" ? row.item.text : "")) + .join(""); + expect(assistantText).not.toBe(""); + // Completion closes the message while follow mode is still running. + // Then stop through the CLI's normal signal path. + await expect.poll(() => stdout, { timeout: 10_000 }).toContain(`${assistantText.trim()}\n`); + follow.kill("SIGINT"); + await exited; + const output = stdout.slice(stdout.indexOf("---\n") + 4).trim(); + expect(output, stderr).toBe(assistantText.trim()); + } finally { + follow?.kill("SIGTERM"); + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index f9d7fce7b4..862923dec6 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -1,5 +1,15 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + rmSync, + readdirSync, + readlinkSync, + realpathSync, + symlinkSync, +} from "fs"; +import { once } from "node:events"; +import { get } from "node:http"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -8,6 +18,41 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } +// Linux exposes the real open descriptors without replacing file or HTTP methods. +function openDownloadDescriptors(filePath: string): string[] { + const realFilePath = realpathSync(filePath); + return readdirSync("/proc/self/fd").filter((fd) => { + try { + return readlinkSync(`/proc/self/fd/${fd}`) === realFilePath; + } catch (error) { + // A descriptor can close between listing it and reading its link. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }); +} + +async function expectDownloadClosed(filePath: string): Promise<void> { + await expect.poll(() => openDownloadDescriptors(filePath)).toEqual([]); +} + +async function cancelDownloadAfterFirstChunk(url: string): Promise<void> { + const request = get(url); + try { + const [response] = await once(request, "response"); + expect(response.statusCode).toBe(200); + const [chunk] = await once(response, "data"); + expect(chunk.length).toBeGreaterThan(0); + expect(chunk.length).toBeLessThan(64 * 1024 * 1024); + const closed = once(response, "close"); + response.destroy(); + request.destroy(); + await closed; + } finally { + request.destroy(); + } +} + // Use gpt-5.4-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; @@ -24,42 +69,110 @@ describe("daemon E2E", () => { }, 60000); describe("file download tokens", () => { - test("issues token over WS and downloads via HTTP", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "download.txt"); - const fileContents = "download test payload"; - writeFileSync(filePath, fileContents, "utf-8"); + test.skipIf(process.platform !== "linux")( + "releases file descriptors after repeated HTTP download cancellations", + async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "linked-files"); + symlinkSync(mkdtempSync(path.join(directory, "files-")), cwd, "dir"); + const filePath = path.join(cwd, "large.bin"); + const fileSize = 64 * 1024 * 1024; + writeFileSync(filePath, Buffer.alloc(fileSize)); + try { + const completedToken = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(completedToken.error).toBeNull(); + const completed = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${completedToken.token}`, + ); + expect(completed.status).toBe(200); + expect((await completed.arrayBuffer()).byteLength).toBe(fileSize); + await expectDownloadClosed(filePath); - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Download Token Test Agent", - }); + for (let attempt = 0; attempt < 3; attempt++) { + const token = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(token.error).toBeNull(); + await cancelDownloadAfterFirstChunk( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${token.token}`, + ); + await expectDownloadClosed(filePath); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); - expect(agent.id).toBeTruthy(); + test.each([ + { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "報告書.md", + disposition: + "attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md", + }, + { + fileName: "보고서.txt", + disposition: + "attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + ])( + "issues token over WS and downloads $fileName via HTTP", + async ({ fileName, disposition }) => { + const cwd = tmpCwd(); + try { + const filePath = path.join(cwd, fileName); + const fileContents = "download test payload"; + writeFileSync(filePath, fileContents, "utf-8"); - const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt"); + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Download Token Test Agent", + }); - expect(tokenResponse.error).toBeNull(); - expect(tokenResponse.token).toBeTruthy(); - expect(tokenResponse.fileName).toBe("download.txt"); + expect(agent.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); - const response = await fetch( - `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, - ); + const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); - const disposition = response.headers.get("content-disposition") ?? ""; - expect(disposition).toContain("download.txt"); + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(tokenResponse.fileName).toBe(fileName); - const body = await response.text(); - expect(body).toBe(fileContents); + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, + ); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); + expect(response.headers.get("content-disposition")).toBe(disposition); + + const body = await response.text(); + expect(body).toBe(fileContents); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60000, + ); test("rejects invalid token", async () => { const response = await fetch( diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 4bb4736133..da52c4dac4 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -1,7 +1,17 @@ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs"; +import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest"; +import { + mkdtempSync, + writeFileSync, + rmSync, + mkdirSync, + symlinkSync, + readdirSync, + readlinkSync, +} from "fs"; +import { rm } from "node:fs/promises"; import { tmpdir } from "os"; import path from "path"; +import { execFileSync } from "node:child_process"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; function tmpCwd(): string { @@ -22,7 +32,526 @@ afterEach(async () => { await ctx.cleanup(); }, 60000); +describe("checkout discard", () => { + test.each([".", "apps/web"])( + "discards selected Changes from the %s workspace without changing siblings", + async (workspacePath) => { + const cwd = tmpCwd(); + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + git(["init", "-b", "main"]); + git(["config", "user.name", "Paseo Test"]); + git(["config", "user.email", "test@getpaseo.local"]); + git(["config", "core.autocrlf", "false"]); + const nested = path.join(cwd, "apps", "web"); + mkdirSync(nested, { recursive: true }); + writeFileSync(path.join(nested, "target[ab].txt"), "target base\n"); + writeFileSync(path.join(nested, "targeta.txt"), "sibling base\n"); + writeFileSync(path.join(nested, "deleted.txt"), "deleted base\n"); + git(["add", "."]); + git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]); + const creation = await ctx.client.createWorkspace({ + source: { kind: "directory", path: path.join(cwd, workspacePath) }, + title: "Discard regression", + }); + const workspaceCwd = creation.workspace?.workspaceDirectory; + if (!workspaceCwd) { + throw new Error(creation.error ?? "Workspace creation failed"); + } + + writeFileSync(path.join(nested, "target[ab].txt"), "target edited\n"); + writeFileSync(path.join(nested, "targeta.txt"), "sibling edited\n"); + writeFileSync(path.join(nested, "staged.txt"), "staged new file\n"); + writeFileSync(path.join(nested, "scratch.txt"), "untracked file\n"); + git(["--literal-pathspecs", "add", "--", "apps/web/target[ab].txt", "apps/web/staged.txt"]); + git(["rm", "apps/web/deleted.txt"]); + + const subscription = ctx.client.observeCheckoutDiff(workspaceCwd, { mode: "uncommitted" }); + try { + const changes = await subscription.ready; + expect(changes.error).toBeNull(); + expect(changes.files.map((file) => file.path).sort()).toEqual([ + "apps/web/deleted.txt", + "apps/web/scratch.txt", + "apps/web/staged.txt", + "apps/web/target[ab].txt", + "apps/web/targeta.txt", + ]); + const selectedPaths = changes.files + .filter((file) => file.path !== "apps/web/targeta.txt") + .map((file) => file.path); + const discarded = await ctx.client.checkoutDiscardChanges(workspaceCwd, { + paths: selectedPaths, + }); + expect(discarded).toMatchObject({ success: true, error: null }); + + const target = await ctx.client.readFile(cwd, "apps/web/target[ab].txt"); + expect(new TextDecoder().decode(target.bytes)).toBe("target base\n"); + const restored = await ctx.client.readFile(cwd, "apps/web/deleted.txt"); + expect(new TextDecoder().decode(restored.bytes)).toBe("deleted base\n"); + const sibling = await ctx.client.readFile(cwd, "apps/web/targeta.txt"); + expect(new TextDecoder().decode(sibling.bytes)).toBe("sibling edited\n"); + await expect(ctx.client.readFile(cwd, "apps/web/staged.txt")).rejects.toThrow(/ENOENT/); + await expect(ctx.client.readFile(cwd, "apps/web/scratch.txt")).rejects.toThrow(/ENOENT/); + expect(git(["status", "--porcelain=v1", "-z"])).toBe(" M apps/web/targeta.txt\0"); + } finally { + await subscription.release(); + } + } finally { + // afterEach shuts down the daemon and its repository observers first. + onTestFinished(() => + rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }), + ); + } + }, + ); +}); + describe("file explorer", () => { + test.each(["..", "..."])( + "workspace suggestions resolve exact paths starting with %s", + async (prefix) => { + const cwd = tmpCwd(); + const directoryName = `${prefix}docs`; + const nestedPath = `${directoryName}/notes.md`; + const fileName = `${prefix}notes.md`; + try { + mkdirSync(path.join(cwd, directoryName)); + writeFileSync(path.join(cwd, nestedPath), "nested payload\n"); + writeFileSync(path.join(cwd, fileName), "direct payload\n"); + const file = await ctx.client.readFile(cwd, nestedPath); + expect(new TextDecoder().decode(file.bytes)).toBe("nested payload\n"); + + const nestedEntry = { path: nestedPath, kind: "file" }; + const entries = [ + nestedEntry, + { path: directoryName, kind: "directory" }, + { path: fileName, kind: "file" }, + ]; + const cases = entries.flatMap((entry) => [ + { query: `./${entry.path}`, entry }, + { query: path.resolve(cwd, entry.path), entry }, + ]); + cases.unshift({ query: nestedPath, entry: nestedEntry }); + for (const { query, entry } of cases) { + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 1, + includeFiles: true, + includeDirectories: true, + matchMode: "suffix", + }); + expect(response.error).toBeNull(); + expect(response.entries).toEqual([entry]); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test("workspace suggestions exclude exact parent and sibling paths", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const sibling = path.join(directory, "workspace-other"); + try { + mkdirSync(cwd); + mkdirSync(sibling); + writeFileSync(path.join(cwd, "visible.txt"), "inside payload\n"); + writeFileSync(path.join(sibling, "outside.txt"), "outside payload\n"); + for (const query of [ + "./..", + "../workspace-other/outside.txt", + path.join(sibling, "outside.txt"), + ]) { + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 1, + includeFiles: true, + includeDirectories: true, + matchMode: "suffix", + }); + expect(response.error).toBeNull(); + expect(response.entries).toEqual([]); + } + const control = await ctx.client.getDirectorySuggestions({ + cwd, + query: "./visible.txt", + limit: 1, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + }); + expect(control.error).toBeNull(); + expect(control.entries).toEqual([{ path: "visible.txt", kind: "file" }]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test.each([ + { description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` }, + { description: "an ordinary name", fileName: "notes.txt" }, + { description: "a long Unicode name", fileName: `${"文".repeat(76)}.txt` }, + ])("saves text files with $description", async ({ fileName }) => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createFileEntry({ + cwd, + parentPath: ".", + name: fileName, + kind: "file", + }); + expect(created).toMatchObject({ path: fileName, success: true, error: null }); + writeFileSync(path.join(cwd, fileName), "original payload\n"); + const initial = await ctx.client.readFile(cwd, fileName); + + const saved = await ctx.client.writeFile({ + cwd, + path: fileName, + content: "saved payload\n", + expectedModifiedAt: initial.modifiedAt, + expectedRevision: initial.revision, + }); + expect(saved).toMatchObject({ status: "written", size: 14 }); + const after = await ctx.client.readFile(cwd, fileName); + expect(after.path).toBe(fileName); + expect(new TextDecoder().decode(after.bytes)).toBe("saved payload\n"); + + const staleSave = await ctx.client.writeFile({ + cwd, + path: fileName, + content: "stale payload\n", + expectedModifiedAt: initial.modifiedAt, + expectedRevision: initial.revision, + }); + expect(staleSave).toMatchObject({ + status: "conflict", + version: { + status: "ready", + path: fileName, + size: after.size, + modifiedAt: after.modifiedAt, + revision: after.revision, + }, + }); + const afterConflict = await ctx.client.readFile(cwd, fileName); + expect(new TextDecoder().decode(afterConflict.bytes)).toBe("saved payload\n"); + + const secondSave = await ctx.client.writeFile({ + cwd, + path: fileName, + content: "second payload\n", + expectedModifiedAt: afterConflict.modifiedAt, + expectedRevision: afterConflict.revision, + }); + expect(secondSave).toMatchObject({ status: "written", size: 15 }); + const final = await ctx.client.readFile(cwd, fileName); + expect(new TextDecoder().decode(final.bytes)).toBe("second payload\n"); + expect(readdirSync(cwd)).toEqual([fileName]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test.each([ + { + query: "implementation", + expectedPaths: [ + ".cursor/plans/implementation-plan.md", + ".codex/plans/implementation-plan.md", + ".claude/plans/implementation-plan.md", + ], + }, + { + query: ".cursor/plans/implementation", + expectedPaths: [".cursor/plans/implementation-plan.md"], + }, + ])( + "workspace suggestions find hidden agent plans for '$query'", + async ({ query, expectedPaths }) => { + const cwd = tmpCwd(); + try { + for (const directory of [".cursor", ".codex", ".claude", ".hidden"]) { + mkdirSync(path.join(cwd, directory, "plans"), { recursive: true }); + writeFileSync(path.join(cwd, directory, "plans", "implementation-plan.md"), "plan\n"); + } + + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 50, + includeFiles: true, + includeDirectories: true, + }); + expect(response.error).toBeNull(); + expect(response.entries).toHaveLength(expectedPaths.length); + expect(response.entries).toEqual( + expect.arrayContaining( + expectedPaths.map((entryPath) => ({ path: entryPath, kind: "file" })), + ), + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test.each(["..", "..."])("supports file operations on names starting with %s", async (prefix) => { + const cwd = tmpCwd(); + const fileName = `${prefix}notes.txt`; + const directoryName = `${prefix}drafts`; + try { + const createdFile = await ctx.client.createFileEntry({ + cwd, + parentPath: ".", + name: fileName, + kind: "file", + }); + expect(createdFile).toMatchObject({ path: fileName, success: true, error: null }); + writeFileSync(path.join(cwd, fileName), "notes payload\n"); + writeFileSync(path.join(cwd, ".notes.txt"), "dotfile control\n"); + writeFileSync(path.join(cwd, "notes.txt"), "ordinary control\n"); + + const createdDirectory = await ctx.client.createFileEntry({ + cwd, + parentPath: ".", + name: directoryName, + kind: "directory", + }); + expect(createdDirectory).toMatchObject({ path: directoryName, success: true, error: null }); + const createdChild = await ctx.client.createFileEntry({ + cwd, + parentPath: directoryName, + name: "inside.txt", + kind: "file", + }); + expect(createdChild).toMatchObject({ + path: `${directoryName}/inside.txt`, + success: true, + error: null, + }); + writeFileSync(path.join(cwd, directoryName, "inside.txt"), "nested payload\n"); + + const directory = await ctx.client.listDirectory(cwd, "."); + expect( + Object.fromEntries(directory.entries.map((entry) => [entry.path, entry.kind])), + ).toEqual({ + [directoryName]: "directory", + [fileName]: "file", + ".notes.txt": "file", + "notes.txt": "file", + }); + const children = await ctx.client.listDirectory(cwd, directoryName); + expect(children.entries.map((entry) => entry.path)).toEqual([`${directoryName}/inside.txt`]); + const file = await ctx.client.readFile(cwd, fileName); + expect(file.path).toBe(fileName); + expect(new TextDecoder().decode(file.bytes)).toBe("notes payload\n"); + const nestedFile = await ctx.client.readFile(cwd, `${directoryName}/inside.txt`); + expect(nestedFile.path).toBe(`${directoryName}/inside.txt`); + expect(new TextDecoder().decode(nestedFile.bytes)).toBe("nested payload\n"); + + const dotfile = await ctx.client.readFile(cwd, ".notes.txt"); + expect(new TextDecoder().decode(dotfile.bytes)).toBe("dotfile control\n"); + const ordinaryFile = await ctx.client.readFile(cwd, "notes.txt"); + expect(new TextDecoder().decode(ordinaryFile.bytes)).toBe("ordinary control\n"); + + const download = await ctx.client.requestDownloadToken(cwd, fileName); + expect(download.error).toBeNull(); + expect(download.fileName).toBe(fileName); + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${download.token}`, + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("notes payload\n"); + + const renamedFileName = `${prefix}renamed.txt`; + const renamed = await ctx.client.renameFileEntry({ + cwd, + path: fileName, + name: renamedFileName, + }); + expect(renamed).toMatchObject({ renamedPath: renamedFileName, success: true, error: null }); + const renamedFile = await ctx.client.readFile(cwd, renamedFileName); + expect(new TextDecoder().decode(renamedFile.bytes)).toBe("notes payload\n"); + + const copiedDirectoryName = `${directoryName} copy`; + const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: directoryName }); + expect(duplicated).toMatchObject({ + duplicatedPath: copiedDirectoryName, + success: true, + error: null, + }); + const copiedFile = await ctx.client.readFile(cwd, `${copiedDirectoryName}/inside.txt`); + expect(new TextDecoder().decode(copiedFile.bytes)).toBe("nested payload\n"); + + const deletedDirectory = await ctx.client.deleteFileEntry({ cwd, path: directoryName }); + expect(deletedDirectory).toMatchObject({ success: true, error: null }); + const deletedFile = await ctx.client.deleteFileEntry({ cwd, path: renamedFileName }); + expect(deletedFile).toMatchObject({ success: true, error: null }); + const remaining = await ctx.client.listDirectory(cwd, "."); + expect(remaining.entries.map((entry) => entry.path).sort()).toEqual( + [".notes.txt", "notes.txt", copiedDirectoryName].sort(), + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("rejects parent traversal and absolute paths to a sibling workspace", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const outside = path.join(directory, "workspace-sibling"); + try { + mkdirSync(cwd); + mkdirSync(outside); + writeFileSync(path.join(outside, "secret.txt"), "outside payload\n"); + for (const requestedPath of ["..", "../workspace-sibling", outside]) { + await expect(ctx.client.listDirectory(cwd, requestedPath)).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + } + for (const requestedPath of [ + "../workspace-sibling/secret.txt", + path.join(outside, "secret.txt"), + ]) { + await expect(ctx.client.readFile(cwd, requestedPath)).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + const download = await ctx.client.requestDownloadToken(cwd, requestedPath); + expect(download).toMatchObject({ + token: null, + error: "Access outside of workspace is not allowed", + }); + const deleted = await ctx.client.deleteFileEntry({ cwd, path: requestedPath }); + expect(deleted).toMatchObject({ + success: false, + error: "Access outside of workspace is not allowed", + }); + } + expect(readdirSync(outside)).toEqual(["secret.txt"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("accepts symlinks to double-dot names inside the workspace and rejects outside targets", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const outside = path.join(directory, "workspace-sibling"); + try { + mkdirSync(path.join(cwd, "..drafts"), { recursive: true }); + mkdirSync(outside); + writeFileSync(path.join(cwd, "..drafts", "notes.txt"), "inside payload\n"); + writeFileSync(path.join(outside, "secret.txt"), "outside payload\n"); + // Junctions also work on Windows without requiring symlink privileges. + symlinkSync(path.join(cwd, "..drafts"), path.join(cwd, "inside-link"), "junction"); + symlinkSync(outside, path.join(cwd, "outside-link"), "junction"); + + const listing = await ctx.client.listDirectory(cwd, "."); + expect(listing.entries.map((entry) => entry.path).sort()).toEqual([ + "..drafts", + "inside-link", + ]); + const file = await ctx.client.readFile(cwd, "inside-link/notes.txt"); + expect(file.path).toBe("inside-link/notes.txt"); + expect(new TextDecoder().decode(file.bytes)).toBe("inside payload\n"); + await expect(ctx.client.readFile(cwd, "outside-link/secret.txt")).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + const download = await ctx.client.requestDownloadToken(cwd, "outside-link/secret.txt"); + expect(download).toMatchObject({ + token: null, + error: "Access outside of workspace is not allowed", + }); + expect(readdirSync(outside)).toEqual(["secret.txt"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === "win32")( + "duplicating a folder keeps relative symlinks inside the copy", + async () => { + const cwd = tmpCwd(); + try { + mkdirSync(path.join(cwd, "template")); + writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n"); + symlinkSync("data.txt", path.join(cwd, "template", "alias.txt")); + + const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" }); + expect(duplicated).toMatchObject({ + duplicatedPath: "template copy", + success: true, + error: null, + }); + expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt"); + + const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt"); + const targetWrite = await ctx.client.writeFile({ + cwd, + path: "template copy/data.txt", + content: "copy payload\n", + expectedModifiedAt: copiedTarget.modifiedAt, + expectedRevision: copiedTarget.revision, + }); + expect(targetWrite.status).toBe("written"); + const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt"); + expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n"); + + const aliasWrite = await ctx.client.writeFile({ + cwd, + path: "template copy/alias.txt", + content: "edited through copied alias\n", + expectedModifiedAt: copiedAlias.modifiedAt, + expectedRevision: copiedAlias.revision, + }); + expect(aliasWrite.status).toBe("written"); + const original = await ctx.client.readFile(cwd, "template/data.txt"); + expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n"); + const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt"); + expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n"); + + const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" }); + expect(deleted).toMatchObject({ success: true, error: null }); + const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt"); + expect(new TextDecoder().decode(survivingAlias.bytes)).toBe( + "edited through copied alias\n", + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(process.platform === "win32")( + "duplicating a file symlink preserves an explicit absolute target", + async () => { + const cwd = tmpCwd(); + const targetPath = path.join(cwd, "data.txt"); + try { + writeFileSync(targetPath, "absolute target payload\n"); + symlinkSync(targetPath, path.join(cwd, "alias.txt")); + + const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" }); + expect(duplicated).toMatchObject({ + duplicatedPath: "alias copy.txt", + success: true, + error: null, + }); + expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath); + const copy = await ctx.client.readFile(cwd, "alias copy.txt"); + expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + test("lists directory contents", async () => { const cwd = tmpCwd(); @@ -125,3 +654,63 @@ describe("file explorer", () => { rmSync(cwd, { recursive: true, force: true }); }, 60000); // 1 minute timeout }); + +describe("commit file diffs", () => { + test.each([".", "apps/web"])("opens committed files from %s workspaces", async (relativePath) => { + const cwd = tmpCwd(); + onTestFinished(() => + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), + ); + const workspace = path.join(cwd, relativePath); + const filePath = "apps/web/notes.txt"; + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); + } + git(["init", "-b", "main"]); + git(["config", "user.name", "Test"]); + git(["config", "user.email", "test@example.com"]); + git(["config", "core.autocrlf", "false"]); + mkdirSync(path.join(cwd, "apps", "web"), { recursive: true }); + writeFileSync(path.join(cwd, filePath), "before\n"); + writeFileSync(path.join(cwd, "apps/web/untouched.txt"), "unchanged\n"); + git(["add", "."]); + git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]); + git(["checkout", "-b", "feature"]); + writeFileSync(path.join(cwd, filePath), "after\n"); + git(["add", "."]); + git(["-c", "commit.gpgsign=false", "commit", "-m", "edit"]); + const sha = git(["rev-parse", "HEAD"]).trim(); + + const history = await ctx.client.listCheckoutCommits(workspace); + const commit = history.commits.find((entry) => entry.sha === sha); + expect(commit?.files).toEqual([ + { path: filePath, additions: 1, deletions: 1, status: "modified" }, + ]); + const result = await ctx.client.getCommitFileDiff(workspace, sha, filePath); + expect(result).toEqual({ + file: { + path: filePath, + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], + }, + }); + expect(await ctx.client.getCommitFileDiff(workspace, sha, "apps/web/untouched.txt")).toEqual({ + file: null, + }); + }); +}); diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 64ba85eb48..be225f171d 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -1,5 +1,14 @@ import { test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, existsSync, rmSync, readFileSync, realpathSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + existsSync, + rmSync, + readFileSync, + realpathSync, + mkdirSync, +} from "fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -152,6 +161,78 @@ afterEach(async () => { await ctx.cleanup(); }, 60000); +interface GitCommandOptions { + cwd: string; + args: string[]; +} + +async function mergeFeatureIntoBaseWorktree(baseName: string): Promise<void> { + const directory = realpathSync(tmpCwd()); + const base = path.join(directory, baseName); + const feature = path.join(directory, "feature"); + function git({ cwd, args }: GitCommandOptions): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + mkdirSync(base); + git({ cwd: base, args: ["init", "-b", "main"] }); + git({ cwd: base, args: ["config", "user.name", "Paseo Test"] }); + git({ cwd: base, args: ["config", "user.email", "test@paseo.local"] }); + git({ cwd: base, args: ["config", "commit.gpgsign", "false"] }); + git({ cwd: base, args: ["config", "core.autocrlf", "false"] }); + git({ cwd: base, args: ["commit", "--allow-empty", "-m", "base"] }); + git({ cwd: base, args: ["worktree", "add", "-b", "feature", feature] }); + writeFileSync(path.join(feature, "feature.txt"), "feature payload\n"); + git({ cwd: feature, args: ["add", "feature.txt"] }); + git({ cwd: feature, args: ["commit", "-m", "feature"] }); + const featureCommit = git({ cwd: feature, args: ["rev-parse", "HEAD"] }).trim(); + + const merged = await ctx.client.checkoutMerge(feature, { baseRef: "main" }); + expect(merged).toMatchObject({ success: true, error: null }); + expect(git({ cwd: base, args: ["rev-parse", "HEAD"] }).trim()).toBe(featureCommit); + expect(readFileSync(path.join(base, "feature.txt"), "utf8")).toBe("feature payload\n"); + expect(git({ cwd: feature, args: ["symbolic-ref", "--short", "HEAD"] }).trim()).toBe("feature"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test.skipIf(isPlatform("win32")).each(["base\ncheckout", "base "])( + "merges into a base worktree whose path preserves %j", + mergeFeatureIntoBaseWorktree, +); + +test.each(["base checkout", "中文 checkout"])( + "merges into a base worktree named %j", + mergeFeatureIntoBaseWorktree, +); + +test("merges into a local base branch without another worktree", async () => { + const cwd = realpathSync(tmpCwd()); + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + git(["init", "-b", "main"]); + git(["config", "user.name", "Paseo Test"]); + git(["config", "user.email", "test@paseo.local"]); + git(["config", "commit.gpgsign", "false"]); + git(["commit", "--allow-empty", "-m", "base"]); + git(["checkout", "-b", "feature"]); + writeFileSync(path.join(cwd, "feature.txt"), "feature payload\n"); + git(["add", "feature.txt"]); + git(["commit", "-m", "feature"]); + const featureCommit = git(["rev-parse", "HEAD"]).trim(); + + const merged = await ctx.client.checkoutMerge(cwd, { baseRef: "main" }); + expect(merged).toMatchObject({ success: true, error: null }); + expect(git(["rev-parse", "main"]).trim()).toBe(featureCommit); + expect(git(["symbolic-ref", "--short", "HEAD"]).trim()).toBe("feature"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + test("returns diff for modified file in git repo", async () => { const cwd = tmpCwd(); diff --git a/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs new file mode 100644 index 0000000000..542c2cf2ba --- /dev/null +++ b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs @@ -0,0 +1,44 @@ +// Local ACP peer for the bundled Hoplite adapter; no Hoplite service or credentials. +const fs = require("node:fs"); +const readline = require("node:readline"); +fs.appendFileSync(process.env.HOPLITE_TEST_RECORD, "launch\n"); +const configOptions = [ + { + id: "model", + category: "model", + type: "select", + name: "Model", + currentValue: "fixture-model", + options: [{ value: "fixture-model", name: "Fixture model" }], + }, +]; +const send = (message) => + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n"); +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + let result = {}; + if (request.method === "initialize") + result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }; + if (request.method === "session/new" && request.params.cwd !== process.env.HOPLITE_TEST_CWD) { + send({ + id: request.id, + error: { code: -32602, message: "No Hoplite project for this directory" }, + }); + return; + } + if (request.method === "session/new") + result = { + sessionId: "hoplite-fixture", + configOptions, + }; + if (request.method === "session/set_config_option") result = { configOptions }; + if (request.method === "session/prompt") { + send({ + method: "_hoplite/run_phase", + params: { runId: "fixture-run", phase: "provisioning", label: "Preparing fixture workspace" }, + }); + result = { stopReason: "end_turn" }; + } + send({ id: request.id, result }); +}); diff --git a/packages/server/src/server/daemon-e2e/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts index 1f2086c879..cfa420654b 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -1,7 +1,8 @@ import { describe, test, expect } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { loadConfig } from "../config.js"; import { findExecutable } from "../../executable-resolution/executable-resolution.js"; import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -124,15 +125,12 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({ try { await client.connect(); await client.fetchAgents({ subscribe: {} }); - await expect - .poll( - async () => { - const snapshot = await client.getProvidersSnapshot({ cwd }); - return snapshot.entries.find((entry) => entry.provider === "antigravity")?.status; - }, - { timeout: 30000 }, - ) - .toBe("ready"); + // Snapshot reads start discovery again; await one refresh before checking readiness. + await client.refreshProvidersSnapshot({ cwd, providers: ["antigravity"] }); + const snapshot = await client.getProvidersSnapshot({ cwd }); + expect(snapshot.entries.find((entry) => entry.provider === "antigravity")?.status).toBe( + "ready", + ); const agent = await client.createAgent({ provider: "antigravity", cwd, @@ -165,3 +163,133 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({ await rm(cwd, { recursive: true, force: true }); } }, 120000); + +test("bundled Hoplite requires a config entry and removal survives restart", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-hoplite-optin-")); + const record = path.join(root, "launches.txt"); + await writeFile(record, ""); + const options = { + paseoHomeRoot: root, + cleanup: false, + mcpEnabled: false, + builtinPlugins: new BuiltinPluginLoader(undefined, [ + "hoplite-provider", + "antigravity-provider", + ]), + }; + let daemon = await createTestPaseoDaemon(options); + const connect = async () => { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.2", + }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return client; + }; + let client = await connect(); + try { + const before = await client.getProvidersSnapshot(); + expect(before.entries.some((entry) => entry.provider === "hoplite")).toBe(false); + expect(before.entries.some((entry) => entry.provider === "antigravity")).toBe(true); + expect(await readFile(record, "utf8")).toBe(""); + + await client.patchDaemonConfig({ + providers: { + hoplite: { + enabled: true, + command: [process.execPath, path.resolve(import.meta.dirname, "hoplite-fixture.cjs")], + env: { HOPLITE_TEST_RECORD: record, HOPLITE_TEST_CWD: root }, + }, + }, + }); + const globalModels = await client.listProviderModels("hoplite"); + expect(globalModels.error).toBeNull(); + expect(globalModels.models).toEqual([]); + await expect + .poll( + async () => + (await client.getProvidersSnapshot()).entries.find( + (entry) => entry.provider === "hoplite", + )?.status, + ) + .toBe("ready"); + const workspaceModels = await client.listProviderModels("hoplite", { cwd: root }); + expect(workspaceModels.error).toBeNull(); + expect(workspaceModels.models.map((model) => model.id)).toEqual(["fixture-model"]); + await client.refreshProvidersSnapshot({ providers: ["hoplite"] }); + expect((await client.listProviderModels("hoplite")).models).toEqual([]); + expect( + (await client.listProviderModels("hoplite", { cwd: root })).models.map((model) => model.id), + ).toEqual(["fixture-model"]); + const agent = await client.createAgent({ + provider: "hoplite", + cwd: root, + model: "fixture-model", + }); + await client.sendMessage(agent.id, "Exercise the contributor transformer"); + await client.waitForFinish(agent.id, 10000); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + expect(timeline.entries.map((entry) => entry.item)).toContainEqual( + expect.objectContaining({ + type: "tool_call", + name: "Hoplite workspace", + status: "completed", + }), + ); + await client.archiveAgent(agent.id); + expect(await readFile(record, "utf8")).toContain("launch"); + + await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } }); + expect( + (await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite") + ?.enabled, + ).toBe(false); + await client.close(); + await daemon.close(); + daemon = await createTestPaseoDaemon({ + ...options, + staticDir: daemon.staticDir, + providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides, + }); + client = await connect(); + expect( + (await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite") + ?.enabled, + ).toBe(false); + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }); + expect( + (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"), + ).toBe(false); + expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined(); + const persisted = JSON.parse( + await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"), + ); + expect(persisted.agents?.providers?.hoplite).toBeUndefined(); + const launchesAfterRemoval = await readFile(record, "utf8"); + await client.close(); + await daemon.close(); + daemon = await createTestPaseoDaemon({ + ...options, + staticDir: daemon.staticDir, + providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides, + }); + client = await connect(); + const restarted = await client.getProvidersSnapshot(); + expect(restarted.entries.some((entry) => entry.provider === "hoplite")).toBe(false); + expect(restarted.entries.map((entry) => entry.provider)).toEqual( + before.entries.map((entry) => entry.provider), + ); + expect(await readFile(record, "utf8")).toBe(launchesAfterRemoval); + await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } }); + expect( + (await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite") + ?.enabled, + ).toBe(false); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + await rm(daemon.staticDir, { recursive: true, force: true }); + } +}, 60000); diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index 510e8b384d..ab407e31bc 100644 --- a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts @@ -131,6 +131,15 @@ async function timeline(client: DaemonClient, agentId: string): Promise<AgentTim return result.entries.map((entry) => entry.item); } +function compactionFiller(records = 1200): string { + const teas = ["assam", "darjeeling", "sencha", "oolong"]; + return Array.from( + { length: records }, + (_, index) => + `Record ${String(index).padStart(5, "0")}: tea=${teas[index % teas.length]} origin=region-${index % 37} notes=lorem ipsum dolor sit amet`, + ).join("\n"); +} + function latestTools(items: AgentTimelineItem[]) { const latest = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>(); for (const item of items) if (item.type === "tool_call") latest.set(item.callId, item); @@ -225,6 +234,27 @@ afterEach(() => { roots.clear(); }); +async function waitForTimelineItem( + client: DaemonClient, + agentId: string, + predicate: (item: AgentTimelineItem) => boolean, +): Promise<AgentTimelineItem> { + const deadline = Date.now() + 60_000; + let items: AgentTimelineItem[] = []; + while (Date.now() < deadline) { + items = await timeline(client, agentId); + const item = items.find(predicate); + if (item) return item; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for OMP timeline item: ${JSON.stringify(items)}`); +} + +async function contextWindowUsedTokens(client: DaemonClient, agentId: string) { + const result = await client.fetchAgent(agentId); + return result?.agent.lastUsage?.contextWindowUsedTokens; +} + describe("daemon E2E (real OMP)", () => { test( "a prompt OMP rejects before its agent runs fails the turn with OMP's error", @@ -249,6 +279,48 @@ describe("daemon E2E (real OMP)", () => { TIMEOUT_MS, ); + test( + "manual compact shows its progress and refreshes context usage", + async () => { + const harness = await createHarness(); + try { + const agent = await createAgent(harness, "manual-compact"); + await promptAndFinish(harness, agent.id, "Reply exactly: ready"); + await promptAndFinish( + harness, + agent.id, + `${compactionFiller(2500)}\n\nWhich tea is in record 00007? Reply with one word.`, + ); + await promptAndFinish(harness, agent.id, "Reply exactly: after-filler"); + const usedBefore = await contextWindowUsedTokens(harness.client, agent.id); + expect(usedBefore).toBeGreaterThan(20_000); + + await harness.client.sendMessage(agent.id, "/compact"); + await waitForTimelineItem( + harness.client, + agent.id, + (item) => item.type === "compaction" && item.status === "completed", + ); + + expect(await timeline(harness.client, agent.id)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "compaction", status: "loading", trigger: "manual" }), + expect.objectContaining({ type: "compaction", status: "completed", trigger: "manual" }), + ]), + ); + await harness.client.waitForAgentUpsert( + agent.id, + (snapshot) => + (snapshot.lastUsage?.contextWindowUsedTokens ?? Infinity) < (usedBefore ?? 0), + 60_000, + ); + } finally { + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "prompt, native tool, and resumed follow-up", async () => { @@ -510,6 +582,56 @@ describe("daemon E2E (real OMP)", () => { TIMEOUT_MS, ); + test( + "a compaction shows as a compaction row after a daemon restart", + async () => { + const harness = await createHarness(); + let restarted: TestPaseoDaemon | null = null; + let restartedClient: DaemonClient | null = null; + try { + const agent = await createAgent(harness, "compact-restart"); + await promptAndFinish( + harness, + agent.id, + `${compactionFiller()}\n\nReply exactly OMP_COMPACT_FIRST`, + ); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_SECOND"); + await harness.client.sendMessage(agent.id, "/compact"); + await harness.client.waitForFinish(agent.id, TIMEOUT_MS); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_THIRD"); + + await harness.client.close(); + await harness.daemon.close(); + restarted = await createTestPaseoDaemon({ + agentClients: createRealProviderClients(["omp"], pino({ level: "silent" })), + providerOverrides: { omp: { enabled: true } }, + logger: pino({ level: "silent" }), + paseoHomeRoot: harness.paseoHomeRoot, + staticDir: harness.staticDir, + cleanup: false, + }); + restartedClient = new DaemonClient({ url: `ws://127.0.0.1:${restarted.port}/ws` }); + await restartedClient.connect(); + await restartedClient.fetchAgents({ subscribe: {} }); + const replayed = await timeline(restartedClient, agent.id); + + expect( + latestTools(replayed) + .map(toolResult) + .filter((text) => text.includes("Unsupported history record")), + ).toEqual([]); + expect(replayed.filter((item) => item.type === "compaction")).toEqual([ + expect.objectContaining({ type: "compaction", status: "completed" }), + ]); + } finally { + await restartedClient?.close().catch(() => undefined); + await restarted?.close().catch(() => undefined); + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "native branch rewinds conversation history and keeps the session usable", async () => { diff --git a/packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts b/packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts new file mode 100644 index 0000000000..fc68fe44f4 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; + +let ctx: DaemonTestContext; +let cwd: string; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); + cwd = mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); +}); + +afterEach(async () => { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); + +describe("a message larger than the daemon accepts", () => { + const oversizedImage = { data: "A".repeat(101 * 1024 * 1024), mimeType: "image/png" }; + + test("fails the creation and keeps the connection up", async () => { + const statuses: string[] = []; + const unsubscribe = ctx.client.subscribeConnectionStatus((state) => { + statuses.push(state.status); + }); + + await expect( + ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Oversized first message", + initialPrompt: "Describe these screens.", + images: [oversizedImage], + }), + ).rejects.toThrow(/too large/); + unsubscribe(); + + expect(statuses).toEqual(["connected"]); + expect((await ctx.client.fetchAgents()).entries).toEqual([]); + }, 30000); + + test("fails the send and keeps the connection up", async () => { + const agent = await ctx.client.createAgent({ provider: "claude", cwd, title: "Agent" }); + const statuses: string[] = []; + const unsubscribe = ctx.client.subscribeConnectionStatus((state) => { + statuses.push(state.status); + }); + + await expect( + ctx.client.sendMessage(agent.id, "Describe these screens.", { images: [oversizedImage] }), + ).rejects.toThrow(/too large/); + unsubscribe(); + + expect(statuses).toEqual(["connected"]); + }, 30000); +}); diff --git a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts index a4db7ce7bf..16dae500f4 100644 --- a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts @@ -1,6 +1,6 @@ import { execFile as execFileCallback } from "node:child_process"; import { mkdtempSync, realpathSync } from "node:fs"; -import { readFile, rm } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -63,10 +63,12 @@ test("an empty project becomes Git without changing its identity or creating a w cleanupClients.add(client); await client.connect(); await client.fetchAgents({ subscribe: {} }); + const projectEvents = client.observeEvents(["project.update"]); + await projectEvents.ready; const added = await client.addProject(projectRoot); - expect(added).toEqual({ + expect(added).toMatchObject({ requestId: expect.any(String), project: { projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/), @@ -77,7 +79,11 @@ test("an empty project becomes Git without changing its identity or creating a w }, error: null, }); - const project = added.project!; + const { + projectIconRevision: _icon, + projectCustomIconRevision: _customIcon, + ...project + } = added.project!; const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] }); @@ -96,15 +102,15 @@ test("an empty project becomes Git without changing its identity or creating a w label: "project.update after git init", }); - expect(update).toEqual({ + expect(update).toMatchObject({ kind: "upsert", - project: { ...project, projectKind: "git" }, + project: { ...project, projectKind: "git", projectKey: expect.any(String) }, }); const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); expect(afterGitInit).toMatchObject({ entries: [], - emptyProjects: [{ ...project, projectKind: "git" }], + emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }], }); const persistedProjects = JSON.parse( @@ -119,5 +125,72 @@ test("an empty project becomes Git without changing its identity or creating a w createdAt: expect.any(String), updatedAt: expect.any(String), archivedAt: null, + projectKey: expect.any(String), + customIconRevision: null, }); }, 30_000); + +test("a failed worktree attempt before git init does not prevent creation afterward", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-"))); + const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-"))); + cleanupPaths.add(projectRoot); + cleanupPaths.add(paseoHomeRoot); + await writeFile(path.join(projectRoot, "README.md"), "hi\n"); + + const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false }); + cleanupDaemons.add(daemon); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + cleanupClients.add(client); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const projectEvents = client.observeEvents(["project.update"]); + await projectEvents.ready; + const added = await client.addProject(projectRoot); + expect(added.project?.projectKind).toBe("non_git"); + const source = { + kind: "worktree" as const, + cwd: projectRoot, + projectId: added.project!.projectId, + action: "branch-off" as const, + refName: "main", + branchName: "after-init", + worktreeSlug: "after-init", + }; + const before = await client.createWorkspace({ source }); + expect(before.error).toBe("Create worktree requires a git repository"); + + const gitProjectUpdate = waitForProjectUpdate( + client, + (payload) => + payload.kind === "upsert" && + payload.project.projectId === added.project!.projectId && + payload.project.projectKind === "git", + ); + await execFile("git", ["init", "-b", "main"], { cwd: projectRoot }); + await execFile("git", ["add", "README.md"], { cwd: projectRoot }); + await execFile( + "git", + [ + "-c", + "user.name=Paseo Test", + "-c", + "user.email=test@getpaseo.dev", + "-c", + "commit.gpgSign=false", + "commit", + "-m", + "initial", + ], + { cwd: projectRoot }, + ); + await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" }); + const projects = await client.fetchWorkspaces({ + filter: { projectId: added.project!.projectId }, + }); + expect(projects.emptyProjects[0]?.projectKind).toBe("git"); + + const created = await client.createWorkspace({ source }); + expect(created.error).toBeNull(); + expect(created.workspace?.workspaceKind).toBe("worktree"); + expect(created.workspace?.projectId).toBe(added.project!.projectId); +}, 30_000); diff --git a/packages/server/src/server/daemon-e2e/project-path-spaces.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-path-spaces.e2e.test.ts new file mode 100644 index 0000000000..89385c67db --- /dev/null +++ b/packages/server/src/server/daemon-e2e/project-path-spaces.e2e.test.ts @@ -0,0 +1,73 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, expect, onTestFinished, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +// Windows does not preserve trailing spaces in folder names. +const testOnPosix = test.skipIf(process.platform === "win32"); + +let client: DaemonClient; +let repoDir: string; +let projectId: string; + +beforeEach(async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-path-spaces-"))); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + repoDir = join(root, "repo "); + execFileSync("git", ["init", "-b", "main", repoDir], { 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: repoDir, stdio: "pipe" }, + ); + const daemon = await createTestPaseoDaemon(); + onTestFinished(() => daemon.close()); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const testClient = client; + onTestFinished(() => testClient.close()); + await client.connect(); + const added = await client.addProject(repoDir); + expect(added.error).toBeNull(); + expect(added.project?.projectRootPath).toBe(repoDir); + projectId = added.project!.projectId; +}); + +testOnPosix("a project folder ending in a space keeps its Git status", async () => { + const opened = await client.createWorkspace({ + source: { kind: "directory", path: repoDir, projectId }, + }); + expect(opened.error).toBeNull(); + expect(opened.workspace?.workspaceDirectory).toBe(repoDir); + const status = await client.getCheckoutStatus(repoDir); + expect(status.isGit).toBe(true); + expect(status.repoRoot).toBe(repoDir); + expect(status.currentBranch).toBe("main"); +}); + +testOnPosix("a project folder ending in a space can create a worktree", async () => { + const created = await client.createWorkspace({ + source: { + kind: "worktree", + projectId, + action: "branch-off", + branchName: "feature/spaces", + baseBranch: "main", + }, + }); + expect(created.error).toBeNull(); + expect(created.workspace?.gitRuntime?.currentBranch).toBe("feature/spaces"); +}); diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 3edede83d3..4da97d6eba 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "vitest"; +import { withTimeout } from "../../utils/promise-timeout.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; import { WebSocket } from "ws"; import pino from "pino"; import { Writable } from "node:stream"; @@ -6,9 +8,13 @@ import net from "node:net"; import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { Buffer } from "node:buffer"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { deriveSharedKey, @@ -73,6 +79,10 @@ function decodeOfferFromFragmentUrl(url: string): { return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 }; } +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + function encodeCiphertext(ciphertext: ArrayBuffer): string { return Buffer.from(new Uint8Array(ciphertext)).toString("base64"); } @@ -202,12 +212,20 @@ async function waitForCapturedLog( if (options.useLocalRelay) { relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:"); } - relayProcess = spawn("npx", relayArgs, { - cwd: relayDir, - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); + relayProcess = spawn( + process.execPath, + [ + "--experimental-vm-modules", + path.resolve(relayDir, "../../node_modules/wrangler/wrangler-dist/cli.js"), + ...relayArgs.slice(1), + ], + { + cwd: relayDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }, + ); relayProcess.stdout?.on("data", (data: Buffer) => { const lines = data @@ -241,6 +259,73 @@ async function waitForCapturedLog( relayProcess = null; }; + test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { + await startRelay({ useLocalRelay: true }); + const daemon = await createTestPaseoDaemon({ + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayUseTls: false, + relayPublicUseTls: false, + }); + let client: DaemonClient | null = null; + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: true, + relayEndpoint: daemon.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + client = new DaemonClient({ + url: buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + e2ee: { enabled: true, daemonPublicKeyB64 }, + reconnect: { baseDelayMs: 100, maxDelayMs: 100 }, + }); + await client.connect(); + const statuses: string[] = []; + client.subscribeConnectionStatus((state) => statuses.push(state.status)); + for (const imageMiB of [30, 61]) { + // Attachments are base64 in the JSON, then E2EE base64 expands the frame again. + const images = [ + { data: "A".repeat(4 * Math.ceil((imageMiB * 1024 * 1024) / 3)), mimeType: "image/png" }, + ]; + await expect( + withTimeout( + client.createWorkspace({ + source: { kind: "directory", path: daemon.staticDir }, + agent: { + provider: "claude", + cwd: daemon.staticDir, + initialPrompt: "Describe these screens.", + images, + }, + }), + 10000, + "Workspace creation did not settle", + ), + ).rejects.toThrow(/too large.*32\.0 MiB/); + expect((await client.fetchAgents()).entries).toEqual([]); + } + const agent = await client.createAgent({ provider: "claude", cwd: daemon.staticDir }); + await expect( + client.sendMessage(agent.id, "Describe these screens.", { + images: [{ data: "A".repeat(40 * 1024 * 1024), mimeType: "image/png" }], + }), + ).rejects.toThrow(/too large.*32\.0 MiB/); + await client.sendMessage(agent.id, "A small follow-up still works."); + expect((await client.fetchAgents()).entries).toHaveLength(1); + expect(statuses).toEqual(["connected"]); + } finally { + await client?.close(); + await daemon.close(); + await stopRelay(); + } + }, 60000); + test("daemon connects to relay and client ping/pong works through relay", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; @@ -783,4 +868,67 @@ async function waitForCapturedLog( await stopRelay(); } }, 90000); + + test("reads a workspace file over the E2EE relay session byte-for-byte", async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + let daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | null = null; + let workspaceDir: string | null = null; + const fileName = "rapport données.bin"; + const source = randomBytes(4 * FILE_EXPLORER_STREAM_CHUNK_BYTES + 123); + let client: DaemonClient | null = null; + + try { + await startRelay({ useLocalRelay: true }); + daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + }); + workspaceDir = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-download-")); + await writeFile(path.join(workspaceDir, fileName), source); + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + + client = new DaemonClient({ + url: buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + clientType: "cli", + connectTimeoutMs: 30_000, + e2ee: { enabled: true, daemonPublicKeyB64 }, + reconnect: { enabled: false }, + }); + await client.connect(); + + const result = await client.readFile(workspaceDir, fileName, undefined, undefined, 0); + + expect(result.size).toBe(source.byteLength); + expect(result.bytes.byteLength).toBe(source.byteLength); + expect(sha256(result.bytes)).toBe(sha256(source)); + } catch (err) { + const tail = lines.slice(-50).join(""); + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", tail); + throw err; + } finally { + await client?.close().catch(() => undefined); + await daemon?.close(); + await stopRelay(); + if (workspaceDir) { + await rm(workspaceDir, { recursive: true, force: true }); + } + } + }, 90000); }); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 2471ff8c5a..5eea6dab24 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -304,6 +304,166 @@ async function waitForRunningClaudeSleep( ); } +// Explicit point-in-time polls of agent status, on top of (not instead of) the pushed +// agent_update events the collector already records. A phantom "running" flip that starts and +// ends between two polls would still be caught by the pushed-event scan the callers pair this +// with; polling here additionally proves the daemon answers fetchAgent with "idle" throughout, +// not just that it eventually pushes one. +async function sampleAgentStatuses( + client: DaemonClient, + agentId: string, + samples: number, + intervalMs: number, +): Promise<string[]> { + const statuses: string[] = []; + for (let i = 0; i < samples; i += 1) { + const snapshot = await client.fetchAgent({ agentId }); + statuses.push(snapshot?.agent.status ?? "missing"); + if (i < samples - 1) { + await sleep(intervalMs); + } + } + return statuses; +} + +// NOTE: agent_stream events on the wire (AgentStreamEventPayloadSchema in +// packages/protocol/src/messages.ts) do not include a "provider_subagent" variant. A backgrounded +// subagent's descriptor updates are forwarded exclusively as separate "agent.provider_subagents.update" +// messages (see forwardProviderSubagentUpdate in session.ts), and that push channel's delivery is +// gated by capabilities this test's plain DaemonClient does not declare. list_provider_subagents is a +// plain request/response RPC unaffected by that gating, so status history below polls it directly +// instead of trying to reconstruct status transitions from the stream. +type ProviderSubagentDescriptor = Awaited< + ReturnType<DaemonClient["listProviderSubagents"]> +>["subagents"][number]; + +interface ProviderSubagentPoll { + subagent: ProviderSubagentDescriptor; + // Every status observed while polling, in order, so a transient cancel cannot hide behind a + // later "completed" read even though the store's status field is not expected to un-cancel. + history: string[]; +} + +// Polls agent.provider_subagents.list until the backgrounded helper is registered at all. +// "Main's turn is running" (waitForAgentUpsert) fires on generic turn-start bookkeeping, which +// can be true before Claude has actually issued the Agent tool call; canceling that early +// cancels a turn with no helper launched yet, which is not what "Stop spares a helper" means to +// test. This confirms the helper actually exists before the caller acts on it. +async function waitForProviderSubagentToExist( + client: DaemonClient, + agentId: string, + timeoutMs = 60_000, +): Promise<ProviderSubagentDescriptor> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const { subagents } = await client.listProviderSubagents(agentId); + if (subagents[0]) { + return subagents[0]; + } + await sleep(300); + } + throw new Error( + `Timed out waiting for a background helper to be registered for agent ${agentId}`, + ); +} + +// Claude starts the wake-up turn a moment after the helper's completion lands (0.1 to 5 s in +// recorded traces), so poll for it instead of checking the instant the helper reads completed. +// The daemon announces an autonomous turn to subscribers as a pushed "running" agent_update, +// not as an agent_stream turn_started (that stream reaches the client that started a run). +async function waitForWakeTurn( + collector: ReturnType<typeof createMessageCollector>, + agentId: string, + fromIndex: number, + timeoutMs = 30_000, +): Promise<boolean> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const window = collector.messages.slice(fromIndex); + const woke = + getAgentStatuses(window, agentId).includes("running") || + window.some( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "turn_started", + ); + if (woke) return true; + await sleep(500); + } + return false; +} + +// The wake-up turn runs briefly after it opens, so the final state is read once it settles. +async function waitForAgentStatus( + client: DaemonClient, + agentId: string, + status: string, + timeoutMs = 60_000, +): Promise<string | undefined> { + const deadline = Date.now() + timeoutMs; + let last: string | undefined; + while (Date.now() < deadline) { + last = (await client.fetchAgent({ agentId }))?.agent.status; + if (last === status) return last; + await sleep(500); + } + return last; +} + +// Polls agent.provider_subagents.list until the single backgrounded helper leaves "running". +// Scenarios in this file always launch exactly one background helper, so subagents[0] is it. +async function waitForProviderSubagentTerminal( + client: DaemonClient, + agentId: string, + timeoutMs = 90_000, +): Promise<ProviderSubagentPoll> { + const deadline = Date.now() + timeoutMs; + const history: string[] = []; + let lastSeen: ProviderSubagentDescriptor[] = []; + while (Date.now() < deadline) { + const { subagents } = await client.listProviderSubagents(agentId); + lastSeen = subagents; + const subagent = subagents[0]; + if (subagent) { + history.push(subagent.status); + if (subagent.status !== "running") { + return { subagent, history }; + } + } + await sleep(1_000); + } + throw new Error( + `Timed out waiting for the background helper to leave "running". history=${JSON.stringify(history)} lastSeen=${JSON.stringify(lastSeen)}`, + ); +} + +// Pulls the main agent's own assistant text via fetchAgentTimeline rather than the push +// collector. Assistant text delivery is paced (docs/agent-stream-performance.md), so checking +// the collector immediately after waitForFinish resolves can still miss the final text; the +// timeline fetch is authoritative and matches what the pre-existing tests in this file do. +async function fetchAssistantTexts( + client: DaemonClient, + agentId: string, + limit = 100, +): Promise<string[]> { + const timeline = await client.fetchAgentTimeline(agentId, { limit }); + return timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => (entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text); +} + +async function fetchSubagentAssistantTexts( + client: DaemonClient, + agentId: string, + subagentId: string, +): Promise<string[]> { + const timeline = await client.fetchProviderSubagentTimeline(agentId, subagentId, { limit: 50 }); + return timeline.rows + .filter((row) => row.item.type === "assistant_message") + .map((row) => (row.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text); +} + describe("daemon E2E (real claude) - send message during tool call", () => { let canRun = false; interface SteeringResources { @@ -504,6 +664,141 @@ describe("daemon E2E (real claude) - send message during tool call", () => { } }, 210_000); + test("a background subagent keeps running after a message is sent while it works", async () => { + // The scenario the rest of this file explicitly rules out: every other test here tells Claude + // "Do not use a background task", so steering during a backgrounded subagent was never + // covered. A subagent streams on the parent's channel, so a parent that mistakes the child's + // frames for its own work reports "running" after it has already finished, and the message + // sent into that window is refused, or delivered by cancelling the turn and taking the + // subagent with it. Both outcomes are user-visible, and both are what this asserts against. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Claude background-subagent test client was not created"); + await within("connect background-subagent test client", 15_000, client.connect()); + await within( + "subscribe background-subagent test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create background-subagent test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-background-survives-send", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + await within( + "launch one background subagent and end the turn", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor".`, + `prompt: "Use the Bash tool with run_in_background: true to run exactly: sleep 40.`, + `Wait for its completion notification, then reply exactly: CHILD_DONE."`, + "Immediately after launching it, reply with the single word LAUNCHED and end your turn.", + "Do not wait for it. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // The main session has ended its turn. It must read as idle even though the subagent it + // launched is still streaming on the shared channel. + const afterLaunch = await within( + "wait for the main session to go idle after launching the subagent", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect( + afterLaunch.status, + "a main session that finished its turn must not report busy because a subagent is running", + ).not.toBe("running"); + + // And a message sent in that window must be accepted rather than refused. + const followUpId = generateClientMessageId(); + await within( + "send a message to the idle main session while the subagent runs", + 30_000, + client.sendAgentMessage(agent.id, "Are you there? Reply exactly: MAIN_ALIVE.", { + messageId: followUpId, + }), + ); + await within( + "wait for the main session to answer", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + + const timeline = await within( + "fetch background-subagent timeline", + 15_000, + client.fetchAgentTimeline(agent.id, { limit: 200 }), + ); + const assistantText = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map( + (entry) => (entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text, + ) + .join("\\n"); + expect( + assistantText, + "the main session must be reachable while its background subagent still runs", + ).toContain("MAIN_ALIVE"); + + // The subagent must have survived the send rather than being cancelled with the turn. Its + // status is read from agent.provider_subagents.list (see the note above + // waitForProviderSubagentToExist): agent_stream carries no provider_subagent events. + const { subagents } = await within( + "list provider subagents after the send", + 15_000, + client.listProviderSubagents(agent.id), + ); + expect(subagents.length, "the background subagent must be tracked").toBeGreaterThan(0); + expect( + subagents.map((subagent) => subagent.status), + "a backgrounded subagent must not be canceled by a message sent to the main session", + ).not.toContain("canceled"); + const { subagent, history } = await within( + "wait for the background subagent to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect(history, "no status poll may read canceled").not.toContain("canceled"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Claude background-subagent E2E cleanup failures").toEqual([]); + } + }, 300_000); + test("Stop cancels a queued Claude steer before it can resume the interrupted turn", async () => { const logger = pino({ level: "silent" }); const cwd = tmpCwd(); @@ -678,4 +973,430 @@ describe("daemon E2E (real claude) - send message during tool call", () => { rmSync(cwd, { recursive: true, force: true }); } }, 300_000); + + test("a helper's own nested background job does not phantom-run the parent or kill the helper", async () => { + // The exact reported bug: main backgrounds a helper, the helper itself starts its OWN + // background job (a grandchild from main's perspective) and then keeps working in the + // foreground. The grandchild's completion is bookkeeping that belongs to the helper, not to + // main, and must not make main phantom-report "running" or get treated as busy. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("nested background-job test client was not created"); + await within("connect nested background-job test client", 15_000, client.connect()); + await within( + "subscribe nested background-job test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create nested background-job test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-nested-background-job", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch a helper with its own nested background job and end the turn", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "nested bg survivor".`, + `prompt: "Use the Bash tool with run_in_background: true to run exactly:`, + `python3 -c 'import time; time.sleep(5)'.`, + `Immediately after starting it, without waiting for it, use the Bash tool again`, + `in the foreground to run exactly: python3 -c 'import time; time.sleep(50)'.`, + `After that foreground command finishes, reply exactly: CHILD_DONE."`, + "Immediately after launching it, reply with the single word LAUNCHED and end your turn.", + "Do not wait for it. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + const afterLaunch = await within( + "wait for the main session to go idle after launching the nested-background helper", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect(afterLaunch.status, "the main session must be idle once it replied LAUNCHED").toBe( + "idle", + ); + + // The helper's own nested background job (5s) finishes while the helper is still busy + // in its unrelated foreground sleep (50s). Sample for 30s, well past the nested job's + // completion even when the helper is slow to start it, + // both by polling and by scanning every pushed agent_update in the window, so a brief + // phantom flip cannot slip between polls. + const sampleWindowStart = resources.collector.messages.length; + const polledStatuses = await sampleAgentStatuses(client, agent.id, 11, 3_000); + const pushedStatuses = getAgentStatuses( + resources.collector.messages.slice(sampleWindowStart), + agent.id, + ); + const observedRunning = [...polledStatuses, ...pushedStatuses].filter( + (status) => status === "running", + ); + expect( + observedRunning, + `the parent must stay idle across the nested job's completion; polled=${JSON.stringify(polledStatuses)} pushed=${JSON.stringify(pushedStatuses)}`, + ).toEqual([]); + + // A message sent in that window must still be answered normally. + await within( + "send PONG request while the nested job settles", + 30_000, + client.sendMessage(agent.id, "Reply with exactly PONG.", { + messageId: generateClientMessageId(), + }), + ); + await within("wait for the PONG reply", 60_000, client.waitForFinish(agent.id, 50_000)); + // Pulled via fetchAgentTimeline, not the push collector: assistant text delivery is paced + // (docs/agent-stream-performance.md) and can trail waitForFinish's own completion signal, + // so the collector can still be missing the final text the instant waitForFinish resolves. + const pongTexts = await within( + "fetch timeline for the PONG reply", + 15_000, + fetchAssistantTexts(client, agent.id, 20), + ); + expect( + pongTexts.join("\n"), + "the agent must answer PONG while the helper is still running", + ).toContain("PONG"); + + const messagesBeforeWake = resources.collector.messages.length; + + // The helper itself must survive: never recorded canceled, and it eventually reports + // CHILD_DONE from its own foreground sleep finishing. + const { subagent, history } = await within( + "wait for the nested-background helper to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + + // The helper's completion should wake the main session with a fresh turn (the + // session-state-driven autonomous turn), separate from the PONG exchange above. + const wokeMainAgent = await waitForWakeTurn( + resources.collector, + agent.id, + messagesBeforeWake, + ); + if (!wokeMainAgent) { + const wakeWindowMessages = resources.collector.messages.slice(messagesBeforeWake); + const wakeWindowEventTypes = wakeWindowMessages + .filter( + (message) => message.type === "agent_stream" && message.payload.agentId === agent.id, + ) + .map((message) => + message.type === "agent_stream" ? message.payload.event.type : "unknown", + ); + const timeline = await client + .fetchAgentTimeline(agent.id, { limit: 100 }) + .catch(() => null); + throw new Error( + `the helper's completion must wake the main session with a new turn. ` + + `wakeWindowEventTypes=${JSON.stringify(wakeWindowEventTypes)} ` + + `recentTimeline=${JSON.stringify(timeline ? summarizeTimelineItems(timeline) : null)}`, + ); + } + + expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe( + "idle", + ); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "nested background-job E2E cleanup failures").toEqual([]); + } + }, 300_000); + + test("the app's default steer survives a backgrounded helper while the main session works", async () => { + // appSettings.sendBehavior defaults to "steer" (packages/app/src/hooks/use-settings/storage.ts), + // so this is what the app actually sends when a user replies while an agent looks busy. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("default-steer background-helper test client was not created"); + await within("connect default-steer test client", 15_000, client.connect()); + await within( + "subscribe default-steer test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create default-steer test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-default-steer-background-helper", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch the background helper and start the main session's own foreground sleep", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor b".`, + `prompt: "Use the Bash tool in the foreground to run exactly:`, + `python3 -c 'import time; time.sleep(42)'. After it finishes, reply exactly: CHILD_DONE."`, + "Do not wait for that background helper to finish before continuing.", + "After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,", + "to run exactly: python3 -c 'import time; time.sleep(16)'.", + // Matching the proven pattern from the first test in this file: tell Claude to wait + // for a later message rather than relying on the sleep call itself to block, since a + // Bash call can resolve through Claude Code's own async task_notification path once a + // background subagent is already active instead of blocking the turn. + "Do not reply or finish your turn until a later user message arrives, no matter how", + "quickly that Bash command finishes. When that later message arrives, address it and", + "end your turn. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // Confirm the daemon considers main's own turn active before steering it. This does not + // wait for a specific "sleep 16" shell call to show as running: Claude Code 2.1.280 can + // report a slower Bash call through its own async task_notification tool instead of a + // classic blocking call once a background subagent is already active, so pinning to a + // literal running shell tool_call is not reliable here. Busy at the daemon level is what + // "while main is busy with its own sleep" actually needs. + await within( + "confirm main's own turn is active before steering", + 30_000, + client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 25_000), + ); + // The running status precedes the Agent tool call, so a steer sent on it alone can reach + // Claude before the helper exists and prove nothing about a helper surviving it. + await within( + "confirm the background helper is registered before steering", + 60_000, + waitForProviderSubagentToExist(client, agent.id, 55_000), + ); + + await within( + "send the app's default steer while main is busy with its own sleep", + 30_000, + client.sendMessage(agent.id, "Reply with exactly PONG.", { + messageId: generateClientMessageId(), + activeTurnBehavior: "steer", + }), + ); + + const finish = await within( + "wait for the main session to finish after the steer", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect(finish.status, "the main session must end idle after the steer").toBe("idle"); + // Pulled via fetchAgentTimeline, not the push collector: see fetchAssistantTexts. + const postSteerTexts = await within( + "fetch timeline for the post-steer PONG reply", + 15_000, + fetchAssistantTexts(client, agent.id, 20), + ); + expect(postSteerTexts.join("\n"), "PONG must be answered after the steer").toContain("PONG"); + + const { subagent, history } = await within( + "wait for the background helper to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "default-steer background-helper E2E cleanup failures").toEqual([]); + } + }, 300_000); + + test("Stop cancels the main session's own turn but spares its background helper", async () => { + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Stop-spares-helper test client was not created"); + await within("connect Stop-spares-helper test client", 15_000, client.connect()); + await within( + "subscribe Stop-spares-helper test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create Stop-spares-helper test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-stop-spares-helper", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch the background helper and start the main session's own foreground sleep", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor c".`, + `prompt: "Use the Bash tool in the foreground to run exactly:`, + `python3 -c 'import time; time.sleep(43)'. After it finishes, reply exactly: CHILD_DONE."`, + "Do not wait for that background helper to finish before continuing.", + "After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,", + "to run exactly: python3 -c 'import time; time.sleep(23)'.", + "Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // Confirm the helper has actually been launched (registered) before Stop, not just that + // main's turn shows "running": that status can flip true on generic turn-start bookkeeping + // before Claude has issued the Agent tool call, and canceling that early cancels a turn + // with no helper to spare yet. + await within( + "confirm the background helper is registered before Stop", + 60_000, + waitForProviderSubagentToExist(client, agent.id, 55_000), + ); + + const messagesBeforeStop = resources.collector.messages.length; + await within("Stop the main session's own turn", 30_000, client.cancelAgent(agent.id)); + const afterStop = await within( + "wait for the agent to go idle after Stop", + 60_000, + client.waitForFinish(agent.id, 50_000), + ); + expect(afterStop.status, "Stop must leave the agent idle").toBe("idle"); + + // The helper must survive Stop: it keeps running and later reports CHILD_DONE, which + // wakes the main session with a fresh autonomous turn. + const { subagent, history } = await within( + "wait for the background helper to finish after Stop", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "Stop must not cancel or fail the background helper").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + + const wokeMainAgent = await waitForWakeTurn( + resources.collector, + agent.id, + messagesBeforeStop, + ); + expect( + wokeMainAgent, + "the helper's completion after Stop must wake the main session with a new turn", + ).toBe(true); + + expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe( + "idle", + ); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Stop-spares-helper E2E cleanup failures").toEqual([]); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index 19ccdf45e8..80c9409cef 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -244,3 +244,28 @@ async function observeScriptLifecycles( await subscription.ready; return lifecycles; } + +test.runIf(process.platform !== "win32")( + "a missing command fails creation without leaving a terminal listed", + async () => { + const workspaceId = await createWorkspace("Missing command"); + await expect( + sdk.terminals.create({ + workspaceId, + name: "Missing command", + command: "paseo-test-absent-terminal-command", + }), + ).rejects.toThrow("paseo-test-absent-terminal-command: command not found"); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); + const control = await sdk.terminals.create({ + workspaceId, + command: process.execPath, + args: ["-e", "process.stdin.resume()"], + }); + expect((await sdk.terminals.list({ workspaceId })).entries.map((entry) => entry.id)).toEqual([ + control.id, + ]); + await control.kill(); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); + }, +); diff --git a/packages/server/src/server/daemon-instance.test.ts b/packages/server/src/server/daemon-instance.test.ts index 9d7ea66c5a..ee428b2e58 100644 --- a/packages/server/src/server/daemon-instance.test.ts +++ b/packages/server/src/server/daemon-instance.test.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir, uptime } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js"; import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js"; @@ -94,3 +94,42 @@ describe("daemon instance identity across a reboot", () => { await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/); }); }); + +// Linux keeps a boot's id while a VM is paused for a host sleep, though the VM's wall clock +// runs ahead of its uptime once it resumes. A sleep longer than the time between boot and +// the supervisor's start puts the wall-clock boot instant after the lock's startedAt. +describe.runIf(process.platform === "linux")("daemon instance identity on Linux", () => { + let paseoHome: string; + + beforeEach(async () => { + paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-linux-")); + }); + + afterEach(async () => { + vi.useRealTimers(); + await rm(paseoHome, { recursive: true, force: true }); + }); + + test("a supervisor still holds the lock after its paused VM resumes", async () => { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid }); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + uptime() * 1000 + 12 * 60 * 60_000); + + expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid }); + expect(await isLocked(paseoHome)).toMatchObject({ locked: true }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + }); + + test("a lock written during another boot has no running owner", async () => { + await writeLock(paseoHome, { + ...lockFor(process.pid, new Date()), + bootId: "00000000-0000-0000-0000-000000000000", + }); + + expect(await readDaemonInstance(paseoHome)).toBeNull(); + expect(await isLocked(paseoHome)).toMatchObject({ locked: false }); + }); +}); diff --git a/packages/server/src/server/directory-sync/index.ts b/packages/server/src/server/directory-sync/index.ts index 1b08360cb7..e16e20302f 100644 --- a/packages/server/src/server/directory-sync/index.ts +++ b/packages/server/src/server/directory-sync/index.ts @@ -69,27 +69,29 @@ export class DirectorySyncService { synchronizeWorkspaces( snapshot: Iterable<WorkspaceDescriptorPayload>, cursor: DirectorySyncCursor, + includeBackground = false, ): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> { this.workspaces.replaceAll(snapshot); - const read = this.read(this.workspaces, cursor); + const read = this.read(this.workspaces, cursor, includeBackground); return { entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), emptyProjects: [], pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - sync: this.metadata(read), + sync: this.metadata(read, includeBackground), }; } synchronizeAgents( snapshot: Iterable<AgentDirectoryEntry>, cursor: DirectorySyncCursor, + includeBackground = false, ): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> { this.agents.replaceAll(snapshot); - const read = this.read(this.agents, cursor); + const read = this.read(this.agents, cursor, includeBackground); return { entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - sync: this.metadata(read), + sync: this.metadata(read, includeBackground), }; } @@ -111,13 +113,19 @@ export class DirectorySyncService { value: WorkspaceDescriptorPayload | null, id: string, includeSequence: boolean, + includeBackground = false, ): T & Partial<DirectoryVersion> { if (value) { this.workspaces.replace(value); } else { this.workspaces.remove(id); } - return this.withVersion(payload, includeSequence, this.version(this.workspaces, id)); + return this.withVersion( + payload, + includeSequence, + this.version(this.workspaces, id), + includeBackground, + ); } sequenceAgentUpdate<T extends object>( @@ -125,18 +133,24 @@ export class DirectorySyncService { value: AgentDirectoryEntry | null, id: string, includeSequence: boolean, + includeBackground = false, ): T & Partial<DirectoryVersion> { if (value) { this.agents.replace(value); } else { this.agents.remove(id); } - return this.withVersion(payload, includeSequence, this.version(this.agents, id)); + return this.withVersion( + payload, + includeSequence, + this.version(this.agents, id), + includeBackground, + ); } - private metadata(read: CollectionRead<unknown>) { + private metadata(read: CollectionRead<unknown>, includeBackground = false) { return { - generation: this.generation, + generation: this.generationFor(includeBackground), headSeq: read.headSeq, mode: read.mode, ...(read.reason ? { reason: read.reason } : {}), @@ -148,15 +162,19 @@ export class DirectorySyncService { payload: T, includeSequence: boolean, version: DirectoryVersion | null, + includeBackground = false, ): T & Partial<DirectoryVersion> { - return version && includeSequence ? { ...payload, ...version } : payload; + return version && includeSequence + ? { ...payload, ...version, generation: this.generationFor(includeBackground) } + : payload; } private read<T>( collection: VersionedCollection<T>, cursor: DirectorySyncCursor, + includeBackground = false, ): CollectionRead<T> { - if (cursor.generation !== this.generation) { + if (cursor.generation !== this.generationFor(includeBackground)) { return collection.readSnapshot( cursor.generation === undefined ? "no_cursor" : "generation_changed", ); @@ -167,6 +185,10 @@ export class DirectorySyncService { return collection.readAfter(cursor.afterSeq); } + private generationFor(includeBackground: boolean): string { + return includeBackground ? `${this.generation}:background` : this.generation; + } + private version<T>(collection: VersionedCollection<T>, id: string): DirectoryVersion | null { const seq = collection.sequenceFor(id); return seq === null ? null : { generation: this.generation, seq }; diff --git a/packages/server/src/server/file-download/content-disposition.test.ts b/packages/server/src/server/file-download/content-disposition.test.ts new file mode 100644 index 0000000000..46748c21ef --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { formatAttachmentContentDisposition } from "./content-disposition.js"; + +describe("formatAttachmentContentDisposition", () => { + test.each([ + { fileName: "report.txt", disposition: 'attachment; filename="report.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "Résumé Ångström.pdf", + disposition: + "attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + { + fileName: 'say "hi" \\ bye.txt', + disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"', + }, + { + fileName: "100%25 done.txt", + disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt", + }, + { + fileName: "l'été (1)*.txt", + disposition: + "attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt", + }, + ])("$fileName", ({ fileName, disposition }) => { + expect(formatAttachmentContentDisposition(fileName)).toBe(disposition); + }); +}); diff --git a/packages/server/src/server/file-download/content-disposition.ts b/packages/server/src/server/file-download/content-disposition.ts new file mode 100644 index 0000000000..1ad7997b7c --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.ts @@ -0,0 +1,32 @@ +const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g; +const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/; +const QUOTED_STRING_SPECIALS = /[\\"]/g; +const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g; + +/** + * Content-Disposition for a file download (RFC 6266). + * + * Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because + * browsers do not read raw Latin-1 bytes in `filename` and fall back to a name + * taken from the URL. A name with a percent escape gets it too, so browsers do + * not decode the escape in `filename`. + */ +export function formatAttachmentContentDisposition(fileName: string): string { + const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?"); + const disposition = `attachment; filename=${quote(asciiFileName)}`; + if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) { + return disposition; + } + return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`; +} + +function quote(value: string): string { + return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`; +} + +function encodeExtendedValue(value: string): string { + return encodeURIComponent(value).replace( + NON_ATTR_CHARS_LEFT_BY_URI_ENCODING, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index ea22ac24b9..a82e1a8f80 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -12,8 +12,8 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { runGitCommand } from "../../utils/run-git-command.js"; +import { describe, expect, it, vi } from "vitest"; +import { runGitCommand, snapshotGitCommandRuntimeMetrics } from "../../utils/run-git-command.js"; import { createExplorerEntry, deleteExplorerEntry, @@ -517,6 +517,129 @@ describe("file explorer service", () => { } }); + it("renames an untracked bracketed filename without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-entry-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "i.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i.tsx"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "untracked\n", "utf8"); + + snapshotGitCommandRuntimeMetrics(); + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(snapshotGitCommandRuntimeMetrics().operationsTop).toEqual([ + ["ls-files", 1], + ["rev-parse", 1], + ]); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("renames an untracked bracketed folder without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-folder-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.join(root, "i")); + await writeFile(path.join(root, "i", "route.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i"], { cwd: root }); + await mkdir(path.join(root, "[id]")); + await writeFile(path.join(root, "[id]", "route.tsx"), "untracked\n", "utf8"); + + const result = await renameExplorerEntry({ root, relativePath: "[id]", name: "route" }); + + expect(result).toEqual({ status: "ok", path: "route" }); + expect(await readFile(path.join(root, "route", "route.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id]"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i", "route.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i/route.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("uses Git to rename a tracked bracketed filename and preserves its tracked sibling", async () => { + const root = await createTempDir("paseo-tracked-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "--", "[id].tsx", "i.tsx"], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + "i.tsx\0renamed.tsx\0", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it.each([ + { + sourcePath: "[id].tsx", + sourceFile: "[id].tsx", + targetName: "renamed.tsx", + targetFile: "renamed.tsx", + }, + { + sourcePath: "[id]", + sourceFile: "[id]/route.tsx", + targetName: "route", + targetFile: "route/route.tsx", + }, + ])( + "keeps tracked renames in the index with inherited literal pathspecs ($sourcePath)", + async ({ sourcePath, sourceFile, targetName, targetFile }) => { + const root = await createTempDir("paseo-inherited-literal-rename-"); + try { + vi.stubEnv("GIT_LITERAL_PATHSPECS", "1"); + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.dirname(path.join(root, sourceFile)), { recursive: true }); + await writeFile(path.join(root, sourceFile), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "."], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: sourcePath, + name: targetName, + }); + + expect(result).toEqual({ status: "ok", path: targetName }); + expect(await readFile(path.join(root, targetFile), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, sourcePath))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + `i.tsx\0${targetFile}\0`, + ); + expect(process.env.GIT_LITERAL_PATHSPECS).toBe("1"); + } finally { + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + } + }, + ); + it("supports case-only renames for tracked and untracked files", async () => { const root = await createTempDir("paseo-entry-case-rename-"); try { diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 5aa4efe974..d99c97616b 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -489,7 +489,7 @@ export async function writeExplorerFile({ const temporaryPath = path.join( path.dirname(filePath.resolvedPath), - `.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`, + `.paseo-${randomUUID()}.tmp`, ); let temporaryHandle: FileHandle | null = null; try { @@ -669,6 +669,7 @@ export async function duplicateExplorerEntry({ errorOnExist: true, force: false, preserveTimestamps: true, + verbatimSymlinks: true, }); return { status: "ok", path: normalizeRelativePath({ root, targetPath }) }; } catch (error) { @@ -724,7 +725,11 @@ export async function renameExplorerEntry({ }); const tracked = repository.exitCode === 0 - ? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot }) + ? await runGitCommand(["ls-files", "-z", "--", `:(literal)${sourcePath}`], { + cwd: realRoot, + // Inherited literal mode disables the :(literal) pathspec magic. + envOverlay: { GIT_LITERAL_PATHSPECS: undefined }, + }) : null; if (tracked?.stdout) { await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot }); @@ -820,8 +825,9 @@ async function resolveScopedPath({ function assertWithinWorkspace(root: string, candidate: string): void { const relative = path.relative(root, candidate); - if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return; - throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); + const isOutsideWorkspace = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (isOutsideWorkspace) throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); } async function openFileForRead(filePath: string): Promise<FileHandle> { diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 711b826348..95fe416c54 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -236,7 +236,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and const deletions = expectFileChanges(removedPaths, "delete"); const movedFiles = expectFileChanges(movedPaths); const recovery = expectFileChanges([sentinel]); - const subscription = await subscribeToFileChanges(root, (error, events) => { + const observer = createObserver(); + const subscription = await observer.subscribe(root, (error, events) => { expect(error).toBeNull(); for (const event of events) { population.record(event); @@ -254,9 +255,18 @@ test("observes a thousand concurrent writes and remains healthy after delete and await Promise.all(paths.map((path, index) => writeFile(path, `${index}`))); await writes.complete; - // A native watcher may recover coalesced paths through its safety audit. - // Completion comes from the delivered deletions, not an idle diagnostic or - // a deadline shorter than that recovery cycle. + // The first notice for each write can arrive before its remaining + // classifications. Drain those deliveries before starting a separate deletion + // phase, so a late create cannot coalesce with the expected delete as update. + await expect + .poll(() => observer.getDiagnostics(), { timeout: 10_000 }) + .toMatchObject({ + pendingEventCount: 0, + pendingClassificationCount: 0, + reconciliationInFlightCount: 0, + }); + + // Completion still requires the delivered deletion notices. await Promise.all(removedPaths.map((path) => rm(path))); await deletions.complete; diff --git a/packages/server/src/server/git-filename-diffs.e2e.test.ts b/packages/server/src/server/git-filename-diffs.e2e.test.ts new file mode 100644 index 0000000000..c32ee040b8 --- /dev/null +++ b/packages/server/src/server/git-filename-diffs.e2e.test.ts @@ -0,0 +1,100 @@ +import { test, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js"; + +test("a rename-only commit returns zero added or deleted lines through the client", async () => { + const cwd = mkdtempSync(join(tmpdir(), "commit-rename-diff-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "old.txt"), "same\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "initial"); + git("mv", "old.txt", "new.txt"); + git("-c", "commit.gpgsign=false", "commit", "-m", "rename"); + const sha = git("rev-parse", "HEAD"); + + await client.connect(); + const history = await client.listCheckoutCommits(cwd); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: "new.txt", additions: 0, deletions: 0, status: "renamed" }, + ]); + expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({ + file: { + path: "new.txt", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }, + }); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test.skipIf(process.platform === "win32")( + "checkout and commit diffs preserve filenames that Git quotes", + async () => { + const cwd = mkdtempSync(join(tmpdir(), "git-filename-diffs-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const names = [ + 'report "draft".txt', + "back\\slash.txt", + "tab\tname.txt", + "normal.txt", + "with spaces.txt", + ]; + const editLines = expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.quotepath", "false"); + for (const name of names) writeFileSync(join(cwd, name), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "initial"); + for (const name of names) writeFileSync(join(cwd, name), "after\n"); + + await client.connect(); + const checkout = await client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + expect(checkout.error).toBeNull(); + expect(checkout.files.map((file) => file.path).sort()).toEqual([...names].sort()); + for (const file of checkout.files) { + expect(file).toMatchObject({ status: "ok", additions: 1, deletions: 1 }); + expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines); + } + + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const sha = git("rev-parse", "HEAD"); + for (const name of names) { + const commit = await client.getCommitFileDiff(cwd, sha, name); + expect(commit.file).toMatchObject({ path: name, additions: 1, deletions: 1 }); + expect(commit.file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines); + const file = await client.readFile(cwd, name); + expect(file.kind).toBe("text"); + expect(new TextDecoder().decode(file.bytes)).toBe("after\n"); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/hub/daemon-executions.ts b/packages/server/src/server/hub/daemon-executions.ts index a619be18a8..ec629c3fc9 100644 --- a/packages/server/src/server/hub/daemon-executions.ts +++ b/packages/server/src/server/hub/daemon-executions.ts @@ -205,7 +205,6 @@ export class DaemonExecutions implements HubExecutionAgents { } : {}), worktree: toCreateAgentWorktree(input.worktree), - background: true, notifyOnFinish: false, owner, onWorktreeCreated: (worktree) => { diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index 51596b2384..d994a7280e 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -773,8 +773,12 @@ describe("Hub relationship", () => { const second = await relationship.ownedCreateResult("second-create"); expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId); - expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } }); - expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } }); + expect(first).toMatchObject({ + payload: { success: true, executionId: "first-execution", error: null }, + }); + expect(second).toMatchObject({ + payload: { success: true, executionId: "second-execution", error: null }, + }); expect(await relationship.durableOwnedAgentIds()).toHaveLength(2); }); diff --git a/packages/server/src/server/local-service-connection.ts b/packages/server/src/server/local-service-connection.ts new file mode 100644 index 0000000000..a96a0a7094 --- /dev/null +++ b/packages/server/src/server/local-service-connection.ts @@ -0,0 +1,27 @@ +import http from "node:http"; +import net from "node:net"; + +// A host can support IPv6 loopback without listing ::1 for localhost in DNS. +const lookupLoopback: net.LookupFunction = (_hostname, _options, callback) => { + callback(null, [ + { address: "127.0.0.1", family: 4 }, + { address: "::1", family: 6 }, + ]); +}; + +export function connectToLocalService(port: number): net.Socket { + return net.connect({ host: "localhost", port, autoSelectFamily: true, lookup: lookupLoopback }); +} + +export function requestLocalService( + options: Pick<http.RequestOptions, "port" | "path" | "method" | "headers">, + onResponse: (response: http.IncomingMessage) => void, +): http.ClientRequest { + const requestOptions = { + ...options, + hostname: "localhost", + autoSelectFamily: true, + lookup: lookupLoopback, + }; + return http.request(requestOptions, onResponse); +} diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index cbcef548e4..a5120aa5ff 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -791,13 +791,14 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename createDeps(), ); + // baseRefName must be the repo's default branch, not the checked-out branch + // itself — otherwise the diff panel compares the branch against itself and + // always shows no changes. expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, - baseRefName: "dev", + baseRefName: "main", }); - // A checkout-branch worktree has no distinct base, so the workspace records a - // null baseBranch even though worktree.json's baseRefName is the branch itself. - expect(created.workspace.baseBranch).toBe(null); + expect(created.workspace.baseBranch).toBe("main"); await expect( attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 247fe46c18..94e82d5e95 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -33,6 +33,8 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { workspaceId?: string; projectId?: string; title?: string; + background?: boolean; + callerWorkspaceId?: string; } export interface CreatePaseoWorktreeResult { @@ -101,6 +103,8 @@ async function createPaseoWorktreeWithPriority( baseBranch: createdWorktree.worktree.comparisonBaseRef, title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext), expectsInitialAgent: Boolean(input.firstAgentContext), + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, ...(createdWorktree.intent.kind === "checkout-change-request" && createdWorktree.intent.headRepository ? { diff --git a/packages/server/src/server/persisted-config-json-schema.test.ts b/packages/server/src/server/persisted-config-json-schema.test.ts new file mode 100644 index 0000000000..f943f859ec --- /dev/null +++ b/packages/server/src/server/persisted-config-json-schema.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import Ajv from "ajv"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { PersistedConfigSchema } from "./persisted-config.js"; + +const publishedSchema = JSON.parse( + readFileSync( + new URL("../../../website/public/schemas/paseo.config.v1.json", import.meta.url), + "utf8", + ), +); + +test("editors accept the daemon's browser, profile, plugin and metadata configuration", () => { + const config = { + $schema: "https://paseo.sh/schemas/paseo.config.v1.json", + version: 1, + daemon: { + browserTools: { enabled: true }, + terminalProfiles: [], + agentProfiles: [], + }, + pluginsEnabled: true, + plugins: {}, + agents: { + metadataGeneration: { providers: [{ provider: "claude", model: "claude-opus-5" }] }, + }, + }; + expect(PersistedConfigSchema.parse(config)).toEqual(config); + const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema); + const valid = validate(config); + expect(validate.errors).toBeNull(); + expect(valid).toBe(true); +}); + +test("the published schema stays current with the daemon's configuration schema", () => { + const generated = z.toJSONSchema(PersistedConfigSchema, { + target: "draft-07", + unrepresentable: "any", + io: "input", + }); + generated.title = "PaseoConfigV1"; + expect( + publishedSchema, + "Run npm run generate:config-schema --workspace=@getpaseo/server", + ).toEqual(generated); +}); + +test("editors retain the existing proxy configuration help", () => { + const daemon = publishedSchema.properties.daemon.properties; + expect(daemon.trustedProxies.description).toBe( + 'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.', + ); + expect(daemon.serviceProxy.properties.enabled.description).toBe( + "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + ); + expect(daemon.serviceProxy.properties.listen.description).toBe( + "Optional service-only listener address. Presence enables the standalone service proxy listener.", + ); + expect(daemon.serviceProxy.properties.publicBaseUrl.description).toBe( + "Optional public base URL for service host aliases.", + ); +}); + +test.each([ + { daemon: { browserTools: { enabled: "yes" } } }, + { daemon: { terminalProfiles: "default" } }, + { daemon: { agentProfiles: "default" } }, + { pluginsEnabled: "yes" }, + { plugins: [] }, + { agents: { metadataGeneration: { providers: [{ provider: "" }] } } }, + { unknownSetting: true }, +])("editors still reject invalid configuration: %j", (config) => { + expect(PersistedConfigSchema.safeParse(config).success).toBe(false); + const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema); + expect(validate(config)).toBe(false); +}); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index f443f84865..6b23a6f365 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -1,4 +1,14 @@ -import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; @@ -664,6 +674,19 @@ describe("PersistedConfigSchema voice mode config", () => { expect(parsed.features?.dictation?.stt?.language).toBe("fr"); expect(parsed.features?.voiceMode?.stt?.language).toBe("de"); }); + + test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => { + const parsed = PersistedConfigSchema.parse({ + features: { + voiceMode: { + tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "aiden" }, + }, + }, + }); + + expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0"); + expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden"); + }); }); describe("loadPersistedConfig", () => { @@ -843,3 +866,35 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); }); + +describe.skipIf(process.platform === "win32")( + "config.json symlinked from another directory", + () => { + test("saving writes through the symlink and leaves it in place", () => { + const parent = createTempHome(); + const home = path.join(parent, "home"); + const dotfiles = path.join(parent, "dotfiles"); + const linkedConfigPath = path.join(dotfiles, "config.json"); + const configPath = path.join(home, "config.json"); + try { + mkdirSync(home); + mkdirSync(dotfiles, { mode: 0o755 }); + chmodSync(dotfiles, 0o755); + writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); + symlinkSync(linkedConfigPath, configPath); + + savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } }); + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + expect(modeOf(dotfiles)).toBe(0o755); + expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + }, +); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f924a88ebc..11d94495b1 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -144,7 +144,8 @@ const FeatureVoiceModeSchema = z .object({ provider: SpeechProviderIdSchema.optional(), model: z.string().min(1).optional(), - voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(), + // Free-form: self-hosted OpenAI-compatible servers expose their own voice names. + voice: z.string().trim().min(1).optional(), speakerId: z.number().int().optional(), speed: z.number().optional(), }) @@ -237,7 +238,12 @@ export const PersistedConfigSchema = z listen: z.string().optional(), hostnames: z.union([z.literal(true), z.array(z.string())]).optional(), allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(), - trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(), + trustedProxies: z + .union([z.literal(true), z.array(z.string())]) + .optional() + .describe( + 'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.', + ), mcp: z .object({ enabled: z.boolean().optional(), @@ -284,9 +290,22 @@ export const PersistedConfigSchema = z // COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02. // Parsed only to suppress optional public/listen layers for old configs; // localhost service proxying remains always enabled. - enabled: z.boolean().optional(), - listen: z.string().optional(), - publicBaseUrl: z.url().optional(), + enabled: z + .boolean() + .optional() + .describe( + "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + ), + listen: z + .string() + .optional() + .describe( + "Optional service-only listener address. Presence enables the standalone service proxy listener.", + ), + publicBaseUrl: z + .url() + .optional() + .describe("Optional public base URL for service host aliases."), }) .strict() .optional(), @@ -308,7 +327,6 @@ export const PersistedConfigSchema = z providers: ProvidersSchema.optional(), pluginRegistries: PluginRegistriesSchema.optional(), - pluginRegistryEnabled: z.boolean().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index db95d4786d..314b44ea47 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { open, readFile, stat, unlink, utimes } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { ensurePrivateDirectory } from "./private-files.js"; @@ -15,6 +16,7 @@ export const pidLockInfoSchema = z.object({ serverId: z.string().nullable().optional(), desktopManaged: z.boolean().optional(), heartbeat: z.literal(true).optional(), + bootId: z.string().optional(), }); export interface PidLockInfo extends z.infer<typeof pidLockInfoSchema> {} @@ -62,16 +64,38 @@ function precedesThisBoot(startedAt: string): boolean { return stamped < Date.now() - uptime() * 1000 - BOOT_INSTANT_TOLERANCE_MS; } +let cachedBootId: string | null | undefined; + +// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while +// its host sleeps keeps its uptime, so after it resumes the instant lands after locks its +// running supervisor wrote. +function currentBootId(): string | null { + if (cachedBootId === undefined) { + try { + cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null; + } catch { + cachedBootId = null; + } + } + return cachedBootId; +} + +function writtenDuringThisBoot(lock: PidLockInfo): boolean { + const thisBoot = currentBootId(); + if (lock.bootId && thisBoot) return lock.bootId === thisBoot; + return !precedesThisBoot(lock.startedAt); +} + /** * Whether the process that wrote this lock is still running. * * A PID alone does not identify the supervisor: the operating system hands the number to * something else once the supervisor is gone, and a reboot reassigns it freely. A process - * cannot predate the boot it runs under, so a lock stamped before this boot is abandoned + * cannot predate the boot it runs under, so a lock written during another boot is abandoned * however alive its PID looks. */ export function isPidLockOwnerRunning(lock: PidLockInfo): boolean { - if (precedesThisBoot(lock.startedAt)) return false; + if (!writtenDuringThisBoot(lock)) return false; return isPidRunning(lock.pid); } @@ -214,6 +238,7 @@ export async function acquirePidLock( } // Create new lock with exclusive flag + const bootId = currentBootId(); const lockInfo: PidLockInfo = { pid: lockOwnerPid, startedAt: new Date().toISOString(), @@ -221,6 +246,7 @@ export async function acquirePidLock( uid: process.getuid?.() ?? 0, listen, heartbeat: true, + ...(bootId ? { bootId } : {}), ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index c14c027853..cf6ffe9eee 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -9,6 +9,7 @@ export const builtinPlugins = [ "copilot-usage-source", "cursor-usage-source", "grok-usage-source", + "hoplite-provider", "kimi-usage-source", "minimax-usage-source", "muse-provider", diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index facba48fdb..fc86f85db3 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -138,6 +138,51 @@ async function createRootAlias(directory: string): Promise<string> { } describe("plugin runtime entries", () => { + it.each([ + 'import type { AgentTimelineItem, ToolCallDetail } from "@getpaseo/protocol/agent-types"; export type Detail = ToolCallDetail; export type Item = AgentTimelineItem;', + 'import { type AgentTimelineItem } from "@getpaseo/protocol/agent-types"; export type Item = AgentTimelineItem;', + 'export type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";', + 'export type Item = import("@getpaseo/protocol/agent-types").AgentTimelineItem;', + 'export type { AgentAttachment } from "@getpaseo/protocol/messages";', + ])("compiles protocol types without local dependencies: %s", async (typeSource) => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared", "labels.ts"), + `${typeSource}\nexport const clientLabel = "Client contribution"; export const serverLabel = "Server contribution";`, + ); + + const result = await compilePlugin(entries); + + expect(result.clientBundle).toContain("Client contribution"); + expect(result.serverBundle).toContain("Server contribution"); + expect(result.clientBundle).not.toContain("@getpaseo/protocol"); + expect(result.serverBundle).not.toContain("@getpaseo/protocol"); + }); + + it("still requires local dependencies for protocol runtime imports", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + 'import { TOOL_CALL_ICON_NAMES } from "@getpaseo/protocol/agent-types"; export default () => TOOL_CALL_ICON_NAMES;', + ); + + await expect(compilePlugin(entries)).rejects.toThrow( + 'Could not resolve "@getpaseo/protocol/agent-types"', + ); + }); + + it("still rejects missing third-party type dependencies", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + 'import type { Item } from "missing-types"; export default (item: Item) => item;', + ); + + await expect(compilePlugin(entries)).rejects.toThrow( + 'Could not resolve type dependency "missing-types"', + ); + }); + it.each([ "react", "react/jsx-runtime", diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 15df395d06..b48449bb50 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -245,6 +245,10 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: runtimeSpecifierError(packageSpecifier, owner, file) ?? lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); if (error) return error; + // Protocol types are shared host contracts, also exposed by the SDK's peer + // dependency during development. Erased imports need no plugin-local install; + // runtime imports still go through normal dependency resolution below. + if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue; // Host modules have separately enforced SDK boundaries and need no local installation. if ( (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 544b609df5..71fe2b96e9 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -10,6 +10,7 @@ import { stat, writeFile, } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -418,7 +419,7 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); - it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => { + it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const running = await createPlugin("running", "export default () => () => {};"); @@ -426,7 +427,13 @@ describe("PluginService", () => { const malformed = await createPlugin("malformed", "export default () => () => {};"); await writeFile( path.join(running, "paseo-plugin.json"), - JSON.stringify({ id: "running", description: "Runs checks" }), + JSON.stringify({ + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }), ); await writeFile( path.join(disabled, "paseo-plugin.json"), @@ -442,16 +449,123 @@ describe("PluginService", () => { await service.start(); expect( - (await service.listPlugins()).map(({ id, description }) => ({ id, description })), + (await service.listPlugins()).map(({ id, description, name, icon, media }) => ({ + id, + description, + name, + icon, + media, + })), ).toEqual([ { id: "disabled", description: "Waits until enabled" }, { id: "malformed", description: undefined }, - { id: "running", description: "Runs checks" }, + { + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }, ]); await service.stopAllPlugins(); }); - it("prefers an existing directory and installs its selected plugin subdirectory", async () => { + it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`); + await mkdir(owner); + roots.push(home, owner); + const source = `${path.basename(owner)}/example`; + const repository = await createPlugin("registry-example", "export default () => () => {};\n"); + await mkdir(path.join(owner, "example")); + await cp(repository, path.join(owner, "example"), { recursive: true }); + await runGitCommand(["init", "-b", "main"], { cwd: repository }); + await runGitCommand(["add", "-A"], { cwd: repository }); + await runGitCommand( + [ + "-c", + "user.name=Paseo Tests", + "-c", + "user.email=paseo@example.test", + "commit", + "-m", + "fixture", + ], + { cwd: repository }, + ); + const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const requests: Array<string | undefined> = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + id: source, + name: "Example", + description: "Test", + categories: [], + author: { github: path.basename(owner) }, + repository: { url: "https://github.com/fixture/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit: stdout.trim(), + pluginPath: ".", + }, + media: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Test", + }), + ); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const url = `http://127.0.0.1:${address.port}`; + const store = createStore(home); + store.patch({ pluginsEnabled: false }); + const service = bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.4.0", { + managedSources: new ManagedPluginSources(home, { defaultUrl: url }), + }), + ); + try { + await service.start(); + await expect(service.installSource({ source })).resolves.toMatchObject({ + id: "registry-example", + status: "disabled", + installation: { + identity: { kind: "git", registry: { url, id: source } }, + currentRevision: stdout.trim(), + }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + await expect( + service.installSource({ source: `./${source}`, id: "local-example" }), + ).resolves.toMatchObject({ + installation: { identity: { kind: "directory", path: path.join(owner, "example") } }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + } finally { + await service.stopAllPlugins(); + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }, 30_000); + + it("names a missing explicit directory instead of trying a managed source", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = path.join(home, "missing"); + const service = createService(home); + await expect(service.installSource({ source: directory })).rejects.toThrow( + `Plugin directory does not exist: ${directory}`, + ); + }); + + it("installs an explicit directory and its selected plugin subdirectory", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-")); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 7f74c847df..a5ecea8097 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -21,6 +21,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js"; import { readPluginManifest } from "./manifest.js"; +import { expandTilde } from "../../utils/path.js"; import { runPluginBuild } from "./preparation.js"; import { PluginRuntime } from "./runtime.js"; import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js"; @@ -215,7 +216,12 @@ export class PluginService { }), }; const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null); - if (manifest?.description) item.description = manifest.description; + if (manifest) { + item.name = manifest.name; + item.description = manifest.description; + item.icon = manifest.icon; + item.media = manifest.media; + } item.installation = await this.managedSources ?.describe(id, source.path) .catch(() => undefined); @@ -286,16 +292,11 @@ export class PluginService { if (input.id && this.builtinPluginIds.has(input.id)) { throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`); } - const directDirectory = path.resolve(input.source); - const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source); - const directInfo = await stat(directDirectory).catch(() => null); - const reference = directInfo?.isDirectory() - ? { source: input.source, pluginPath: undefined } - : parsePluginSourceReference(input.source); - const directory = path.resolve(reference.source); - let info = directInfo; - if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null); - if (info?.isDirectory()) { + const reference = parsePluginSourceReference(input.source); + if (reference.kind === "directory") { + const directory = path.resolve(expandTilde(reference.source)); + const info = await stat(directory).catch(() => null); + if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`); if (input.ref) throw new Error("Plugin --ref is only valid for Git sources"); const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath); return this.installDirectory({ path: pluginDirectory, id: input.id }); @@ -628,6 +629,7 @@ export class PluginService { label: provider.label, description: provider.description, command: provider.command, + requiresConfiguration: provider.requiresConfiguration, status: provider.hasStatus ? (request) => { if (!this.runtime.getProviderStatus) diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 40878ae74e..466780b79a 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -280,7 +280,60 @@ describe("managed Git plugin sources", () => { }); describe("registry plugin sources", () => { - it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => { + it("explains how to choose a revision when --ref is used with a registry id", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-")); + roots.push(home); + const sources = new ManagedPluginSources(home); + await expect( + sources.prepareInstall({ source: "fixture/example", ref: "main" }), + ).rejects.toThrow( + "Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.", + ); + }); + + it("names the registry URL and recovery steps when the registry fetch fails", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-")); + roots.push(home); + const server = createServer((request) => request.socket.destroy()); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const url = `http://127.0.0.1:${address.port}`; + try { + const sources = new ManagedPluginSources(home, { defaultUrl: url }); + await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow( + `Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`, + ); + } finally { + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }); + + it("explains explicit local and GitHub sources when a registry ID is missing", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-")); + roots.push(home); + const requests: Array<string | undefined> = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.writeHead(404).end(); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + try { + const sources = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}`, + }); + await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow( + "If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.", + ); + expect(requests).toEqual(["/plugins/fixture/missing.json"]); + } finally { + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }); + + it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => { const repository = await createRepository(); const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-")); roots.push(home); @@ -298,12 +351,34 @@ describe("registry plugin sources", () => { const sources = new ManagedPluginSources(home, { defaultUrl: `http://127.0.0.1:${address.port}`, }); - const candidate = await sources.prepareInstall({ source: "fixture/repository" }); - expect(candidate.record).toEqual({ - kind: "git", - remote: "https://github.com/fixture/repository.git", - }); - await sources.discard(candidate); + for (const source of [ + "git:fixture/repository", + "https://github.com/fixture/repository.git", + ]) { + const candidate = await sources.place( + "managed-example", + await sources.prepareInstall({ source }), + ); + expect(candidate.record).toEqual({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + }); + sources.commit("managed-example", candidate.record); + const restarted = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}/changed`, + }); + expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe( + "current", + ); + expect( + (await restarted.describe("managed-example", candidate.directory)).identity, + ).toEqual({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + pluginPath: ".", + }); + await sources.discard(candidate); + } }); expect(requests).toEqual([]); } finally { @@ -332,12 +407,17 @@ describe("registry plugin sources", () => { intent: string | string[] | undefined; authorization: string | undefined; }> = []; + let missing = false; const server = createServer((req, res) => { requests.push({ path: req.url, intent: req.headers["x-paseo-install"], authorization: req.headers.authorization, }); + if (missing) { + res.writeHead(404).end(); + return; + } res.setHeader("Content-Type", "application/json"); res.end( JSON.stringify({ @@ -353,7 +433,7 @@ describe("registry plugin sources", () => { commit, pluginPath: "packages/example", }, - screenshots: [], + media: [], submittedAt: "2026-10-03", reviewedAt: "2026-10-03", updatedAt: "2026-10-03", @@ -369,7 +449,6 @@ describe("registry plugin sources", () => { const url = `http://${host}/internal`; try { const sources = new ManagedPluginSources(home, { - enabled: true, defaultUrl: url, registries: { [host]: { authorization: "Bearer test" } }, }); @@ -380,7 +459,11 @@ describe("registry plugin sources", () => { ).toBe("managed-example"); await sources.verifyCandidate("managed-example", candidate); sources.commit("managed-example", candidate.record); - const preview = await sources.preview("managed-example", candidate.directory); + const restarted = new ManagedPluginSources(home, { + defaultUrl: "http://127.0.0.1:1/changed", + registries: { [host]: { authorization: "Bearer test" } }, + }); + const preview = await restarted.preview("managed-example", candidate.directory); expect(preview.outcome).toBe("current"); expect(preview.current?.identity).toEqual({ kind: "git", @@ -388,6 +471,10 @@ describe("registry plugin sources", () => { pluginPath: "packages/example", registry: { url, id: "acme/example" }, }); + missing = true; + await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow( + "Check that the installed plugin is still published in this registry before updating", + ); expect(requests).toEqual([ { path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" }, { @@ -395,6 +482,11 @@ describe("registry plugin sources", () => { intent: undefined, authorization: "Bearer test", }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, ]); } finally { await new Promise<void>((resolve, reject) => @@ -424,27 +516,17 @@ it("installs and updates only the npm artifacts pinned by the plugin registry", const server = createServer((request, response) => { intents.push(request.headers["x-paseo-install"]); response.end( + // Install reads only the ID and artifact, so the document omits the directory's display fields. JSON.stringify({ id: "acme/example", - name: "Example", - description: "Test", - categories: [], - author: { github: "acme" }, - repository: { url: "https://github.com/acme/example" }, artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, - screenshots: [], - submittedAt: "2026-10-03", - reviewedAt: "2026-10-03", - updatedAt: "2026-10-03", - publishedAt: "2026-10-03", - readme: "# Example", }), ); }); await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("Missing registry address"); - const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` }; + const options = { defaultUrl: `http://127.0.0.1:${address.port}` }; try { const sources = new ManagedPluginSources(home, options); const candidate = await sources.place( diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index 8ee3b2b6f6..5373b7453a 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -147,11 +147,13 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise<ManagedPluginCandidate> { - const registry = this.registryOptions.enabled - ? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl) - : null; + const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl); if (registry) { - if (input.ref || input.pluginPath) + if (input.ref) + throw new Error( + "Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.", + ); + if (input.pluginPath) throw new Error("Registry sources use their reviewed artifact path and revision"); const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true); const candidate = await this.prepareInstall(resolved.input, resolved.target); @@ -175,7 +177,7 @@ export class ManagedPluginSources { sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName); record = { kind: "npm" }; } else { - const remote = normalizeGitSource(input.source, !this.registryOptions.enabled); + const remote = normalizeGitSource(input.source); sourceRoot = path.join(versionRoot, "checkout"); await clone(remote, sourceRoot); const commit = await resolveRequestedRef(sourceRoot, input.ref); @@ -227,7 +229,7 @@ export class ManagedPluginSources { const location = this.locate(pluginId, configuredPath); let target: PluginUpdateTarget; let outcome: PluginUpdatePreview["outcome"]; - if (this.registryOptions.enabled && current.identity.registry) { + if (current.identity.registry) { if (selection) throw new Error("Registry updates use only the reviewed registry pin"); const resolved = await resolveRegistryPlugin( current.identity.registry, @@ -304,7 +306,7 @@ export class ManagedPluginSources { await this.assertCurrent(proposal, configuredPath); const identity = this.locate(proposal.id, configuredPath).identity; const record = this.records[proposal.id]; - if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) { + if (identity.kind !== "directory" && identity.registry) { const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false); assertRegistryArtifactIdentity(identity, resolved.artifact); if (!isDeepStrictEqual(resolved.target, proposal.target)) @@ -436,10 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s } return []; } -/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */ -function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string { - const expandsShorthand = - bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:"); +/** Only explicit Git prefixes expand GitHub shorthand. */ +function normalizeGitSource(source: string): string { + const expandsShorthand = source.startsWith("github:") || source.startsWith("git:"); if (source.startsWith("github:")) { const shorthand = source.slice(7); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) { diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts index 179bdc5da7..009cac5a68 100644 --- a/packages/server/src/server/plugins/managed-source/registry.ts +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -1,13 +1,19 @@ import { - PublishedPluginDetailSchema, + PluginRegistryArtifactSchema, + PluginRegistryIdSchema, type PluginRegistries, type PluginRegistryIdentity, } from "@getpaseo/protocol/plugin-registry"; import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; +import { z } from "zod"; + +// Install reads only these fields; the rest of a published plugin is the directory's display data. +const RegistryInstallDocumentSchema = z.object({ + id: PluginRegistryIdSchema, + artifact: PluginRegistryArtifactSchema, +}); export interface RegistryOptions { - /** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */ - enabled?: boolean; defaultUrl?: string; registries?: PluginRegistries; } @@ -29,14 +35,29 @@ export async function resolveRegistryPlugin( const headers: Record<string, string> = {}; if (authorization) headers.Authorization = authorization; if (install) headers["X-Paseo-Install"] = "1"; - const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, { - headers, - redirect: "error", - signal: AbortSignal.timeout(30000), - }); + const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`; + let response: Response; + try { + response = await fetch(url, { + headers, + redirect: "error", + signal: AbortSignal.timeout(30000), + }); + } catch (cause) { + const guidance = install + ? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.` + : `Could not check updates for ${identity.id}. Retry later.`; + throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause }); + } + if (response.status === 404) { + const guidance = install + ? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.` + : "Check that the installed plugin is still published in this registry before updating."; + throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`); + } if (!response.ok) throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`); - const plugin = PublishedPluginDetailSchema.parse(await response.json()); + const plugin = RegistryInstallDocumentSchema.parse(await response.json()); if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID"); const artifact = plugin.artifact; const target: PluginUpdateTarget = diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 3ff06e2c3e..399e350493 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -24,6 +24,69 @@ describe("plugin manifest", () => { }); }); + it("reads display metadata and ignores future top-level fields", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"], + }; + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ ...manifest, futureField: { enabled: true } }), + ); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + }); + + it.each([ + { name: " " }, + { name: 42 }, + { icon: "" }, + { icon: 42 }, + { icon: "icon.svg" }, + { icon: "/icon.png" }, + { icon: "../icon.png" }, + { icon: "assets/../../icon.png" }, + { icon: "https://example.com/icon.png" }, + { icon: "C:/icon.png" }, + { icon: "assets\\icon.png" }, + { icon: "icon.png?query" }, + { media: "screenshot.png" }, + { media: [42] }, + { media: [""] }, + { media: ["../demo.mp4"] }, + { media: ["/demo.mp4"] }, + { media: ["http://example.com/demo.mp4"] }, + { media: ["https://"] }, + { media: ["//example.com/demo.mp4"] }, + { media: ["file:///demo.mp4"] }, + { media: ["data:image/png;base64,abc"] }, + { media: ["."] }, + { media: ["assets/"] }, + { id: "INVALID" }, + { description: 42 }, + { requirements: { paseoo: ">=0.11.0" } }, + ])("rejects invalid known fields: %j", async (fields) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "example", futureField: true, ...fields }), + ); + await expect(readPluginManifest(directory)).rejects.toThrow(); + }); + + it("accepts omitted metadata and empty media", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) { + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest)); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + } + }); + it("reads and validates requirements before any plugin code runs", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index d2e13d614b..2312a2cb5a 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json"; const PluginBuildCommandSchema = z .array(z.string().refine((argument) => argument.trim().length > 0)) .min(1); -const PluginManifestSchema = z - .object({ - id: PluginIdSchema, - description: z.string().trim().min(1).optional(), - requirements: PluginRequirementsSchema.strict().optional(), - build: z.array(PluginBuildCommandSchema).min(1).optional(), - }) - .strict(); +// Manifest asset paths use forward slashes and stay inside the plugin package. +const PluginAssetPathSchema = z + .string() + .min(1) + .refine( + (value) => + value === value.trim() && + !value.startsWith("/") && + !/[\\:?#\p{Cc}]/u.test(value) && + !value.split("/").includes("..") && + !value.endsWith("/") && + value.split("/").at(-1) !== ".", + "Expected a relative file path inside the plugin package", + ); +const PluginManifestSchema = z.object({ + id: PluginIdSchema, + name: z.string().trim().min(1).optional(), + description: z.string().trim().min(1).optional(), + icon: PluginAssetPathSchema.refine( + (value) => /\.png$/i.test(value), + "Expected a relative path to a PNG", + ).optional(), + media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(), + // A misspelled requirement must not silently disable compatibility checks. + requirements: PluginRequirementsSchema.strict().optional(), + build: z.array(PluginBuildCommandSchema).min(1).optional(), +}); export type PluginManifest = z.infer<typeof PluginManifestSchema>; diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index d0ad016cec..5da355d5df 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -1,3 +1,7 @@ +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; import { EventEmitter, once } from "node:events"; import { resolveDaemonVersion } from "../daemon-version.js"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -15,6 +19,59 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +test.each(["React Native", "native"])( + "provider readiness works with %s AbortController", + async (runtime) => { + const require = createRequire(import.meta.url); + const reactNativeRequire = createRequire(require.resolve("react-native/package.json")); + const { AbortController: ReactNativeAbortController } = reactNativeRequire( + "abort-controller/dist/abort-controller", + ); + const bundle = await build({ + entryPoints: [fileURLToPath(new URL("../../../../client/src/index.ts", import.meta.url))], + bundle: true, + write: false, + format: "iife", + globalName: "paseoSdk", + platform: "browser", + }); + // A separate runtime starts with React Native's real abort implementation. + // Node's globals and the daemon's runtime remain unchanged. + const sdk: typeof import("@getpaseo/client") = runInNewContext( + `${bundle.outputFiles[0]!.text}\npaseoSdk`, + { + AbortController: runtime === "React Native" ? ReactNativeAbortController : AbortController, + setTimeout, + clearTimeout, + queueMicrotask, + TextEncoder, + TextDecoder, + }, + ); + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const api = sdk.createPaseoApi(client); + try { + await client.connect(); + const snapshot = await api.providers.waitForReady({ cwd: daemon.staticDir }); + expect(snapshot.entries.length).toBeGreaterThan(0); + expect(snapshot.entries.every((entry) => entry.status !== "loading")).toBe(true); + const waiting = api.providers.waitForReady({ cwd: daemon.staticDir }); + const canceled = expect(waiting).rejects.toThrow(/disposed|released/); + await api.dispose(); + await canceled; + const { diagnostic } = await client.collectDiagnostics(); + expect(diagnostic).toContain("Source registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + expect(() => api.providers.waitForReady()).toThrow("disposed"); + } finally { + await api.dispose(); + await client.close(); + await daemon.close(); + } + }, +); + test("plugin handlers create workspaces and agents through their Paseo API", async () => { const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-plugin-")); const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-workspace-")); diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index e959780389..b3f363cb29 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -14,6 +14,7 @@ import { import { z } from "zod"; export interface PluginProviderMetadata { + requiresConfiguration?: boolean; hasCatalogCacheKey?: boolean; hasStatus?: boolean; command?: readonly [string, ...string[]]; @@ -112,6 +113,7 @@ const providerMetadataSchema = z label: z.string().min(1), description: z.string().optional(), iconPath: z.string().optional(), + requiresConfiguration: z.boolean().optional(), hasCatalogCacheKey: z.boolean().optional(), hasStatus: z.boolean().optional(), command: z.tuple([z.string().min(1)], z.string()).optional(), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 3f11926d7d..5e6efa1925 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -158,6 +158,7 @@ export function createPluginWorker(options: { label: provider.label, description: provider.description, iconPath: provider.icon, + requiresConfiguration: provider.requiresConfiguration, hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, hasStatus: provider.status !== undefined, command: provider.command, diff --git a/packages/server/src/server/private-files.ts b/packages/server/src/server/private-files.ts index 8b162f292b..0e12457be1 100644 --- a/packages/server/src/server/private-files.ts +++ b/packages/server/src/server/private-files.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import path from "node:path"; @@ -28,14 +28,27 @@ export function writePrivateFileAtomicSync( data: string | NodeJS.ArrayBufferView, ): void { ensurePrivateDirectory(path.dirname(filePath)); - const parent = path.dirname(filePath); - const temporary = path.join(parent, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}`); + const destination = resolveSymlinkedFile(filePath); + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}`, + ); try { writeFileSync(temporary, data, { mode: PRIVATE_FILE_MODE }); - renameSync(temporary, filePath); - ensurePrivateFile(filePath); + renameSync(temporary, destination); + ensurePrivateFile(destination); } catch (error) { rmSync(temporary, { force: true }); throw error; } } + +// Renaming onto a symlink replaces the link, so write to the file it points at. +function resolveSymlinkedFile(filePath: string): string { + try { + return realpathSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return filePath; + throw error; + } +} diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 06775e5c5c..2dcf29b948 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type pino from "pino"; +import pino from "pino"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { exportPublicKey, generateKeyPair } from "@getpaseo/relay"; import { startRelayTransport } from "./relay-transport"; @@ -146,6 +146,37 @@ describe("relay-transport control lifecycle", () => { vi.useRealTimers(); }); + test("releases a stalled encrypted data connection without restarting control", async () => { + vi.useFakeTimers(); + let attachments = 0; + controllers.push( + startRelayTransport({ + logger: pino({ level: "silent" }), + attachSocket: async () => { + attachments += 1; + }, + relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, + serverId: "srv_test", + daemonKeyPair: generateKeyPair(), + createWebSocket: relay.createWebSocket, + }), + ); + const control = relay.sockets[0]; + control.open(); + const connected = JSON.stringify({ type: "connected", connectionId: "stalled" }); + control.message(connected); + const stalled = relay.sockets[1]; + stalled.open(); + await vi.advanceTimersByTimeAsync(15_000); + expect(stalled.terminateCalls).toBe(1); + expect(attachments).toBe(0); + expect(control.terminateCalls).toBe(0); + control.message(connected); + expect(relay.sockets).toHaveLength(3); + expect(relay.sockets[2].url).toBe(stalled.url); + }); + test("logs relay_control_connected only after first valid control message", () => { const logger = createMockLogger(); const controller = startRelayTransport({ diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index ddf1a3c234..f7b73a5367 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -420,6 +420,10 @@ async function attachEncryptedSocket( attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>, metadata?: ExternalSocketMetadata, ): Promise<void> { + const handshakeTimeout = setTimeout(() => { + logger.warn("relay_e2ee_handshake_timeout_terminating"); + socket.terminate(); + }, 15_000); try { const relayTransport = createRelayTransportAdapter(socket, logger); const emitter = new EventEmitter(); @@ -440,6 +444,7 @@ async function attachEncryptedSocket( emitter.emit("error", error); }, }); + clearTimeout(handshakeTimeout); const encryptedSocket = createEncryptedRelaySocket({ channel, emitter, @@ -459,6 +464,8 @@ async function attachEncryptedSocket( } catch { // ignore } + } finally { + clearTimeout(handshakeTimeout); } } diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index 82895e302b..8d44b9ba80 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import type { ForgeService } from "../services/forge-service.js"; +import { buildForkLocalBranchName } from "../utils/change-request-checkout.js"; import { CheckoutSourceForgeMismatchError, MissingCheckoutTargetError, @@ -32,14 +33,8 @@ function createResolverHarness(overrides?: { defaultCheckoutRefs: ({ changeRequestNumber }) => [ { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, ], - buildPrLocalBranchName: ({ headRef, checkoutTarget }) => { - const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? ""; - const owner = - checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized) - ? normalized - : null; - return owner ? `${owner}/${headRef}` : headRef; - }, + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), supportsCrossRepoCheckoutWithoutRefs: true, } : {}; @@ -182,6 +177,28 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("does not track origin for a same-repo head with no origin branch", async () => { + const deps = createResolverHarness(); + deps.forgeService.getPullRequestCheckoutTarget = async () => ({ + number: 42, + baseRefName: "main", + headRefName: "ford/towel", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + headRefKind: "pull-ref", + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", githubPrNumber: 42 }, + repoRoot, + deps, + ); + + expect(intent).not.toHaveProperty("trackOriginHead"); + }); + test("configures the contributor remote for fork PR targets", async () => { const deps = createResolverHarness(); deps.forgeService.getPullRequestCheckoutTarget = async () => ({ @@ -211,6 +228,29 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("prefixes the pr number when the fork was deleted", async () => { + const deps = createResolverHarness(); + deps.forgeService.getPullRequestCheckoutTarget = async () => ({ + number: 42, + baseRefName: "main", + headRefName: "towel", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", githubPrNumber: 42 }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ headRef: "towel", localBranchName: "pr-42/towel" }); + expect(intent).not.toHaveProperty("pushRemoteUrl"); + expect(intent).not.toHaveProperty("trackOriginHead"); + }); + test("uses an explicit PR head ref without calling GitHub", async () => { const deps = createResolverHarness(); @@ -293,6 +333,105 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("names the local branch pr-<number> for a gitea pull-ref-only head", async () => { + const deps = createResolverHarness({ + forge: "gitea", + forgeService: { + defaultCheckoutRefs: ({ changeRequestNumber }) => [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: `refs/pull/${number}/head`, + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + headRefKind: "pull-ref", + }), + }, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", checkoutSource: { kind: "change_request", number: 3 } }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ localBranchName: "pr-3" }); + expect(intent).not.toHaveProperty("trackOriginHead"); + }); + + test("names the local branch owner/pr-<number> for a gitea fork pull-ref-only head", async () => { + const deps = createResolverHarness({ + forge: "gitea", + forgeService: { + defaultCheckoutRefs: ({ changeRequestNumber }) => [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), + supportsCrossRepoCheckoutWithoutRefs: true, + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: `refs/pull/${number}/head`, + headOwnerLogin: "contributor", + headRepositorySshUrl: "git@gitea.test:contributor/repo.git", + headRepositoryUrl: "https://gitea.test/contributor/repo", + isCrossRepository: true, + headRefKind: "pull-ref", + }), + }, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", checkoutSource: { kind: "change_request", number: 3 } }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ localBranchName: "contributor/pr-3" }); + }); + + test("does not double the pr number for a gitea deleted fork's pull-ref-only head", async () => { + const deps = createResolverHarness({ + forge: "gitea", + forgeService: { + defaultCheckoutRefs: ({ changeRequestNumber }) => [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), + supportsCrossRepoCheckoutWithoutRefs: true, + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: `refs/pull/${number}/head`, + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + headRefKind: "pull-ref", + }), + }, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", checkoutSource: { kind: "change_request", number: 3 } }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ localBranchName: "pr-3" }); + }); + test("reports unsupported cross-repository checkout targets without a checkout ref", async () => { const deps = createResolverHarness({ forge: "gitea", diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index 73403f5908..5eddc0ef7b 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -191,7 +191,8 @@ async function resolvePrCheckoutIntent( }) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }]; const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget }); const crossRepository = resolveCrossRepositoryFields(checkoutTarget); - const trackOriginHead = !checkoutTarget.isCrossRepository; + const trackOriginHead = + !checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref"; return { kind: "checkout-change-request", diff --git a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts index a27de8825c..877ec3aced 100644 --- a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts +++ b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts @@ -235,6 +235,10 @@ test("archiveOnFinish=false local scheduled run emits upserts and remains active await waitForAgentUpsert(events.agentUpdates, agentId); expect(workspaceWasRemoved(events.workspaceUpdates, workspaceId!)).toBe(false); expect(await activeAgentIds()).toContain(agentId); + expect( + (await ctx.client.fetchWorkspaces()).entries.find((entry) => entry.id === workspaceId) + ?.background, + ).toBe(false); events.stop(); }); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 14e0221913..1a9f0f5fd4 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -44,6 +44,37 @@ interface ScheduleServiceInternals { executeSchedule(schedule: StoredSchedule, runId: string): Promise<ScheduleExecutionResult>; } +class OverlappingTickRunner { + private resolveFirstRunStarted: () => void = () => {}; + private resolveFirstRunBlocked: () => void = () => {}; + private laterScheduleRunCount = 0; + + readonly firstRunStarted = new Promise<void>((resolve) => { + this.resolveFirstRunStarted = resolve; + }); + private readonly firstRunBlocked = new Promise<void>((resolve) => { + this.resolveFirstRunBlocked = resolve; + }); + + async run(schedule: StoredSchedule): Promise<ScheduleExecutionResult> { + if (schedule.prompt === "block the first tick") { + this.resolveFirstRunStarted(); + await this.firstRunBlocked; + } else { + this.laterScheduleRunCount += 1; + } + return { agentId: null, output: "ok" }; + } + + unblockFirstRun(): void { + this.resolveFirstRunBlocked(); + } + + get laterScheduleRuns(): number { + return this.laterScheduleRunCount; + } +} + const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, @@ -345,6 +376,241 @@ describe("ScheduleService", () => { expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); }); + test.each(["new-agent", "agent", "maxRuns: 1"] as const)( + "dispatches a shared cron slot only once for %s after an earlier tick resumes", + async (targetKind) => { + const runner = new OverlappingTickRunner(); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: (schedule) => runner.run(schedule), + }); + const cadence = { type: "cron" as const, expression: "0 6 * * 1", timezone: "Europe/Prague" }; + await service.create({ + prompt: "block the first tick", + cadence, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + // Give the later schedule a distinct creation time so file ordering cannot affect the race. + now = new Date("2026-01-01T00:00:01.000Z"); + const agentId = "00000000-0000-0000-0000-000000000001"; + await agentStorage.upsert( + buildAgentRecord({ id: agentId, cwd: tempDir, iso: now.toISOString() }), + ); + const later = await service.create({ + prompt: "later due schedule", + cadence, + target: + targetKind === "agent" + ? { type: "agent", agentId } + : { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: targetKind === "maxRuns: 1" ? 1 : undefined, + }); + now = new Date("2026-01-05T05:00:00.000Z"); + const delayedTick = service.tick(); + await runner.firstRunStarted; + try { + now = new Date("2026-01-05T05:00:01.000Z"); + await service.tick(); + } finally { + runner.unblockFirstRun(); + await delayedTick; + } + const result = await service.inspect(later.id); + expect.soft(runner.laterScheduleRuns).toBe(1); + expect.soft(result.runs).toHaveLength(1); + expect.soft(result.runs.map((run) => run.scheduledFor)).toEqual(["2026-01-05T05:00:00.000Z"]); + expect(result.nextRunAt).toBe( + targetKind === "maxRuns: 1" ? null : "2026-01-12T05:00:00.000Z", + ); + expect(result.status).toBe(targetKind === "maxRuns: 1" ? "completed" : "active"); + }, + ); + + test("advances the next scheduled slot past a run that crosses a cadence boundary", async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => { + now = new Date("2026-01-01T00:02:00.000Z"); + return { agentId: null, output: "ok" }; + }, + }); + + const created = await service.create({ + prompt: "long-running task", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + expect((await service.inspect(created.id)).nextRunAt).toBe("2026-01-01T00:03:00.000Z"); + }); + + test("runs the final allowed cron slot without requiring another occurrence", async () => { + let dispatches = 0; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => { + dispatches += 1; + return { agentId: null, output: "done" }; + }, + }); + const created = await service.create({ + prompt: "Run once on January 5 when it is Monday", + cadence: { type: "cron", expression: "0 6 5 1 1" }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + now = new Date("2026-01-05T06:00:00.000Z"); + await service.tick(); + const result = await service.inspect(created.id); + expect(dispatches).toBe(1); + expect(result.status).toBe("completed"); + expect(result.nextRunAt).toBeNull(); + expect(result.runs).toHaveLength(1); + expect(result.runs[0].status).toBe("succeeded"); + }); + + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "keeps firing future slots after an outcome could not be saved", + async () => { + const schedulesDir = join(tempDir, "schedules"); + let dispatches = 0; + const failOutcomeWrite = async (): Promise<ScheduleExecutionResult> => { + dispatches += 1; + if (dispatches === 1) { + await chmod(schedulesDir, 0o555); + } + return { agentId: null, output: "done" }; + }; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: failOutcomeWrite, + }); + const created = await service.create({ + prompt: "Check status each minute", + runOnCreate: false, + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + now = new Date("2026-01-01T00:01:00.000Z"); + try { + await expect(service.tick()).rejects.toThrow(); + } finally { + await chmod(schedulesDir, 0o755); + } + expect((await service.inspect(created.id)).runs[0].status).toBe("running"); + now = new Date("2026-01-01T00:02:00.000Z"); + await service.tick(); + const result = await service.inspect(created.id); + expect(dispatches).toBe(2); + expect(result.runs.map((run) => run.scheduledFor)).toEqual([ + "2026-01-01T00:01:00.000Z", + "2026-01-01T00:02:00.000Z", + ]); + expect(result.nextRunAt).toBe("2026-01-01T00:03:00.000Z"); + }, + ); + + test("completes an interrupted final slot during restart recovery", async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "done" }), + }); + const created = await service.create({ + prompt: "Run once", + cadence: { type: "cron", expression: "0 6 5 1 1" }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + now = new Date("2026-01-05T06:00:00.000Z"); + const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger()); + await store.update(created.id, (schedule) => ({ + ...schedule, + nextRunAt: null, + runs: [ + { + id: "interrupted-final-slot", + scheduledFor: now.toISOString(), + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }, + ], + })); + now = new Date("2026-01-05T06:01:00.000Z"); + await service.start(); + try { + const result = await service.inspect(created.id); + expect(result.status).toBe("completed"); + expect(result.nextRunAt).toBeNull(); + expect(result.runs).toHaveLength(1); + expect(result.runs[0].status).toBe("failed"); + } finally { + await service.stop(); + } + }); + + test.each([2, null])( + "keeps a next slot when the final run's limit changes to %s", + async (maxRuns) => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async (schedule) => { + await service.update({ id: schedule.id, maxRuns }); + return { agentId: null, output: "done" }; + }, + }); + const created = await service.create({ + prompt: "Allow more runs during execution", + runOnCreate: false, + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + now = new Date("2026-01-01T00:01:30.000Z"); + await service.tick(); + const result = await service.inspect(created.id); + expect(result.status).toBe("active"); + expect(result.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + expect(result.runs).toHaveLength(1); + }, + ); + test("pause and resume update persisted schedule state", async () => { const service = createScheduleService({ paseoHome: tempDir, @@ -904,7 +1170,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType<ScheduleServiceOptions["createAgent"]> >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -982,7 +1247,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType<ScheduleServiceOptions["createAgent"]> >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -1052,7 +1316,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType<ScheduleServiceOptions["createAgent"]> >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -1112,7 +1375,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType<ScheduleServiceOptions["createAgent"]> >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -2867,6 +3129,44 @@ describe("ScheduleService", () => { expect(inspected.runs[0]?.error).toBe("network blip"); }); + // chmod cannot make a directory unwritable on Windows or for root. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "fires again after a tick whose run could not be recorded", + async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ran" }), + }); + + const created = await service.create({ + prompt: "ping target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "88888888-8888-4888-8888-888888888888" }, + }); + + const schedulesDir = join(tempDir, "schedules"); + now = new Date("2026-01-01T00:01:00.000Z"); + await chmod(schedulesDir, 0o555); + try { + await expect(service.tick()).rejects.toThrow(); + } finally { + await chmod(schedulesDir, 0o755); + } + + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]?.status).toBe("succeeded"); + expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + }, + ); + test("completes the schedule when a scheduled run targets an archived agent", async () => { const service = createScheduleService({ paseoHome: tempDir, diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 53d2432f88..7b5487c3f8 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -1,3 +1,4 @@ +import { formatSystemNotificationPrompt } from "../agent/agent-messages/index.js"; import { randomUUID } from "node:crypto"; import { stat } from "node:fs/promises"; import { join } from "node:path"; @@ -7,11 +8,7 @@ import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; import type { AgentStorage } from "../agent/agent-storage.js"; import { curateAgentActivity } from "../agent/activity-curator.js"; import { ensureAgentLoaded } from "../agent/agent-loading.js"; -import { - formatSystemNotificationPrompt, - startAgentRun, - type AgentRunController, -} from "../agent/agent-prompt.js"; +import { startAgentRun, type AgentRunController } from "../agent/agent-prompt.js"; import { resolveCreateAgentTitles } from "../agent/create-agent-title.js"; import { type BoundCreateAgentCommand, formatProviderModel } from "../agent/create-agent/create.js"; import type { PersistedWorkspaceRecord } from "../workspace-registry.js"; @@ -41,6 +38,18 @@ export class ScheduleTargetGoneError extends Error { } } +function advanceNextRunAtPast( + cadence: StoredSchedule["cadence"], + nextRunAt: Date, + now: Date, +): Date { + let next = nextRunAt; + while (next.getTime() <= now.getTime()) { + next = computeNextRunAt(cadence, next); + } + return next; +} + function trimOptionalName(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -624,6 +633,9 @@ export class ScheduleService { error: "Daemon restarted before the scheduled run completed", }; updated = { ...updated, runs }; + if (shouldCompleteSchedule(updated, now)) { + updated = completeSchedule(updated, now); + } dirty = true; } @@ -693,48 +705,113 @@ export class ScheduleService { options?: { manual?: boolean }, ): Promise<void> { const manual = options?.manual === true; + // Reserve before store I/O; persisted history cannot prove a run is still alive. this.runningScheduleIds.add(schedule.id); - const runId = randomUUID(); - const runningRun: ScheduleRun = { - id: runId, - scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()), - startedAt: now.toISOString(), - endedAt: null, - status: "running", - agentId: null, - output: null, - error: null, - }; - const scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun); - try { - const result = await this.runner(scheduleWithRun, runId); - await this.finishRun({ - scheduleId: schedule.id, - runId, - status: "succeeded", - agentId: result.agentId, - output: result.output, - error: null, - targetGone: false, - manual, - }); - } catch (error) { - await this.finishRun({ - scheduleId: schedule.id, - runId, - status: "failed", - agentId: null, - output: null, - error: error instanceof Error ? error.message : String(error), - targetGone: error instanceof ScheduleTargetGoneError, - manual, - }); + let runId: string; + let scheduleWithRun: StoredSchedule; + if (manual) { + runId = randomUUID(); + const runningRun: ScheduleRun = { + id: runId, + scheduledFor: now.toISOString(), + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }; + scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun); + } else { + const claimed = await this.claimDueRun(schedule.id, now); + if (!claimed) { + return; + } + ({ schedule: scheduleWithRun, runId } = claimed); + } + + try { + const result = await this.runner(scheduleWithRun, runId); + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "succeeded", + agentId: result.agentId, + output: result.output, + error: null, + targetGone: false, + manual, + }); + } catch (error) { + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "failed", + agentId: null, + output: null, + error: error instanceof Error ? error.message : String(error), + targetGone: error instanceof ScheduleTargetGoneError, + manual, + }); + } } finally { this.runningScheduleIds.delete(schedule.id); } } + private async claimDueRun( + scheduleId: string, + now: Date, + ): Promise<{ schedule: StoredSchedule; runId: string } | null> { + let claimed: { schedule: StoredSchedule; runId: string } | null = null; + const updatedSchedule = await this.store.update(scheduleId, (schedule) => { + if ( + schedule.status !== "active" || + !schedule.nextRunAt || + shouldCompleteSchedule(schedule, now) || + new Date(schedule.nextRunAt).getTime() > now.getTime() + ) { + return schedule; + } + + const scheduledFor = schedule.nextRunAt; + const isFinalRun = + schedule.maxRuns !== null && countCompletedRuns(schedule) + 1 >= schedule.maxRuns; + const nextRunAt = isFinalRun + ? null + : advanceNextRunAtPast( + schedule.cadence, + computeNextRunAt(schedule.cadence, new Date(scheduledFor)), + now, + ).toISOString(); + + // Claim the due slot and advance its cursor in the same serialized update. + // A tick can hold a stale list while another tick finishes this schedule. + const runId = randomUUID(); + const runningRun: ScheduleRun = { + id: runId, + scheduledFor, + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }; + const updated: StoredSchedule = { + ...schedule, + nextRunAt, + updatedAt: now.toISOString(), + runs: [...schedule.runs, runningRun], + }; + claimed = { schedule: updated, runId }; + return updated; + }); + requireSchedule(updatedSchedule, scheduleId); + return claimed; + } + private async appendRunningRun( scheduleId: string, runningRun: ScheduleRun, @@ -795,14 +872,16 @@ export class ScheduleService { nextRunAt: null, }; } else { - const after = new Date(schedule.nextRunAt ?? now.toISOString()); - let nextRunAt = computeNextRunAt(updated.cadence, after); - while (nextRunAt.getTime() <= now.getTime()) { - nextRunAt = computeNextRunAt(updated.cadence, nextRunAt); - } + // A final slot has no successor until its run limit is raised or cleared. + // Every runner invocation has a recorded run with this id. + const finishedRun = completedRuns.find((run) => run.id === params.runId)!; + const nextRunAt = updated.nextRunAt + ? new Date(updated.nextRunAt) + : computeNextRunAt(updated.cadence, new Date(finishedRun.scheduledFor)); + // Move an overdue cursor past a long run without advancing a future one twice. updated = { ...updated, - nextRunAt: nextRunAt.toISOString(), + nextRunAt: advanceNextRunAtPast(updated.cadence, nextRunAt, now).toISOString(), }; } @@ -910,7 +989,6 @@ export class ScheduleService { features: config.featureValues, unattended: true, promptFailure: "return-error", - background: true, notifyOnFinish: false, }); const agent = created.snapshot; diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index bbc5acf3c7..96af0133ca 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -99,14 +99,14 @@ function createStubTerminalManager( }; } -async function startTcpServer(): Promise<TcpServerHandle> { +async function startTcpServer(host = "127.0.0.1"): Promise<TcpServerHandle> { const server = net.createServer((socket) => { socket.end(); }); await new Promise<void>((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => { + server.listen(0, host, () => { server.off("error", reject); resolve(); }); @@ -153,6 +153,45 @@ describe("ScriptHealthMonitor", () => { servers.clear(); }); + it.each(["127.0.0.1", "::1"])("reports a service bound only to %s as healthy", async (host) => { + const service = await startTcpServer(host); + servers.add(service.server); + const serviceProxy = new ScriptRouteStore(); + serviceProxy.registerWorkspaceService({ + workspaceId: "workspace-a", + projectSlug: "repo", + branchName: "main", + scriptName: "api", + port: service.port, + }); + const updates: ScriptHealthEntry[][] = []; + const monitor = new ScriptHealthMonitor({ + serviceProxy, + onChange: (_workspaceId, scripts) => updates.push(scripts), + pollIntervalMs: 10, + probeTimeoutMs: 100, + graceMs: 0, + failuresBeforeStopped: 1, + }); + monitor.start(); + try { + await vi.waitFor(() => { + expect(updates).toEqual([ + [ + { + scriptName: "api", + hostname: "api--repo.localhost", + port: service.port, + health: "healthy", + }, + ], + ]); + }); + } finally { + monitor.stop(); + } + }); + it("starts new service routes in pending and transitions to healthy after the grace period", async () => { vi.useFakeTimers(); diff --git a/packages/server/src/server/script-health-monitor.ts b/packages/server/src/server/script-health-monitor.ts index 810518f999..7992608f24 100644 --- a/packages/server/src/server/script-health-monitor.ts +++ b/packages/server/src/server/script-health-monitor.ts @@ -1,4 +1,4 @@ -import net from "node:net"; +import { connectToLocalService } from "./local-service-connection.js"; import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js"; export type ScriptHealthState = "pending" | "healthy" | "unhealthy"; @@ -208,7 +208,7 @@ export class ScriptHealthMonitor { private probeRoute(port: number): Promise<boolean> { return new Promise((resolve) => { - const socket = net.connect({ host: "127.0.0.1", port }); + const socket = connectToLocalService(port); let settled = false; const finish = (healthy: boolean) => { diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts index 5313b01d2d..5764b19539 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -32,15 +32,20 @@ function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[ } interface HttpGetOptions { + hostname?: string; path?: string; headers?: Record<string, string>; } function httpGet(port: number, host: string, options: HttpGetOptions = {}) { - const { path: requestPath = "/api/health", headers: extraHeaders = {} } = options; + const { + hostname = "127.0.0.1", + path: requestPath = "/api/health", + headers: extraHeaders = {}, + } = options; return new Promise<{ status: number; body: string }>((resolve, reject) => { const req = http.get( - { hostname: "127.0.0.1", port, path: requestPath, headers: { host, ...extraHeaders } }, + { hostname, port, path: requestPath, headers: { host, ...extraHeaders } }, (res) => { let body = ""; res.on("data", (chunk: Buffer) => { @@ -53,6 +58,31 @@ function httpGet(port: number, host: string, options: HttpGetOptions = {}) { }); } +function httpPost(port: number, host: string, requestPath: string, payload: string) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = http.request( + { + hostname: "127.0.0.1", + port, + method: "POST", + path: requestPath, + headers: { host, "content-type": "application/json" }, + }, + (res) => { + let body = ""; + res.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + res.on("error", reject); + }, + ); + request.on("error", reject); + request.write(payload.slice(0, 5)); + request.end(payload.slice(5)); + }); +} + describe("service proxy subsystem shape", () => { it("keeps production imports behind the service-proxy entrypoint", () => { const offenders: string[] = []; @@ -303,6 +333,7 @@ describe("service proxy subsystem shape", () => { }); interface ForwardedFixture { + upstreamPort: number; daemonPort: number; hostname: string; close(): Promise<void>; @@ -313,11 +344,16 @@ interface ForwardedFixture { * echoes the headers it received so tests can assert what actually crossed the * proxy, not what a helper returned. */ -async function startForwardedHeadersFixture(): Promise<ForwardedFixture> { - const upstreamPort = await findFreePort(); +async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> { const upstream = http.createServer((req, res) => { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify(req.headers)); + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body })); + }); }); const upgradeSockets: net.Socket[] = []; upstream.on("upgrade", (req, socket) => { @@ -331,7 +367,11 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> { `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`, ); }); - await new Promise<void>((resolve) => upstream.listen(upstreamPort, "127.0.0.1", resolve)); + await new Promise<void>((resolve, reject) => { + upstream.once("error", reject); + upstream.listen(0, upstreamHost, resolve); + }); + const upstreamPort = (upstream.address() as net.AddressInfo).port; const serviceProxy = createServiceProxySubsystem({ logger }); const route = serviceProxy.registerWorkspaceService({ @@ -354,6 +394,7 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> { await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve)); return { + upstreamPort, daemonPort, hostname: route.hostname, async close() { @@ -416,6 +457,59 @@ function upgradeThroughProxy( }); } +describe("service proxy loopback services", () => { + it.each(["127.0.0.1", "::1"])("forwards HTTP to a service bound only to %s", async (host) => { + const fixture = await startForwardedHeadersFixture(host); + try { + const direct = await httpGet(fixture.upstreamPort, fixture.hostname, { hostname: host }); + expect(direct.status).toBe(200); + + const response = await httpGet(fixture.daemonPort, fixture.hostname); + expect(response.status).toBe(200); + expect(JSON.parse(response.body).host).toBe(fixture.hostname); + } finally { + await fixture.close(); + } + }); + + it.each(["127.0.0.1", "::1"])( + "streams POST bodies to a service bound only to %s", + async (host) => { + const fixture = await startForwardedHeadersFixture(host); + try { + const payload = JSON.stringify({ message: "hello" }); + const response = await httpPost( + fixture.daemonPort, + fixture.hostname, + "/submit?q=one%20two", + payload, + ); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toMatchObject({ + host: fixture.hostname, + "content-type": "application/json", + method: "POST", + url: "/submit?q=one%20two", + body: payload, + }); + } finally { + await fixture.close(); + } + }, + ); + + it.each(["127.0.0.1", "::1"])("forwards upgrades to a service bound only to %s", async (host) => { + const fixture = await startForwardedHeadersFixture(host); + try { + const headers = await upgradeThroughProxy(fixture.daemonPort, fixture.hostname); + expect(headers.host).toBe(fixture.hostname); + expect(headers.upgrade).toBe("websocket"); + } finally { + await fixture.close(); + } + }); +}); + describe("service proxy forwarded headers", () => { it("forwards the client authority with its port so services build reachable URLs", async () => { const fixture = await startForwardedHeadersFixture(); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index 525a95ac5d..ef93a27f9a 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -1,5 +1,6 @@ import http, { createServer as createHTTPServer } from "node:http"; import net from "node:net"; +import { connectToLocalService, requestLocalService } from "./local-service-connection.js"; import { createHash } from "node:crypto"; import { existsSync, unlinkSync } from "node:fs"; import type { IncomingMessage } from "node:http"; @@ -328,9 +329,8 @@ function proxyHttpRequest({ }): void { const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: req.protocol }); - const proxyReq = http.request( + const proxyReq = requestLocalService( { - hostname: "127.0.0.1", port: route.port, path: req.originalUrl, method: req.method, @@ -368,7 +368,8 @@ function proxyUpgradeRequest({ route: ServiceProxyRoute; logger: Logger; }): void { - const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => { + const targetSocket = connectToLocalService(route.port); + targetSocket.on("connect", () => { const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: "http" }); forwardedHeaders.connection = "Upgrade"; forwardedHeaders.upgrade = req.headers.upgrade ?? "websocket"; diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index aa6773d6df..fb23daa6f0 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -1,11 +1,16 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; +import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; -import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; +import { + createDaemonTestContext, + DaemonClient, + type DaemonTestContext, +} from "./test-utils/index.js"; import type { CreateAgentOptions } from "./test-utils/index.js"; import type { CreateAgentWorktreeTarget } from "./messages.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js"; @@ -96,6 +101,7 @@ async function expectWorktreeListEmpty(repoDir: string): Promise<void> { async function createAgentInBranchOffWorktree(options?: { autoArchive?: boolean; + initialPrompt?: string; branchName?: string; repoDir?: string; }): Promise<{ repoDir: string; agentId: string; worktreePath: string }> { @@ -112,7 +118,7 @@ async function createAgentInBranchOffWorktree(options?: { base: "main", }, ...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), - initialPrompt: "Say done.", + initialPrompt: options?.initialPrompt ?? "Say done.", }); return { repoDir, agentId: created.id, worktreePath: created.cwd }; } @@ -350,24 +356,39 @@ test("archiving a created worktree removes the directory on last reference", asy }); test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => { - const created = await createAgentInBranchOffWorktree({ autoArchive: true }); - - // Create a sibling workspace that shares the same backing directory. - const sibling = await ctx.client.createWorkspace({ - source: { kind: "directory", path: created.worktreePath }, - title: "sibling", + // Hold turn completion while establishing the sibling reference. The original + // ordering raced a fast completed turn against sibling workspace creation. + await ctx.cleanup(); + let finishTurn!: () => void; + const completion = new Promise<void>((resolve) => { + finishTurn = resolve; }); - if (!sibling.workspace) { - throw new Error(sibling.error ?? "Failed to create sibling workspace"); + ctx = await createDaemonTestContext({ + agentClients: createTestAgentClients({ + beforeTurnComplete: async (prompt) => { + if (prompt === "Hold for sibling workspace") await completion; + }, + }), + }); + try { + const created = await createAgentInBranchOffWorktree({ + autoArchive: true, + initialPrompt: "Hold for sibling workspace", + }); + const sibling = await ctx.client.createWorkspace({ + source: { kind: "directory", path: created.worktreePath }, + title: "sibling", + }); + if (!sibling.workspace) throw new Error(sibling.error ?? "Failed to create sibling workspace"); + finishTurn(); + await ctx.client.waitForFinish(created.agentId, 10000); + await expectAgentAbsentFromActiveList(created.agentId); + await expectWorktreePresentInList(created.repoDir, created.worktreePath); + expect(existsSync(created.worktreePath)).toBe(true); + await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); + } finally { + finishTurn(); } - - await ctx.client.waitForFinish(created.agentId, 10000); - - await expectAgentAbsentFromActiveList(created.agentId); - await expectWorktreePresentInList(created.repoDir, created.worktreePath); - expect(existsSync(created.worktreePath)).toBe(true); - - await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); }); test("create_agent_request rejects legacy git options before creating a worktree", async () => { @@ -416,3 +437,248 @@ test("create_agent_request fails cleanly when worktree creation cannot resolve t await expectActiveAgentListEmpty(); await expectWorktreeListEmpty(repoDir); }); + +/** Every agent id with a record under `$PASEO_HOME/agents`, whatever its cwd bucket. */ +function storedAgentIds(): string[] { + const root = path.join(ctx.daemon.paseoHome, "agents"); + if (!existsSync(root)) return []; + return readdirSync(root, { recursive: true, encoding: "utf8" }) + .filter((entry) => entry.endsWith(".json")) + .map((entry) => path.basename(entry, ".json")); +} + +test.each(["git", "worktreeName"] as const)( + "legacy %s worktree creation resolves final workspace visibility without intermediate records", + async (placement) => { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const backgroundCaller = await ctx.client.createAgent({ config, background: true }); + const visibleCaller = await ctx.client.createAgent({ config }); + const cases = [ + { name: "review", background: true, expected: true }, + { name: "review-child", callerAgentId: backgroundCaller.id, expected: true }, + { + name: "visible-child", + callerAgentId: backgroundCaller.id, + background: false, + expected: false, + }, + { name: "hidden-child", callerAgentId: visibleCaller.id, background: true, expected: true }, + { name: "human-default", expected: false }, + ]; + const orderedCases = + placement === "worktreeName" ? [cases[1]!, cases[0]!, ...cases.slice(2)] : cases; + for (const entry of orderedCases) { + const before = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); + const child = await ctx.client.createAgent({ + config, + ...(entry.callerAgentId ? { callerAgentId: entry.callerAgentId } : {}), + ...(entry.background !== undefined ? { background: entry.background } : {}), + ...(placement === "git" + ? { + git: { + createWorktree: true, + createNewBranch: true, + newBranchName: entry.name, + baseBranch: "main", + }, + } + : { worktreeName: entry.name }), + }); + const inclusive = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); + const created = inclusive.entries.filter( + (workspace) => !before.entries.some((existing) => existing.id === workspace.id), + ); + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ + id: child.workspaceId, + workspaceDirectory: child.cwd, + background: entry.expected, + workspaceKind: "worktree", + }); + expect(child.cwd).not.toBe(cwd); + expect(child.labels?.["paseo.parent-agent-id"]).toBe(entry.callerAgentId); + const defaultAgents = await ctx.client.fetchAgents(); + const allAgents = await ctx.client.fetchAgents({ filter: { includeBackground: true } }); + expect(defaultAgents.entries.some((item) => item.agent.id === child.id)).toBe( + !entry.expected, + ); + expect(allAgents.entries.some((item) => item.agent.id === child.id)).toBe(true); + const defaultWorkspaces = await ctx.client.fetchWorkspaces(); + expect( + defaultWorkspaces.entries.some((workspace) => workspace.id === child.workspaceId), + ).toBe(!entry.expected); + } + }, + 30_000, +); + +test("background workspaces keep durable agents addressable through restart and archive", async () => { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const hidden = await ctx.client.createAgent({ + config, + background: true, + idempotencyKey: "background-durable", + }); + expect(hidden.internal).not.toBe(true); + expect( + (await ctx.client.fetchWorkspaces({ filter: { query: hidden.workspaceId } })).entries.map( + (entry) => entry.id, + ), + ).toContain(hidden.workspaceId); + expect(storedAgentIds()).toContain(hidden.id); + expect((await ctx.client.fetchWorkspaces()).entries).not.toContainEqual( + expect.objectContaining({ id: hidden.workspaceId }), + ); + expect( + (await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } })).entries, + ).toContainEqual(expect.objectContaining({ id: hidden.workspaceId, background: true })); + expect((await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain( + hidden.id, + ); + expect( + (await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ), + ).toContain(hidden.id); + const child = await ctx.client.createAgent({ config, callerAgentId: hidden.id }); + expect(child.workspaceId).toBe(hidden.workspaceId); + const inherited = await ctx.client.createWorkspace({ + source: { kind: "directory", path: cwd }, + callerAgentId: hidden.id, + }); + expect(inherited.workspace?.background).toBe(true); + const visible = await ctx.client.createWorkspace({ + source: { kind: "directory", path: cwd }, + callerAgentId: hidden.id, + background: false, + }); + expect(visible.workspace?.background).toBe(false); + const inheritedWorktree = await ctx.client.createAgent({ + config, + callerAgentId: hidden.id, + worktree: { mode: "branch-off", newBranch: "background-child", base: "main" }, + }); + const overriddenWorktree = await ctx.client.createAgent({ + config, + callerAgentId: hidden.id, + background: false, + worktree: { mode: "branch-off", newBranch: "visible-child", base: "main" }, + }); + const workspaces = (await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } })) + .entries; + expect(workspaces.find((entry) => entry.id === inheritedWorktree.workspaceId)?.background).toBe( + true, + ); + expect(workspaces.find((entry) => entry.id === overriddenWorktree.workspaceId)?.background).toBe( + false, + ); + + await expect( + ctx.client.createAgent({ config, workspaceId: hidden.workspaceId, background: false }), + ).rejects.toThrow("workspace creation intent"); + await ctx.client.sendAgentMessage(hidden.id, "Say done."); + await ctx.client.waitForFinish(hidden.id, 10_000); + expect( + (await ctx.client.fetchAgentTimeline(hidden.id, { limit: 20 })).entries.length, + ).toBeGreaterThan(0); + const homeRoot = path.dirname(ctx.daemon.paseoHome); + tempRoots.push(ctx.daemon.staticDir); + await ctx.client.close(); + await ctx.daemon.daemon.stop(); + await ctx.daemon.daemon.agentManager.flush(); + ctx = await createDaemonTestContext({ paseoHomeRoot: homeRoot }); + const replay = await ctx.client.createAgent({ + config, + background: true, + idempotencyKey: "background-durable", + }); + expect(replay.id).toBe(hidden.id); + expect((await ctx.client.fetchAgent(hidden.id))?.agent.workspaceId).toBe(hidden.workspaceId); + await ctx.client.sendAgentMessage(hidden.id, "Say done again."); + await ctx.client.waitForFinish(hidden.id, 10_000); + await ctx.client.archiveAgent(hidden.id); + expect((await ctx.client.fetchAgent(hidden.id))?.agent.archivedAt).toEqual(expect.any(String)); + expect( + (await ctx.client.fetchAgentHistory()).entries.map((entry) => entry.agent.id), + ).not.toContain(hidden.id); + expect( + (await ctx.client.fetchAgentHistory({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ), + ).toContain(hidden.id); +}, 30_000); + +test("default and inclusive observers retain independent snapshots, live updates and catch-up", async () => { + const otherClient = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); + await otherClient.connect(); + try { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const hidden = await ctx.client.createAgent({ config, background: true }); + const visible = await ctx.client.createAgent({ config }); + const inclusive = ctx.client.observeAgents({ + scope: "active", + sync: {}, + includeBackground: true, + }); + const ordinary = otherClient.observeAgents({ scope: "active", sync: {} }); + const inclusiveSnapshot = await inclusive.ready; + const defaultSnapshot = await ordinary.ready; + expect(inclusiveSnapshot.entries.map((entry) => entry.agent.id)).toContain(hidden.id); + expect(defaultSnapshot.entries.map((entry) => entry.agent.id)).toEqual([visible.id]); + const inclusiveWorkspaces = ctx.client.observeWorkspaces({ sync: {}, includeBackground: true }); + const ordinaryWorkspaces = otherClient.observeWorkspaces({ sync: {} }); + const full = await inclusiveWorkspaces.ready; + const partial = await ordinaryWorkspaces.ready; + expect(full.entries.map((entry) => entry.id)).toContain(hidden.workspaceId); + expect(partial.entries.map((entry) => entry.id)).not.toContain(hidden.workspaceId); + const received: string[] = []; + const defaultReceived: string[] = []; + inclusive.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && message.payload.kind === "upsert") + received.push(message.payload.agent.id); + }, + }); + ordinary.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && message.payload.kind === "upsert") + defaultReceived.push(message.payload.agent.id); + }, + }); + await ctx.client.sendAgentMessage(hidden.id, "Say done."); + await ctx.client.waitForFinish(hidden.id, 10_000); + await expect.poll(() => received).toContain(hidden.id); + expect(defaultReceived).not.toContain(hidden.id); + const catchup = await ctx.client.fetchAgents({ + scope: "active", + includeBackground: true, + sync: { + generation: inclusiveSnapshot.sync?.generation, + afterSeq: inclusiveSnapshot.sync?.headSeq, + }, + }); + expect(catchup.sync?.mode).toBe("changes"); + expect(catchup.sync?.removals.map((item) => item.id)).not.toContain(hidden.id); + const scopeChanged = await ctx.client.fetchAgents({ + scope: "active", + includeBackground: true, + sync: { + generation: defaultSnapshot.sync?.generation, + afterSeq: defaultSnapshot.sync?.headSeq, + }, + }); + expect(scopeChanged.sync?.mode).toBe("snapshot"); + expect(scopeChanged.entries.map((entry) => entry.agent.id)).toContain(hidden.id); + await inclusive.release(); + await ordinary.release(); + await inclusiveWorkspaces.release(); + await ordinaryWorkspaces.release(); + } finally { + await otherClient.close(); + } +}, 30_000); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index e60dd95209..e114d8b26e 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -5247,6 +5247,7 @@ test("unions viewed timelines across socket sources and removes detached sources const session = createSessionForTest({ messages, agentManager: { + getAgent: (id: string) => ({ id, internal: false }), subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { agentEventListeners.push(listener); return () => {}; @@ -5354,6 +5355,7 @@ test("keeps selective delivery scoped per socket when a retained session also ha messages, targetedMessages, agentManager: { + getAgent: (id: string) => ({ id, internal: false }), subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { agentEventListeners.push(listener); return () => {}; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d57333da05..08d3f0eefb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import { AgentDirectory } from "./agent/directory.js"; import { searchTimeline } from "./agent/chat-search/index.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; @@ -8,14 +9,14 @@ import type { } from "@getpaseo/protocol/messages"; import { relative } from "node:path"; import { isAbsolute } from "node:path"; -import { CreationService } from "./creation/index.js"; +import { CreationService, type CreatedAgent } from "./creation/index.js"; import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages"; import type { MessageReceipts } from "./message-receipts/index.js"; import equal from "fast-deep-equal"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; -import { basename, resolve, sep } from "path"; +import { basename, join, resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; @@ -248,6 +249,7 @@ import { import { WorkspaceAutoName } from "./workspace-auto-name.js"; import { buildAgentSessionConfig as buildWorktreeAgentSessionConfig, + normalizeAgentCreationGitOptions, createPaseoWorktreeWorkflow as createWorktreeWorkflow, type CreatePaseoWorktreeSetupContinuationInput, type CreatePaseoWorktreeWorkflowResult, @@ -724,6 +726,7 @@ export class Session { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly agentDirectory: AgentDirectory; private readonly directorySync: DirectorySyncService; private readonly filesystem: SessionFileSystem; private readonly github: ForgeService; @@ -1087,7 +1090,22 @@ export class Session { this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source), getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), }); + this.agentDirectory = new AgentDirectory({ + manager: this.agentManager, + storage: this.agentStorage, + workspaces: this.workspaceRegistry, + projectLive: (agent) => this.buildAgentPayload(agent), + projectStored: (record) => this.buildStoredAgentPayload(record), + isProviderVisible: (provider) => this.isProviderVisibleToClient(provider), + isStoredProviderAvailable: (record) => + isStoredAgentProviderAvailable( + record, + new Set(this.providerSnapshotManager.listRegisteredProviderIds()), + ), + }); this.agentUpdates = createAgentUpdatesService({ + includesWorkspace: (agent, includeBackground) => + this.agentDirectory.includes(agent, includeBackground), emit: (message) => this.emit(message), enrichAgentPayload: (payload) => this.enrichAgentPayload(payload), buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record), @@ -1096,12 +1114,13 @@ export class Session { this.buildProjectPlacementForWorkspaceId(workspaceId), emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), - sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) => + sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence, includeBackground) => this.directorySync.sequenceAgentUpdate( payload, agent && project ? { agent, project } : null, agentId, includeSequence, + includeBackground, ), logger: this.sessionLogger, }); @@ -1164,7 +1183,7 @@ export class Session { logger: this.sessionLogger, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, - listAgentPayloads: () => this.listAgentPayloads(), + listAgentPayloads: () => this.listAgentPayloads({ includeBackground: true }), listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(), listTerminalActivityContributions: () => this.listTerminalActivityContributions(), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), @@ -1410,6 +1429,7 @@ export class Session { } // COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12. // Older clients create before subscribing and require the causal update beside the response. + if (workspace.background) return; this.emit({ type: "workspace_update", payload: { @@ -1929,6 +1949,12 @@ export class Session { } if (event.type === "provider_subagent") { + // The subagents track of an internal parent stays hidden. + const parentAgentId = + event.event.type === "upsert" + ? event.event.subagent.parentAgentId + : event.event.parentAgentId; + if (this.agentManager.getAgent(parentAgentId)?.internal) return; this.emitProviderSubagentWorkspaceUpdate(event.event); this.forwardProviderSubagentUpdate(event.event); return; @@ -1974,7 +2000,10 @@ export class Session { this.forwardAgentStream(event, serializedEvent); - if (event.event.type === "permission_requested") { + // An internal agent streams only to clients subscribed to it by id; + // session-wide prompts would surface it everywhere. + const internal = this.agentManager.getAgent(event.agentId)?.internal === true; + if (event.event.type === "permission_requested" && !internal) { this.emit({ type: "agent_permission_request", payload: { @@ -1996,7 +2025,9 @@ export class Session { // Title updates may be applied asynchronously after agent creation. }, - { replayState: false }, + // Internal agents flow through so exact-id subscribers and opted-in + // listings see them; everything else above filters them out. + { replayState: false, includeInternal: true }, ); } @@ -3231,7 +3262,7 @@ export class Session { agentId, ); - if (this.agentUpdates.hasSubscription()) { + if (archivedRecord && this.agentUpdates.hasSubscription()) { const payload = await this.agentUpdates.emitStoredRecord(archivedRecord); if (payload.workspaceId) { await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); @@ -4173,7 +4204,7 @@ export class Session { if (!record) throw new Error("Previously created agent no longer exists"); agent = this.buildStoredAgentPayload(record); } else { - agent = await this.createSessionAgent(msg); + ({ agent } = await this.createSessionAgent(msg)); } this.emit({ type: "status", @@ -4208,11 +4239,29 @@ export class Session { } } + private validatePublicAgentWorkspaceIntent( + request: CreateAgentRequestMessage, + createsWorktree: boolean, + ): void { + if (request.internal) + throw new Error( + "Public internal agents were removed; create a background workspace instead.", + ); + if ( + request.background !== undefined && + (request.workspaceId || (request.callerAgentId && !createsWorktree)) + ) { + throw new Error( + "background is workspace creation intent; configure the selected workspace at creation.", + ); + } + } + private async createSessionAgent( msg: CreateAgentRequestMessage, agentId?: string, onAgentReady?: (agent: AgentSnapshotPayload) => Promise<void>, - ): Promise<AgentSnapshotPayload> { + ): Promise<CreatedAgent> { const { config, worktreeName, @@ -4242,6 +4291,9 @@ export class Session { if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) { throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`); } + // Resolve all supported worktree input forms before provisioning workspace ownership. + const gitIntent = normalizeAgentCreationGitOptions(git, worktreeName, worktree); + this.validatePublicAgentWorkspaceIntent(msg, gitIntent?.createWorktree === true); const trimmedPrompt = initialPrompt?.trim(); const { provisionalTitle } = resolveCreateAgentTitles({ configTitle: config.title, @@ -4255,9 +4307,11 @@ export class Session { const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext); const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({ cwd: config.cwd, - target: worktree, + target: gitIntent, firstAgentContext, - hasLegacyGitOptions: Boolean(git), + setupContinuation: worktree ? undefined : this.agentWorktreeSetupContinuation(), + background: msg.background, + callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId), }); createdWorktreeForCleanup = createdWorktree; const resolvedIntent = await this.resolveSessionCreateAgentIntent({ @@ -4270,7 +4324,7 @@ export class Session { throw new Error(`Working directory does not exist or is not a directory: ${resolvedCwd}`); } - const { snapshot, liveSnapshot } = await createAgentCommand( + const { snapshot, liveSnapshot, initialPromptStarted } = await createAgentCommand( { agentManager: this.agentManager, agentStorage: this.agentStorage, @@ -4290,6 +4344,9 @@ export class Session { workspaceId: resolvedIntent.intent.workspaceId, worktreeName, initialPrompt, + source: msg.callerAgentId + ? { kind: "agent-message", agentId: msg.callerAgentId } + : undefined, clientMessageId, outputSchema, images, @@ -4299,8 +4356,14 @@ export class Session { env, provisionalTitle, firstAgentContext, - buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) => - this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx), + buildSessionConfig: async (sessionConfig, gitOptions, legacyWorktreeName, ctx) => + createdWorktree + ? { + sessionConfig, + setupContinuation: createdWorktree.setupContinuation, + createdWorkspaceId: createdWorktree.workspace.workspaceId, + } + : this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx), }, ); createdAgentId = snapshot.id; @@ -4324,7 +4387,7 @@ export class Session { { agentId: snapshot.id, provider: snapshot.provider }, "Created agent", ); - return this.buildAgentPayload(liveSnapshot); + return { agent: await this.buildAgentPayload(liveSnapshot), initialPromptStarted }; } catch (error) { await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({ createdWorktree: createdWorktreeForCleanup, @@ -4370,6 +4433,8 @@ export class Session { createdWorktree: null, cwd: config.cwd, initialTitle: input.workspacePromptTitle, + background: request.background, + callerWorkspaceId: callerAgent?.workspaceId, }), cwd: config.cwd, }), @@ -4768,6 +4833,26 @@ export class Session { } } + private agentWorktreeSetupContinuation(): CreatePaseoWorktreeSetupContinuationInput { + return { + kind: "agent", + terminalManager: this.terminalManager, + appendTimelineItem: ({ agentId, item }) => + appendTimelineItemIfAgentKnown({ + agentManager: this.agentManager, + agentId, + item, + }), + emitLiveTimelineItem: ({ agentId, item }) => + emitLiveTimelineItemIfAgentKnown({ + agentManager: this.agentManager, + agentId, + item, + }), + logger: this.sessionLogger, + }; + } + private async buildAgentSessionConfig( config: AgentSessionConfig, gitOptions?: GitSetupOptions, @@ -4787,23 +4872,7 @@ export class Session { createPaseoWorktree: (input, serviceOptions) => this.createPaseoWorktreeWorkflow(input, { ...serviceOptions, - setupContinuation: { - kind: "agent", - terminalManager: this.terminalManager, - appendTimelineItem: ({ agentId, item }) => - appendTimelineItemIfAgentKnown({ - agentManager: this.agentManager, - agentId, - item, - }), - emitLiveTimelineItem: ({ agentId, item }) => - emitLiveTimelineItemIfAgentKnown({ - agentManager: this.agentManager, - agentId, - item, - }), - logger: this.sessionLogger, - }, + setupContinuation: this.agentWorktreeSetupContinuation(), }), checkoutExistingBranch: (cwd, branch) => this.gitMutation.checkoutExistingBranch(cwd, branch), @@ -5056,8 +5125,13 @@ export class Session { try { const workspaceCwd = cwd?.trim(); const searchesWorkspace = Boolean(workspaceCwd); + const homeRoot = process.env.HOME ?? homedir(); + // readdir on TCC-protected folders under ~/Library blocks forever for a process that cannot + // show a consent prompt, and each blocked call holds a libuv threadpool thread. + const excludedDiscoveryPaths = + !searchesWorkspace && process.platform === "darwin" ? [join(homeRoot, "Library")] : []; const entries = await searchDirectoryEntries({ - root: workspaceCwd ? expandTilde(workspaceCwd) : (process.env.HOME ?? homedir()), + root: workspaceCwd ? expandTilde(workspaceCwd) : homeRoot, query, pathFormat: searchesWorkspace ? "relative" : "absolute", pathQueryPolicy: searchesWorkspace ? "slashes" : "rooted", @@ -5068,6 +5142,7 @@ export class Session { : [], confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000, respectGitIgnore: searchesWorkspace, + excludedDiscoveryPaths, includeFiles, includeDirectories, matchMode, @@ -5166,52 +5241,10 @@ export class Session { /** * Build the current agent list payload (live + persisted), optionally filtered by labels. */ - private async listAgentPayloads(filter?: { - labels?: Record<string, string>; - includeArchived?: boolean; - includeUnavailablePersisted?: boolean; - }): Promise<AgentSnapshotPayload[]> { - const includeArchived = filter?.includeArchived === true; - const labelEntries = filter?.labels ? Object.entries(filter.labels) : []; - - // Get live agents with session modes - const agentSnapshots = this.agentManager.listAgents(); - const liveAgents = await Promise.all( - agentSnapshots.map((agent) => this.buildAgentPayload(agent)), - ); - - // Add persisted agents that have not been lazily initialized yet - // (excluding internal agents which are for ephemeral system tasks) - const registryRecords = await this.agentStorage.list(); - const liveIds = new Set(agentSnapshots.map((a) => a.id)); - const registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()); - const persistedAgents = registryRecords - .filter((record) => !liveIds.has(record.id) && !record.internal) - // Keep raw-record filters ahead of projection; seeded homes can carry thousands of archived agents. - .filter((record) => includeArchived || !record.archivedAt) - .filter((record) => labelEntries.every(([key, value]) => record.labels?.[key] === value)) - .filter( - (record) => - filter?.includeUnavailablePersisted === true || - isStoredAgentProviderAvailable(record, registeredProviderIds), - ) - .map((record) => this.buildStoredAgentPayload(record, registeredProviderIds)); - - let agents = [...liveAgents, ...persistedAgents]; - - agents = agents.filter((agent) => this.isProviderVisibleToClient(agent.provider)); - if (!includeArchived) { - agents = agents.filter((agent) => !agent.archivedAt); - } - - // Filter by labels if filter provided - if (labelEntries.length > 0) { - agents = agents.filter((agent) => - labelEntries.every(([key, value]) => agent.labels[key] === value), - ); - } - - return agents; + private listAgentPayloads( + filter?: Parameters<AgentDirectory["list"]>[0], + ): Promise<AgentSnapshotPayload[]> { + return this.agentDirectory.list(filter); } private async resolveAgentIdentifier( @@ -5222,6 +5255,13 @@ export class Session { return { ok: false, notFound: false, error: "Agent identifier cannot be empty" }; } + // An internal agent, live or recently archived, is addressable by its exact + // id and nothing else: the caller that created it holds the id, and no + // listing ever includes it. + if (this.agentManager.getAgent(trimmed) || this.agentManager.getRetiredInternalAgent(trimmed)) { + return { ok: true, agentId: trimmed }; + } + const stored = await this.agentStorage.list(); const storedRecords = stored.filter((record) => !record.internal); const knownIds = new Set<string>(); @@ -5269,6 +5309,25 @@ export class Session { return { ok: false, notFound: true, error: `Agent not found: ${trimmed}` }; } + /** + * An agent that is no longer live, in its settled form: the retained snapshot + * of a recently archived internal agent, else its stored record. Null when + * neither exists, and for a stored internal record, which is never served. + */ + private async readSettledAgent( + agentId: string, + ): Promise<{ record: StoredAgentRecord; lastMessage: string | null } | null> { + const retired = this.agentManager.getRetiredInternalAgent(agentId); + if (retired) { + return { record: retired.record, lastMessage: retired.lastMessage }; + } + const record = await this.agentStorage.get(agentId); + if (!record || record.internal) { + return null; + } + return { record, lastMessage: null }; + } + private async getAgentPayloadById(agentId: string): Promise<AgentSnapshotPayload | null> { const live = this.agentManager.getAgent(agentId); if (live) { @@ -5276,11 +5335,11 @@ export class Session { return this.isProviderVisibleToClient(payload.provider) ? payload : null; } - const record = await this.agentStorage.get(agentId); - if (!record || record.internal) { + const settled = await this.readSettledAgent(agentId); + if (!settled) { return null; } - const payload = this.buildStoredAgentPayload(record); + const payload = this.buildStoredAgentPayload(settled.record); return this.isProviderVisibleToClient(payload.provider) ? payload : null; } @@ -5404,6 +5463,9 @@ export class Session { let agents = await this.listAgentPayloads({ labels: filter?.labels, includeArchived: filter?.includeArchived, + includeBackground: + filter?.includeBackground || + (request.type === "fetch_agents_request" && request.includeBackground), includeUnavailablePersisted: request.type === "fetch_agent_history_request", }); const activePlacementsByWorkspaceId = @@ -5547,6 +5609,7 @@ export class Session { title: workspace.title, pinnedAt: workspace.pinnedAt, ...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}), + background: workspace.background, archivingAt: null, status: "done", statusEnteredAt: null, @@ -5632,6 +5695,7 @@ export class Session { worktreeSlug: basename(result.worktree.worktreePath), projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, + background: result.workspace.background, name: resolveWorkspaceName({ title: result.workspace.title, derivedDisplayName: result.worktree.branchName || result.workspace.displayName, @@ -5710,7 +5774,13 @@ export class Session { pageInfo: FetchWorkspacesResponsePageInfo; }> { try { - return await this.workspaceDirectory.listFetchEntries(request); + return await this.workspaceDirectory.listFetchEntries({ + ...request, + filter: { + ...request.filter, + ...(request.includeBackground ? { includeBackground: true } : {}), + }, + }); } catch (error) { if (error instanceof CursorError) { throw new SessionRequestError("invalid_cursor", error.message); @@ -6012,6 +6082,7 @@ export class Session { workspace ?? null, workspaceId, subscription.syncEnabled === true, + subscription.filter?.includeBackground, ), ); continue; @@ -6022,6 +6093,7 @@ export class Session { workspace ?? null, workspaceId, subscription.syncEnabled === true, + subscription.filter?.includeBackground, ); if ( @@ -6127,7 +6199,10 @@ export class Session { subscriptionId: owner.id, isProviderVisible: (provider) => this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)), - filter: request.filter, + filter: { + ...request.filter, + ...(request.includeBackground ? { includeBackground: true } : {}), + }, syncEnabled: Boolean(request.sync), emit: (message) => { if (message.type === "agent_update") owner.emit(message); @@ -6266,7 +6341,10 @@ export class Session { subscriptionId: owner.id, owner, syncEnabled: Boolean(request.sync), - filter: request.filter, + filter: { + ...request.filter, + ...(request.includeBackground ? { includeBackground: true } : {}), + }, isBootstrapping: true, pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), @@ -6522,24 +6600,41 @@ export class Session { } const snapshot = await this.listFetchAgentsEntries({ ...request, + includeBackground: true, page: { limit: Number.MAX_SAFE_INTEGER }, }); - return this.directorySync.synchronizeAgents(snapshot.entries, request.sync ?? {}); + const result = this.directorySync.synchronizeAgents( + snapshot.entries, + request.sync ?? {}, + request.includeBackground, + ); + const entries = await Promise.all( + result.entries.map(async (entry) => + (await this.agentDirectory.includes(entry.agent, request.includeBackground)) ? entry : null, + ), + ); + return { ...result, entries: entries.filter((entry) => entry !== null) }; } private async readWorkspaceDirectorySync( request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>, ) { - if (request.filter) { + if (request.filter) throw new SessionRequestError( "invalid_request", "Sequenced workspace directory reads do not support filters.", ); - } - return this.directorySync.synchronizeWorkspaces( + const result = this.directorySync.synchronizeWorkspaces( await this.workspaceDirectory.listDescriptors(), request.sync ?? {}, + request.includeBackground, ); + return { + ...result, + entries: result.entries.filter( + (workspace) => request.includeBackground || !workspace.background, + ), + }; } // Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates` @@ -6616,10 +6711,23 @@ export class Session { } } + private getCallerWorkspaceId(callerAgentId?: string): string | undefined { + if (!callerAgentId) return undefined; + const caller = this.agentManager.getAgent(callerAgentId); + if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`); + return caller.workspaceId; + } + private async createRequestedWorkspace( request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>, workspaceId?: string, ): Promise<WorkspaceDescriptorPayload> { + if (request.internal) + throw new Error( + "Public internal workspaces were removed; use background workspace creation.", + ); + if (request.agent?.background !== undefined) + throw new Error("Configure background on the workspace, not its initial agent."); let creationRequest = request; // Hooks belong to the operation: retries fingerprint the caller's input // and must not rerun hooks or compare their potentially changing output. @@ -6653,7 +6761,12 @@ export class Session { cwd, explicitTitle ?? promptTitle, request.source.projectId, - { expectsInitialAgent: Boolean(request.firstAgentContext), workspaceId }, + { + expectsInitialAgent: Boolean(request.firstAgentContext), + workspaceId, + background: request.background, + callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId), + }, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); @@ -6715,6 +6828,8 @@ export class Session { githubPrNumber: source.githubPrNumber, firstAgentContext: request.firstAgentContext, title: request.title, + background: request.background, + callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId), }, source.baseBranch ? { resolveDefaultBranch: async () => source.baseBranch as string } @@ -7388,7 +7503,7 @@ export class Session { const requestedWorkspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId]; let agents: AgentSnapshotPayload[]; try { - agents = await this.listAgentPayloads(); + agents = await this.listAgentPayloads({ includeBackground: true }); } catch (error) { const message = getErrorMessage(error); const results = requestedWorkspaceIds.map((requestedWorkspaceId) => ({ @@ -7528,7 +7643,7 @@ export class Session { throw new Error(`Workspace not found: ${workspaceId}`); } - const agents = (await this.listAgentPayloads()).filter((agent) => + const agents = (await this.listAgentPayloads({ includeBackground: true })).filter((agent) => this.isProviderVisibleToClient(agent.provider), ); const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)); @@ -8088,6 +8203,9 @@ export class Session { agentStorage: this.agentStorage, agentId, prompt, + source: msg.sourceAgentId + ? { kind: "agent-message", agentId: msg.sourceAgentId } + : undefined, messageId: msg.messageId, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt", clearPendingPermissions: true, @@ -8105,9 +8223,13 @@ export class Session { await this.messageReceipts.send({ agentId, messageId: msg.messageId, - request: { prompt, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt" }, + request: { + prompt, + sourceAgentId: msg.sourceAgentId, + activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt", + }, prepare: async () => { - await this.prepareAgentMessage(agentId, msg.text); + if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text); }, send, }); @@ -8164,8 +8286,8 @@ export class Session { const agentId = resolved.agentId; const live = this.agentManager.getAgent(agentId); if (!live) { - const record = await this.agentStorage.get(agentId); - if (!record || record.internal) { + const settled = await this.readSettledAgent(agentId); + if (!settled) { this.emit({ type: "wait_for_finish_response", payload: { @@ -8178,6 +8300,7 @@ export class Session { }); return; } + const { record, lastMessage } = settled; const final = this.buildStoredAgentPayload(record); let status: "permission" | "error" | "idle"; if (record.attentionReason === "permission") { @@ -8190,7 +8313,7 @@ export class Session { const error = resolveWaitForFinishError({ status, final }); this.emit({ type: "wait_for_finish_response", - payload: { requestId, status, final, error, lastMessage: null }, + payload: { requestId, status, final, error, lastMessage }, }); return; } diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 001966ab39..b9c04e8244 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -9514,6 +9514,50 @@ test("workspace.create.request attaches a directory workspace to its explicit ac }); }); +test("workspace.create.request with background persists a hidden workspace that only opted-in listings show", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspaces = new Map<string, PersistedWorkspaceRecord>(); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + session.workspaceRegistry.upsert = async (record: unknown) => { + const workspace = record as PersistedWorkspaceRecord; + workspaces.set(workspace.workspaceId, workspace); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-background-workspace", + background: true, + source: { kind: "directory", path: REPO_CWD }, + }); + + const response = findByType(emitted, "workspace.create.response"); + expect(response?.payload).toMatchObject({ + requestId: "req-background-workspace", + error: null, + workspace: { background: true }, + }); + const workspaceId = response?.payload.workspace?.id as string; + expect(workspaces.get(workspaceId)?.background).toBe(true); + // No subscription exists, and the legacy causal update stays quiet for a hidden workspace. + expect(filterByType(emitted, "workspace_update")).toEqual([]); + + const hidden = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-list-hidden", + }); + expect(hidden.entries).toEqual([]); + + const shown = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-list-shown", + filter: { includeBackground: true }, + }); + expect(shown.entries.map((entry) => [entry.id, entry.background])).toEqual([[workspaceId, true]]); +}); + test("workspace.create.request reports an unknown explicit project", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts index 4c83cec6a3..78343c95ac 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts @@ -36,6 +36,7 @@ function makeAgentPayload(input: { requiresAttention?: boolean; effectiveThinkingOptionId?: string | null; thinkingOptionId?: string | null; + internal?: boolean; }): AgentSnapshotPayload { const updatedAt = input.updatedAt ?? "2026-03-01T12:00:00.000Z"; const provider = input.provider ?? "codex"; @@ -44,6 +45,7 @@ function makeAgentPayload(input: { provider, cwd: "/tmp/repo", ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input.internal ? { internal: true } : {}), model: null, thinkingOptionId: input.thinkingOptionId ?? null, effectiveThinkingOptionId: input.effectiveThinkingOptionId ?? null, @@ -235,6 +237,15 @@ describe("matchesAgentUpdatesFilter", () => { ); }); + test("internal agents are excluded unless includeInternal", () => { + const agent = makeAgentPayload({ id: "a", internal: true }); + expect(matchesAgentUpdatesFilter({ agent, project })).toBe(false); + expect(matchesAgentUpdatesFilter({ agent, project, filter: {} })).toBe(false); + expect(matchesAgentUpdatesFilter({ agent, project, filter: { includeInternal: true } })).toBe( + true, + ); + }); + test("thinking-option filter compares the resolved option", () => { const high = makeAgentPayload({ id: "a", effectiveThinkingOptionId: "high" }); expect( diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index 58521f336f..f17e2a2067 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -47,6 +47,7 @@ export interface AgentUpdatesService { } export interface AgentUpdatesServiceDeps { + includesWorkspace?(agent: AgentSnapshotPayload, includeBackground?: boolean): Promise<boolean>; emit(message: SessionOutboundMessage): void; enrichAgentPayload(payload: AgentSnapshotPayload): Promise<AgentSnapshotPayload>; buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload; @@ -59,6 +60,7 @@ export interface AgentUpdatesServiceDeps { project: ProjectPlacementPayload | null, agentId: string, includeSequence: boolean, + includeBackground?: boolean, ): T; logger: pino.Logger; } @@ -136,6 +138,10 @@ export function matchesAgentUpdatesFilter(input: { return false; } + if (agent.internal && filter?.includeInternal !== true) { + return false; + } + if (filter && !agentThinkingOptionMatchesFilter(agent, filter)) { return false; } @@ -160,7 +166,15 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU agent: AgentSnapshotPayload | null, project: ProjectPlacementPayload | null, agentId: string, - ) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true); + ) => + deps.sequenceAgentUpdate( + payload, + agent, + project, + agentId, + sub.syncEnabled === true, + sub.filter?.includeBackground, + ); function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void { if (subscriptions.get(sub.subscriptionId) !== sub) return; @@ -234,10 +248,16 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU const project = payload.workspaceId ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) : null; + const backgroundVisible = await Promise.all( + observers.map( + (sub) => deps.includesWorkspace?.(payload, sub.filter?.includeBackground) ?? true, + ), + ); return ( project !== null && observers.some( - (sub) => + (sub, index) => + backgroundVisible[index] && sub.isProviderVisible(payload.provider) && matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }), ) @@ -252,7 +272,9 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU : null; for (const sub of observers) { const matches = - project && matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }); + project && + ((await deps.includesWorkspace?.(payload, sub.filter?.includeBackground)) ?? true) && + matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }); bufferOrEmit( sub, sequence( diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index b84734eb8b..2692250b49 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -145,6 +145,46 @@ test("fresh non-git directory creates a directory workspace at the exact path", expect(workspace.cwd).toBe(dir); }); +test("background workspace creation preserves hooks, inheritance and durable visibility", async () => { + const dir = path.join(tmpDir, "plain"); + const events: string[] = []; + provisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: gitService(), + isDirectory, + logger, + lifecycle: { + emit: (name) => { + events.push(name); + }, + before: async (_name, request) => request, + }, + }); + const parent = await provisioning.createWorkspaceForDirectory(dir, null, undefined, { + background: true, + }); + const child = await provisioning.createWorkspaceForDirectory(dir, null, undefined, { + callerWorkspaceId: parent.workspaceId, + }); + const visible = await provisioning.createWorkspaceForDirectory(dir, null, undefined, { + callerWorkspaceId: parent.workspaceId, + background: false, + }); + const human = await provisioning.createWorkspaceForDirectory(dir); + expect(parent.background).toBe(true); + expect(child.background).toBe(true); + expect(visible.background).toBe(false); + expect(human.background).toBe(false); + expect(events).toEqual(Array(4).fill("workspace.created")); + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await reloaded.initialize(); + expect((await reloaded.get(child.workspaceId))?.background).toBe(true); +}); + test("re-opening an active workspace by exact path returns the same record without duplicating", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); @@ -236,6 +276,30 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps expect(reopened.archivedAt).toBeNull(); }); +test("default directory open skips archived background workspaces while explicit recovery preserves their visibility", async () => { + const cwd = path.join(tmpDir, "repo"); + mkdirSync(cwd, { recursive: true }); + const background = await provisioning.createWorkspaceForDirectory(cwd, undefined, undefined, { + background: true, + }); + await workspaceRegistry.archive(background.workspaceId, ARCHIVED_AT); + + const opened = await provisioning.findOrCreateWorkspaceForDirectory(cwd); + expect(opened).toMatchObject({ background: false, archivedAt: null }); + expect(opened.workspaceId).not.toBe(background.workspaceId); + const archived = await workspaceRegistry.get(background.workspaceId); + expect(archived).toMatchObject({ background: true, archivedAt: ARCHIVED_AT }); + const recovered = await provisioning.ensureWorkspaceRecordUnarchived(archived!); + expect(recovered).toMatchObject({ + workspaceId: background.workspaceId, + background: true, + archivedAt: null, + }); + expect((await provisioning.findOrCreateWorkspaceForDirectory(cwd)).workspaceId).toBe( + opened.workspaceId, + ); +}); + test("reopening archived exact-root records restores the fresh Git project", async () => { const cwd = path.join(tmpDir, "repo"); mkdirSync(cwd, { recursive: true }); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 5149ed5d1c..6dbcabdda6 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -26,6 +26,8 @@ export interface ResolveOrCreateWorkspaceIdInput { requestedWorkspaceId?: string; cwd: string; initialTitle: string | null; + background?: boolean; + callerWorkspaceId?: string; } export interface ImportWorkspaceInput { @@ -50,6 +52,8 @@ export interface CreateWorktreeWorkspaceInput { title: string | null; expectsInitialAgent?: boolean; untrustedSource?: UntrustedWorkspaceSource; + background?: boolean; + callerWorkspaceId?: string; } export interface WorkspaceProvisioningService { @@ -63,7 +67,12 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean; workspaceId?: string }, + context?: { + expectsInitialAgent?: boolean; + workspaceId?: string; + background?: boolean; + callerWorkspaceId?: string; + }, ): Promise<PersistedWorkspaceRecord>; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -215,11 +224,27 @@ export function createWorkspaceProvisioningService(deps: { return project; } + async function resolveBackground(input?: { + background?: boolean; + callerWorkspaceId?: string; + }): Promise<boolean> { + if (input?.background !== undefined) return input.background; + if (!input?.callerWorkspaceId) return false; + const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId); + if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`); + return callerWorkspace.background; + } + async function createWorkspaceForDirectory( cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean; workspaceId?: string }, + context?: { + expectsInitialAgent?: boolean; + workspaceId?: string; + background?: boolean; + callerWorkspaceId?: string; + }, ): Promise<PersistedWorkspaceRecord> { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -235,9 +260,12 @@ export function createWorkspaceProvisioningService(deps: { title: title?.trim() || null, createdAt: timestamp, updatedAt: timestamp, + background: await resolveBackground(context), }); - await workspaceRegistry.upsert(workspace, context); - deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + await workspaceRegistry.upsert(workspace, { + expectsInitialAgent: context?.expectsInitialAgent, + }); + emitWorkspaceCreated(workspace); return workspace; } @@ -269,14 +297,19 @@ export function createWorkspaceProvisioningService(deps: { createdAt: timestamp, updatedAt: timestamp, ...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}), + background: await resolveBackground(input), }); await workspaceRegistry.upsert(workspace, { expectsInitialAgent: input.expectsInitialAgent, }); - deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + emitWorkspaceCreated(workspace); return workspace; } + function emitWorkspaceCreated(workspace: PersistedWorkspaceRecord): void { + deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + } + async function resolveSourceProjectForWorktree(input: { sourceCwd: string; projectId?: string; @@ -320,26 +353,19 @@ export function createWorkspaceProvisioningService(deps: { async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> { const normalizedCwd = resolve(cwd); - const workspaces = await workspaceRegistry.list(); - const active = workspaces + // Path-based discovery selects public work; explicit-ID recovery bypasses this selector. + const workspaces = (await workspaceRegistry.list()) .filter( - (workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd), + (workspace) => !workspace.background && areEquivalentPaths(workspace.cwd, normalizedCwd), ) .sort( (left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.workspaceId.localeCompare(right.workspaceId), - )[0]; + ); + const active = workspaces.find((workspace) => !workspace.archivedAt); if (active) return refreshWorkspaceRecord(active); - const archived = workspaces - .filter( - (workspace) => workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd), - ) - .sort( - (left, right) => - Date.parse(left.createdAt) - Date.parse(right.createdAt) || - left.workspaceId.localeCompare(right.workspaceId), - )[0]; + const archived = workspaces.find((workspace) => workspace.archivedAt); if (archived) { const project = await projectRegistry.get(archived.projectId); if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived); @@ -355,6 +381,8 @@ export function createWorkspaceProvisioningService(deps: { return ( await createWorkspaceForDirectory(input.cwd, input.initialTitle, undefined, { expectsInitialAgent: true, + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, }) ).workspaceId; } diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts new file mode 100644 index 0000000000..e40cc25384 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { + ensureLocalSpeechModels, + getLocalSpeechModelDir, + listLocalSpeechModels, + listMissingLocalSpeechModels, +} from "./models.js"; + +const logger = pino({ level: "silent" }); +const modelId = "parakeet-tdt-0.6b-v2-int8"; +const spec = listLocalSpeechModels().find((model) => model.id === modelId)!; + +function writeCompleteModel(modelDir: string): void { + mkdirSync(modelDir, { recursive: true }); + for (const file of spec.requiredFiles) { + writeFileSync(path.join(modelDir, file), `complete ${file}`.padEnd(64_000, ".")); + } +} + +function setupModelsDirWithArchive(): { modelsDir: string; modelDir: string; archive: string } { + const root = mkdtempSync(path.join(tmpdir(), "paseo-local-models-")); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + const sourceRoot = path.join(root, "source"); + writeCompleteModel(path.join(sourceRoot, spec.extractedDir)); + const modelsDir = path.join(root, "models"); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", sourceRoot, spec.extractedDir]); + return { modelsDir, modelDir: getLocalSpeechModelDir(modelsDir, modelId), archive }; +} + +describe("local speech models", () => { + test("reinstalls a model whose extraction was interrupted", async () => { + // An interrupted extraction leaves the archive in .downloads and a truncated file in place. + const { modelsDir, modelDir, archive } = setupModelsDirWithArchive(); + writeCompleteModel(modelDir); + const encoder = path.join(modelDir, spec.requiredFiles[0]); + truncateSync(encoder, 1_000); + + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ + modelId, + ]); + + await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + + expect(readFileSync(encoder, "utf8")).toBe( + `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), + ); + expect(existsSync(archive)).toBe(false); + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index f6c7cc4e4e..a884be86d7 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,4 +1,8 @@ -import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; +import { + ensureSherpaOnnxModels, + getSherpaOnnxModelDir, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, @@ -30,6 +34,16 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } +export async function listMissingLocalSpeechModels(options: { + modelsDir: string; + modelIds: LocalSpeechModelId[]; +}): Promise<LocalSpeechModelId[]> { + const installed = await Promise.all( + options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), + ); + return options.modelIds.filter((_modelId, index) => !installed[index]); +} + export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts index 993c959b4d..bc2e462a53 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts @@ -1,10 +1,21 @@ import { describe, expect, test } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, + truncateSync, + readdirSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { ensureSherpaOnnxModel, getSherpaOnnxModelDir } from "./model-downloader.js"; +import { getSherpaOnnxModelSpec } from "./model-catalog.js"; function makeTmpDir(): string { return mkdtempSync(path.join(tmpdir(), "paseo-speech-models-")); @@ -13,6 +24,67 @@ function makeTmpDir(): string { const logger = pino({ level: "silent" }); describe("sherpa model downloader", () => { + test("does not expose a partial model when tar extraction fails", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + mkdirSync(sourceDir, { recursive: true }); + mkdirSync(path.dirname(archive), { recursive: true }); + writeFileSync(path.join(sourceDir, "model.onnx"), Buffer.alloc(32_768, 1)); + execFileSync("tar", [ + "cf", + archive, + "-C", + path.dirname(sourceDir), + `${spec.extractedDir}/model.onnx`, + ]); + truncateSync(archive, 1_024); + await expect(ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger })).rejects.toThrow( + "tar exited", + ); + expect(existsSync(getSherpaOnnxModelDir(modelsDir, spec.id))).toBe(false); + expect(existsSync(archive)).toBe(true); + expect(readdirSync(modelsDir)).toEqual([".downloads"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("repairs a partial legacy extraction when its cached archive remains", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const modelDir = getSherpaOnnxModelDir(modelsDir, spec.id); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + for (const dir of [sourceDir, modelDir]) { + mkdirSync(path.join(dir, "espeak-ng-data"), { recursive: true }); + for (const filename of ["model.onnx", "voices.bin", "tokens.txt"]) { + writeFileSync(path.join(dir, filename), "complete model data"); + } + } + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", path.dirname(sourceDir), spec.extractedDir]); + writeFileSync(path.join(modelDir, "model.onnx"), "truncated"); + await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger }); + expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("getSherpaOnnxModelDir maps modelId to extractedDir", () => { const modelsDir = "/tmp/models"; expect(getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8")).toContain( diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index 8ce21f5e01..cdd286389f 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -90,6 +90,38 @@ async function extractTarArchive( }); } +interface SherpaOnnxModelPaths { + modelDir: string; + archivePath: string; + extractionDir: string; +} + +function getSherpaOnnxModelPaths( + modelsDir: string, + modelId: SherpaOnnxModelId, +): SherpaOnnxModelPaths { + const spec = getSherpaOnnxModelSpec(modelId); + const downloadsDir = path.join(modelsDir, ".downloads"); + return { + modelDir: path.join(modelsDir, spec.extractedDir), + archivePath: path.join(downloadsDir, path.basename(new URL(spec.archiveUrl).pathname)), + extractionDir: path.join(downloadsDir, `${spec.extractedDir}.extracting`), + }; +} + +// The archive is removed only as its model moves into place, so a model whose archive is +// still in .downloads did not finish installing and may hold truncated files. +export async function isSherpaOnnxModelInstalled( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise<boolean> { + const spec = getSherpaOnnxModelSpec(modelId); + const { modelDir, archivePath } = getSherpaOnnxModelPaths(modelsDir, modelId); + return ( + !(await isNonEmptyFile(archivePath)) && (await hasRequiredFiles(modelDir, spec.requiredFiles)) + ); +} + async function isNonEmptyFile(filePath: string): Promise<boolean> { try { const s = await stat(filePath); @@ -110,18 +142,17 @@ export async function ensureSherpaOnnxModel( }); const spec = getSherpaOnnxModelSpec(options.modelId); - const modelDir = path.join(options.modelsDir, spec.extractedDir); - if (await hasRequiredFiles(modelDir, spec.requiredFiles)) { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths( + options.modelsDir, + options.modelId, + ); + if (await isSherpaOnnxModelInstalled(options.modelsDir, options.modelId)) { return modelDir; } logger.info({ modelsDir: options.modelsDir }, "Starting model download"); try { - const downloadsDir = path.join(options.modelsDir, ".downloads"); - const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname); - const archivePath = path.join(downloadsDir, archiveFilename); - if (!(await isNonEmptyFile(archivePath))) { await downloadToFile({ url: spec.archiveUrl, @@ -138,32 +169,40 @@ export async function ensureSherpaOnnxModel( }, "Extracting model archive", ); - await extractTarArchive(archivePath, options.modelsDir, options.signal); - - logger.info( - { - modelId: options.modelId, - modelDir, - }, - "Verifying downloaded model files", - ); - if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { - throw new Error( - `Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`, - ); - } - - logger.info( - { - modelId: options.modelId, - archivePath, - }, - "Finalizing model artifacts", - ); + // Extract beside the archive and move into place once complete, so an interrupted + // extraction never leaves partial files at modelDir. + await rm(extractionDir, { recursive: true, force: true }); try { - await rm(archivePath, { force: true }); - } catch { - // ignore + await extractTarArchive(archivePath, extractionDir, options.signal); + const extractedModelDir = path.join(extractionDir, spec.extractedDir); + + logger.info( + { + modelId: options.modelId, + modelDir, + }, + "Verifying downloaded model files", + ); + if (!(await hasRequiredFiles(extractedModelDir, spec.requiredFiles))) { + throw new Error( + `Downloaded and extracted ${path.basename(archivePath)}, but required files are missing.`, + ); + } + + logger.info( + { + modelId: options.modelId, + archivePath, + }, + "Finalizing model artifacts", + ); + // A retained archive marks the model as not installed, so remove it before the model + // is moved into place. If removal fails, nothing at modelDir looks installed. + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true, maxRetries: 3 }); + await rename(extractedModelDir, modelDir); + } finally { + await rm(extractionDir, { recursive: true, force: true }); } logger.info({ modelDir }, "Model download completed"); diff --git a/packages/server/src/server/speech/providers/openai/config.test.ts b/packages/server/src/server/speech/providers/openai/config.test.ts index 55da430edc..1f3433a347 100644 --- a/packages/server/src/server/speech/providers/openai/config.test.ts +++ b/packages/server/src/server/speech/providers/openai/config.test.ts @@ -203,4 +203,40 @@ describe("resolveOpenAiSpeechConfig", () => { expect(resolved?.stt?.apiKey).toBe("stt-only-key"); expect(resolved?.tts).toBeUndefined(); }); + + test("sends the model and voice an OpenAI-compatible server serves, as written", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { + openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } }, + }, + features: { + voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } }, + }, + }); + + const resolved = resolveOpenAiSpeechConfig({ + env: {} as NodeJS.ProcessEnv, + persisted, + providers: ALL_OPENAI, + }); + + expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0"); + expect(resolved?.tts?.voice).toBe("Aiden"); + }); + + test("accepts OpenAI's own gpt-4o-mini-tts model", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { openai: { tts: { apiKey: "tts-key" } } }, + features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } }, + }); + + const resolved = resolveOpenAiSpeechConfig({ + env: {} as NodeJS.ProcessEnv, + persisted, + providers: ALL_OPENAI, + }); + + expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts"); + expect(resolved?.tts?.voice).toBe("alloy"); + }); }); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index ac8d2ffa3a..efb25bffaf 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -12,10 +12,6 @@ export interface OpenAiSpeechProviderConfig { tts?: Partial<TTSConfig> & { apiKey?: string }; } -const OpenAiTtsVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]); - -const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]); - const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]); const OptionalFiniteNumberSchema = NumberLikeSchema.pipe( @@ -44,14 +40,12 @@ const OpenAiSttOptionsSchema = z.object({ sttModel: OptionalTrimmedStringSchema, }); +// Voice and model are free-form so a self-hosted server can use its own ids; the +// OpenAI catalog is only the default. Both are sent as written, because the +// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0). const OpenAiTtsOptionsSchema = z.object({ - ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"), - ttsModel: z - .string() - .trim() - .toLowerCase() - .pipe(OpenAiTtsModelSchema) - .default(DEFAULT_OPENAI_TTS_MODEL), + ttsVoice: z.string().trim().default("alloy"), + ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL), }); function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean { diff --git a/packages/server/src/server/speech/providers/openai/tts.ts b/packages/server/src/server/speech/providers/openai/tts.ts index 6cc7e5c5bb..31f7b5b263 100644 --- a/packages/server/src/server/speech/providers/openai/tts.ts +++ b/packages/server/src/server/speech/providers/openai/tts.ts @@ -8,8 +8,8 @@ export type { SpeechStreamResult }; export interface TTSConfig { apiKey: string; baseUrl?: string; - model?: "tts-1" | "tts-1-hd"; - voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer"; + model?: "tts-1" | "tts-1-hd" | (string & {}); + voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" | (string & {}); responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm"; } diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index 428ecd27aa..c9c21b3e89 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -36,8 +36,7 @@ vi.mock("./providers/openai/runtime.js", () => ({ vi.mock("./providers/local/models.js", () => ({ ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - getLocalSpeechModelDir: vi.fn(() => ""), - listLocalSpeechModels: vi.fn(() => []), + listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), })); function createStubStt(id: string): SpeechToTextProvider { diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 6e56a9e955..932f87c077 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -1,14 +1,8 @@ -import { stat } from "node:fs/promises"; -import { join } from "node:path"; import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, -} from "./providers/local/models.js"; +import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -76,48 +70,6 @@ function resolveRequestedSpeechProviders( }; } -async function hasRequiredLocalModelFile(filePath: string): Promise<boolean> { - try { - const fileStat = await stat(filePath); - if (fileStat.isDirectory()) { - return true; - } - return fileStat.isFile() && fileStat.size > 0; - } catch { - return false; - } -} - -async function findMissingRequiredLocalModels(params: { - modelsDir: string | null; - requiredModelIds: LocalSpeechModelId[]; -}): Promise<LocalSpeechModelId[]> { - const { modelsDir, requiredModelIds } = params; - if (!modelsDir || requiredModelIds.length === 0) { - return []; - } - - const specsById = new Map(listLocalSpeechModels().map((model) => [model.id, model])); - const missing = new Set<LocalSpeechModelId>(); - - const checks = await Promise.all( - requiredModelIds.map(async (modelId) => { - const spec = specsById.get(modelId); - if (!spec) return { modelId, missing: true }; - const modelDir = getLocalSpeechModelDir(modelsDir, modelId); - const filePresence = await Promise.all( - spec.requiredFiles.map((relPath) => hasRequiredLocalModelFile(join(modelDir, relPath))), - ); - return { modelId, missing: !filePresence.every((present) => present) }; - }), - ); - for (const check of checks) { - if (check.missing) missing.add(check.modelId); - } - - return Array.from(missing); -} - function joinModelIds(modelIds: LocalSpeechModelId[]): string { if (modelIds.length === 0) { return "none"; @@ -493,10 +445,12 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise<void> => { - missingLocalModelIds = await findMissingRequiredLocalModels({ - modelsDir: localModelConfig?.modelsDir ?? null, - requiredModelIds: localModelConfig?.defaultModelIds ?? [], - }); + missingLocalModelIds = localModelConfig + ? await listMissingLocalSpeechModels({ + modelsDir: localModelConfig.modelsDir, + modelIds: localModelConfig.defaultModelIds, + }) + : []; }; const reconcileServices = async (): Promise<void> => { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 2a437f9bbf..1c7127d144 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -58,14 +58,18 @@ interface FakeAgentSessionOptions { sessionId?: string; memoryMarker?: string | null; closeSession?: () => Promise<void>; - onStartTurn?: (prompt: AgentPromptInput) => void; + onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; + beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>; + historyEvents?: readonly AgentStreamEvent[]; } export interface TestAgentClientOptions { - beforeCreateSession?: () => Promise<void>; + beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>; closeSession?: () => Promise<void>; - onStartTurn?: (prompt: AgentPromptInput) => void; + onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; + beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>; supportsMcpServers?: boolean; + historyEvents?: readonly AgentStreamEvent[]; } function createDeferred<T>(): Deferred<T> { @@ -337,7 +341,9 @@ class FakeAgentSession implements AgentSession { private activeForegroundTurnId: string | null = null; private readonly closeSession: (() => Promise<void>) | undefined; - private readonly onStartTurn: ((prompt: AgentPromptInput) => void) | undefined; + private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; + private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined; + private readonly historyEvents: readonly AgentStreamEvent[] | undefined; constructor(options: FakeAgentSessionOptions) { this.capabilities = { @@ -350,6 +356,8 @@ class FakeAgentSession implements AgentSession { this.memoryMarker = options.memoryMarker ?? null; this.closeSession = options.closeSession; this.onStartTurn = options.onStartTurn; + this.beforeTurnComplete = options.beforeTurnComplete; + this.historyEvents = options.historyEvents; this.historyPath = path.join( tmpdir(), "paseo-fake-provider-history", @@ -440,7 +448,7 @@ class FakeAgentSession implements AgentSession { const turnId = `fake-turn-${this.nextTurnOrdinal++}`; this.activeForegroundTurnId = turnId; - this.onStartTurn?.(prompt); + this.onStartTurn?.(prompt, this.config); void this.emitTurnEvents(prompt); @@ -799,6 +807,7 @@ class FakeAgentSession implements AgentSession { this.notifySubscribers(assistantChunkB); } + await this.beforeTurnComplete?.(prompt); const completed: AgentStreamEvent = { type: "turn_completed", provider: this.providerName, @@ -812,6 +821,10 @@ class FakeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator<AgentStreamEvent> { + if (this.historyEvents) { + yield* this.historyEvents; + return; + } let contents: string; try { contents = await readFile(this.historyPath, "utf8"); @@ -1213,13 +1226,15 @@ class FakeAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise<AgentSession> { - await this.options.beforeCreateSession?.(); + await this.options.beforeCreateSession?.(config); return new FakeAgentSession({ providerName: this.provider, config: { ...config }, supportsMcpServers: this.options.supportsMcpServers, closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, + beforeTurnComplete: this.options.beforeTurnComplete, + historyEvents: this.options.historyEvents, }); } @@ -1245,6 +1260,8 @@ class FakeAgentClient implements AgentClient { memoryMarker: typeof marker === "string" ? marker : null, closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, + beforeTurnComplete: this.options.beforeTurnComplete, + historyEvents: this.options.historyEvents, }); } diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 1c98686042..c5cf271ad3 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -35,6 +35,7 @@ export function asAgentManager(stub: { }): SessionOptions["agentManager"] { return createStub<SessionOptions["agentManager"]>({ listProviderSubagentActivity: () => [], + getRetiredInternalAgent: () => null, ...stub, }); } @@ -239,6 +240,7 @@ export function createProviderSnapshotManagerStub(): { const off = vi.fn(); const destroy = vi.fn<ProviderSnapshotManager["destroy"]>(); const stub = { + supportsConfigRequiredProvider: () => false, getSnapshot, refreshSnapshotForCwd, refreshSettingsSnapshot, diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index e7747f57ae..53b8a44c1e 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -55,6 +55,7 @@ export function createNoopWorkspaceGitService( suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], + invalidateWorktrees: () => {}, getProjectSlug: async (cwd: string) => { const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts index cc6754c50b..fda98d6cbf 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -190,6 +190,113 @@ index 1234567..abcdefg 100644 `; describe("parseDiff", () => { + it.each(["-- comment", "++ marker"])( + "preserves hunk content resembling patch headers: %s", + (content) => { + const files = parseDiff(`diff --git a/example.txt b/example.txt +index 1234567..abcdefg 100644 +--- a/example.txt ++++ b/example.txt +@@ -1 +1 @@ +-${content} before ++${content} after +`); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + path: "example.txt", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + }); + expect(files[0].hunks[0].lines).toEqual([ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: `${content} before` }, + { type: "add", content: `${content} after` }, + ]); + }, + ); + + it("decodes quoted Git patch paths before matching files", () => { + const files = parseDiff( + [ + 'diff --git "a/report \\"draft\\".txt" "b/report \\"draft\\".txt"', + "index 1234567..abcdefg 100644", + '--- "a/report \\"draft\\".txt"', + '+++ "b/report \\"draft\\".txt"', + "@@ -1 +1 @@", + "-before", + "+after", + "", + ].join("\n"), + ); + + expect(files).toEqual([ + { + path: 'report "draft".txt', + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], + }, + ]); + }); + + it.each([ + ["tab\\tname.txt", "tab\tname.txt"], + ["line\\nname.txt", "line\nname.txt"], + ["back\\\\slash.txt", "back\\slash.txt"], + ["bell\\aname.txt", "bell\u0007name.txt"], + ["vertical\\vname.txt", "vertical\u000bname.txt"], + ["\\346\\265\\213\\350\\257\\225.txt", "测试.txt"], + ['测试 \\"draft\\".txt', '测试 "draft".txt'], + ])("decodes C-quoted path %s without changing its diff lines", (encoded, path) => { + const files = parseDiff( + [ + 'diff --git "a/' + encoded + '" "b/' + encoded + '"', + "index 1234567..abcdefg 100644", + '--- "a/' + encoded + '"', + '+++ "b/' + encoded + '"', + "@@ -1 +1 @@", + "-before", + "+after", + "", + ].join("\n"), + ); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path, additions: 1, deletions: 1 }); + expect(files[0].hunks[0].lines).toEqual([ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ]); + }); + + it.each([ + ['"a/old \\"draft\\".txt" b/new with spaces.txt', "new with spaces.txt"], + ['a/old with spaces.txt "b/new \\"draft\\".txt"', 'new "draft".txt'], + ['"report \\"draft\\".txt" "report \\"draft\\".txt"', 'report "draft".txt'], + ])("reads quoted paths from header-only patches: %s", (header, path) => { + expect(parseDiff("diff --git " + header + "\nsimilarity index 100%\n")).toEqual([ + { path, isNew: false, isDeleted: false, additions: 0, deletions: 0, hunks: [] }, + ]); + }); + it("parses a simple diff with one hunk", () => { const files = parseDiff(SIMPLE_DIFF); @@ -597,6 +704,28 @@ describe("highlightDiffFromHunks", () => { }); describe("highlightDiffWithFileContent", () => { + it("renders an Astro diff with an empty expression and keeps surrounding highlighting", async () => { + const diff = ASTRO_DIFF.replace("{title}", "{title} {}"); + const file = parseDiff(diff)[0]; + const highlighted = await highlightDiffWithFileContent(file, ".", { + oldFileContent: + '---\nconst title = "Hello";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>', + newFileContent: + '---\nconst title = "Hello, Astro";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>', + }); + + expect(highlighted.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({ + text: '"Hello, Astro"', + style: "string", + }); + expect(highlighted.hunks[0].lines.map(({ content }) => content)).toEqual( + file.hunks[0].lines.map(({ content }) => content), + ); + expect( + highlightDiffFromHunks(file).hunks[0].lines.find((line) => line.type === "add")?.tokens, + ).toContainEqual({ text: '"Hello, Astro"', style: "string" }); + }); + it("leaves files with an oversized line unhighlighted", async () => { const oversizedLine = `const payload = "${"x".repeat(10_001)}";`; const [file] = parseDiff( diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index daa1ebbb3d..3c2df279e2 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight"; +import { unquoteGitPath } from "../../utils/git-quoted-path.js"; const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000; @@ -53,7 +54,7 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri return null; } - const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd(); + const path = unquoteGitPath(line.slice(prefix.length).replace(/\t.*$/, "")); return path === "/dev/null" ? null : path; } @@ -64,6 +65,17 @@ function extractPathFromDiffHeader(lines: string[]): string { return prefixedPathMatch[2]; } + const quotedPathMatch = + firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/); + if (quotedPathMatch) { + const oldPath = unquoteGitPath(quotedPathMatch[1]); + const newPath = unquoteGitPath(quotedPathMatch[2]); + if (usesDiffPathPrefixes(oldPath, newPath)) { + return newPath.slice(2); + } + return newPath; + } + const metadataPath = extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- "); if (metadataPath) { @@ -79,16 +91,6 @@ function extractPathFromDiffHeader(lines: string[]): string { return "unknown"; } -function isMetadataLine(line: string): boolean { - return ( - line.startsWith("index ") || - line.startsWith("--- ") || - line.startsWith("+++ ") || - line.startsWith("new file mode") || - line.startsWith("deleted file mode") - ); -} - function parseHunkHeader(line: string): DiffHunk | null { const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); if (!hunkMatch) return null; @@ -116,8 +118,6 @@ function parseSectionBody(lines: string[]): ParsedSectionBody { for (let i = 1; i < lines.length; i++) { const line = lines[i]; - if (isMetadataLine(line)) continue; - const newHunk = parseHunkHeader(line); if (newHunk) { if (currentHunk) hunks.push(currentHunk); diff --git a/packages/server/src/server/web-ui.ts b/packages/server/src/server/web-ui.ts index e7135a9eab..b59896c159 100644 --- a/packages/server/src/server/web-ui.ts +++ b/packages/server/src/server/web-ui.ts @@ -250,6 +250,22 @@ function serializeInlineScriptJson(value: unknown): string { .replace(/&/g, "\\u0026"); } +/** + * A Host header carries no port when the client used the scheme's default one, + * which is what every browser sends to a proxy terminating TLS on 443. The app + * parses `listen` with parseHostPort, which requires an explicit port and + * rejects the value outright, so the hint has to spell the port out. + */ +function hasExplicitPort(host: string): boolean { + if (host.startsWith("[")) { + // IPv6 literals are bracketed: [::1] has no port, [::1]:443 does. + const end = host.indexOf("]"); + return end !== -1 && /^:\d{1,5}$/.test(host.slice(end + 1)); + } + const colon = host.indexOf(":"); + return colon !== -1 && colon === host.lastIndexOf(":") && /^\d{1,5}$/.test(host.slice(colon + 1)); +} + function injectConnectionHint( html: string, req: Parameters<RequestHandler>[0], @@ -257,8 +273,16 @@ function injectConnectionHint( ): string { const host = typeof req.headers.host === "string" ? req.headers.host : ""; const useTls = req.protocol === "https"; + let listen = host; + if (host && !hasExplicitPort(host)) { + let defaultPort = 80; + if (useTls) { + defaultPort = 443; + } + listen = `${host}:${defaultPort}`; + } const hint = { - listen: host, + listen, useTls, label, }; diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 25acb9946b..10694a6ebe 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1141,6 +1141,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.stableProjectIdentity).toBe(true); expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); expect(serverInfo.features?.providersSnapshotCwd).toBe(true); + expect(serverInfo.features?.hopliteProvider).toBe(false); expect(serverInfo.features?.pluginLogs).toBe(true); expect(serverInfo.features?.workspaceMarkUnread).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4dcdc680ab..e833a07ce3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -33,6 +33,7 @@ import { } from "./messages.js"; import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; +import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import type { HostnamesConfig } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js"; import { @@ -246,6 +247,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], + invalidateWorktrees: () => {}, getProjectSlug: async (cwd: string) => { const snapshot = createFallbackWorkspaceGitSnapshot(cwd); return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); @@ -821,6 +823,7 @@ export class VoiceAssistantWebSocketServer { const wss = new WebSocketServer({ server, path: "/ws", + maxPayload: MAX_WEBSOCKET_MESSAGE_BYTES, handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password), verifyClient: ({ req }, callback) => { this.verifyWsUpgrade( @@ -1793,6 +1796,7 @@ export class VoiceAssistantWebSocketServer { workspaceRequestReceipts: true, creationLifecycle: true, hubAgentRpc: true, + backgroundWorkspaces: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. @@ -1911,6 +1915,7 @@ export class VoiceAssistantWebSocketServer { commitBaseClassification: true, // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: true, + hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: true, // COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02. @@ -1923,6 +1928,7 @@ export class VoiceAssistantWebSocketServer { explicitEventSubscriptions: true, // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: true, + agentMessageProvenance: true, // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: true, // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index cc05d496ba..87cb758ac5 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -1,10 +1,13 @@ -import { execFileSync, spawn } from "node:child_process"; +import { execFile, execFileSync, spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { afterEach, beforeEach, expect, test } from "vitest"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { createWorktree } from "../utils/worktree.js"; import { createDaemonTestContext, DaemonClient, @@ -54,6 +57,32 @@ function createGitRepo(): string { return repoDir; } +async function runCli(args: string[], cwd: string): Promise<string> { + const result = await promisify(execFile)( + process.execPath, + [ + "--conditions=source", + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${ctx.daemon.port}`, + ...args, + ], + { + cwd, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + PASEO_HOME: ctx.daemon.paseoHome, + }, + timeout: 20000, + }, + ); + return result.stdout; +} + async function createLocalWorkspace(cwd: string, title: string): Promise<string> { const result = await ctx.client.createWorkspace({ source: { kind: "directory", path: cwd }, @@ -248,6 +277,78 @@ test("renaming a workspace updates every subscribed client", async () => { } }); +test.each(["worktree", "workspace"] as const)( + "%s archive immediately removes the deleted worktree from a warm CLI listing", + async (command) => { + const repoDir = createGitRepo(); + const created = await ctx.client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + worktreeSlug: "cached-worktree", + baseBranch: "main", + }, + }); + const workspace = created.workspace; + if (!workspace?.workspaceDirectory) { + throw new Error(created.error ?? "Failed to create worktree workspace"); + } + const worktreePath = workspace.workspaceDirectory; + const name = path.basename(worktreePath); + const before = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir)); + expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]); + + await runCli( + [command, "archive", command === "worktree" ? name : workspace.id, "--json"], + repoDir, + ); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir }).toString(), + ).not.toContain(worktreePath); + const after = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir)); + console.log( + `${command} archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`, + ); + expect(after).toEqual([]); + }, + 60000, +); + +test("archiving an unregistered worktree in a bare repo clears the main checkout listing", async () => { + const sourceRepo = createGitRepo(); + const repoRoot = makeTempDir("workspace-archive-bare-"); + const bareRepo = path.join(repoRoot, "repo.git"); + const mainCheckout = path.join(repoRoot, "main"); + execFileSync("git", ["clone", "--bare", sourceRepo, bareRepo], { stdio: "pipe" }); + execFileSync("git", ["worktree", "add", mainCheckout, "main"], { + cwd: bareRepo, + stdio: "pipe", + }); + const { worktreePath } = await createWorktree({ + cwd: mainCheckout, + source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" }, + worktreeSlug: "cached-bare-worktree", + runSetup: false, + paseoHome: ctx.daemon.paseoHome, + }); + expect(await activeWorkspaceIds()).toEqual(new Set()); + const name = path.basename(worktreePath); + const before = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout)); + expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]); + + await runCli(["worktree", "archive", name, "--json"], mainCheckout); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainCheckout }).toString(), + ).not.toContain(worktreePath); + const after = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout)); + console.log( + `bare repo archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`, + ); + expect(after).toEqual([]); +}, 60000); + test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => { const repoDir = createGitRepo(); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index c3b77f7070..d1cfb8ada7 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -140,7 +140,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies { github: createGitHubServiceStub(), workspaceGitService: { getSnapshot: vi.fn(async () => null), - } as unknown as Pick<WorkspaceGitService, "getSnapshot">, + invalidateWorktrees: () => {}, + } as unknown as Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">, agentManager: { listAgents: () => [], getAgent: () => null, @@ -617,28 +618,37 @@ describe("archiveByScope", () => { expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled(); }); - test("worktree scope removes an owned directory with zero matching records", async () => { - const { tempDir, repoDir } = createGitRepo(); - const paseoHome = path.join(tempDir, ".paseo"); - const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records"); + test.each(["intact", "missing"] as const)( + "worktree scope removes an owned directory with zero matching records and %s Git administration", + async (gitAdministration) => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records"); + if (gitAdministration === "missing") { + const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], { + cwd: worktree.worktreePath, + }) + .toString() + .trim(); + rmSync(gitDir, { recursive: true, force: true }); + } + const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] }); + const invalidateWorktrees = vi.fn(); + dependencies.workspaceGitService.invalidateWorktrees = invalidateWorktrees; - const result = await archiveByScope( - createArchiveDeps({ - paseoHome, - activeWorkspaces: [], - }), - { + const result = await archiveByScope(dependencies, { scope: { kind: "worktree", targetPath: worktree.worktreePath }, requestId: "req-zero-records", - }, - ); + }); - assertArchiveResult(result, { - archivedWorkspaceIds: [], - removedDirectory: true, - }); - expect(existsSync(worktree.worktreePath)).toBe(false); - }); + assertArchiveResult(result, { + archivedWorkspaceIds: [], + removedDirectory: true, + }); + expect(existsSync(worktree.worktreePath)).toBe(false); + expect(invalidateWorktrees).toHaveBeenCalledTimes(1); + }, + ); test("marks archiving, emits an upsert carrying the archiving state, then clears it and emits a remove", async () => { const { tempDir, repoDir } = createGitRepo(); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index a02c97eda8..e9e58681a1 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -32,7 +32,7 @@ export interface ArchiveDependencies { // Base directory that may hold worktrees across repositories. paseoWorktreesBaseRoot?: string; github: ForgeService; - workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">; + workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">; agentManager: Pick<AgentManager, "listAgents" | "getAgent" | "archiveAgent" | "archiveSnapshot">; agentStorage: Pick<AgentStorage, "listByWorkspace">; // Resolves the worktree at a path to its workspaceId for archive-by-path. The @@ -408,6 +408,7 @@ async function maybeRemoveDirectory( paseoHome: dependencies.paseoHome, worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, }); + dependencies.workspaceGitService.invalidateWorktrees(); dependencies.github.invalidate({ cwd: backing.path }); return true; } catch (error) { @@ -466,7 +467,7 @@ export async function archiveWorkspaceContents( const archivedAgents = new Set<string>(); const liveAgents = dependencies.agentManager - .listAgents() + .listAgents({ includeInternal: true }) .filter((agent) => agent.workspaceId === workspaceId); for (const agent of liveAgents) { archivedAgents.add(agent.id); diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 2e1f2c4e49..69c1da2efc 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, onTestFinished, test } from "vitest"; @@ -18,6 +19,7 @@ function createGitRepoWithBranch(): { repoDir: string; tempRoot: string } { const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-create-worktree-source-")); const repoDir = path.join(tempRoot, "repo"); execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "core.autocrlf", "false"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repoDir, stdio: "pipe", @@ -60,10 +62,176 @@ test("workspace.create worktree source forwards action=checkout + refName into t } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); +test.each([false, true])( + "workspace.create opens worktree.terminals (setup service: %s)", + async (withSetupService) => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + writeFileSync( + path.join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + ...(withSetupService + ? { + // Launch the Windows service asynchronously so it outlives + // the setup command instead of holding setup open. + setup: + process.platform === "win32" + ? "Start-Process -FilePath node -ArgumentList 'setup.cjs','serve' -WorkingDirectory (Get-Location).Path -WindowStyle Hidden; while (!(Test-Path setup-pid.txt)) { Start-Sleep -Milliseconds 25 }" + : "node setup.cjs", + } + : {}), + terminals: [ + { name: "preview", command: "echo paseo-auto-terminal >> terminal-marker.txt" }, + ], + }, + }), + ); + writeFileSync( + path.join(repoDir, "setup.cjs"), + ` +const fs = require("node:fs"); +if (process.argv[2] === "serve") { + require("node:net").createServer().listen(Number(process.env.PASEO_WORKTREE_PORT), "127.0.0.1", () => { + fs.writeFileSync("setup-pid.txt", String(process.pid)); + process.send?.("ready"); + }); +} else { + const child = require("node:child_process").spawn(process.execPath, [__filename, "serve"], { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + child.once("message", () => { child.disconnect(); child.unref(); }); +} +`, + ); + execFileSync("git", ["add", "paseo.json", "setup.cjs"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { + cwd: repoDir, + stdio: "pipe", + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + let workspaceCwd: string | undefined; + try { + await client.connect(); + + // This is the request the New Workspace screen sends: the workspace is + // created on its own, with no agent in the same request. + const result = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + branchName: "feature/auto-terminals", + worktreeSlug: "feature-auto-terminals", + baseBranch: "main", + }, + }); + + expect(result.error).toBeNull(); + const workspace = result.workspace; + if (!workspace) { + throw new Error("Expected a created workspace"); + } + + workspaceCwd = workspace.workspaceDirectory; + if (withSetupService) { + await expect + .poll(() => existsSync(path.join(workspaceCwd!, "setup-pid.txt")), { timeout: 30000 }) + .toBe(true); + } + await expect + .poll(async () => (await client.fetchWorkspaceSetupStatus(workspace.id)).snapshot, { + timeout: 30000, + }) + .toMatchObject({ status: "completed", error: null }); + await expect + .poll( + async () => + ( + await client.listTerminals(workspace.workspaceDirectory, undefined, { + workspaceId: workspace.id, + }) + ).terminals.map((terminal) => terminal.name), + { timeout: 30000 }, + ) + .toContain("preview"); + await expect + .poll( + () => { + const marker = path.join(workspaceCwd!, "terminal-marker.txt"); + return existsSync(marker) ? readFileSync(marker, "utf8").trim() : null; + }, + { timeout: 10000 }, + ) + .toBe("paseo-auto-terminal"); + expect( + (await client.listTerminals(workspaceCwd, undefined, { workspaceId: workspace.id })) + .terminals, + ).toHaveLength(1); + } finally { + const pidFile = workspaceCwd && path.join(workspaceCwd, "setup-pid.txt"); + if (pidFile && existsSync(pidFile)) { + process.kill(Number(readFileSync(pidFile, "utf8"))); + } + await client.close().catch(() => undefined); + await daemon.close(); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }, + 180000, +); + +test("existing branch changes remain visible after archive and restore", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.2", + }); + onTestFinished(async () => { + await client.close().catch(() => undefined); + await daemon.close(); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + }); + runGit(repoDir, "switch", "feature/existing-branch"); + commitFile(repoDir, "feature.txt", "feature change\n"); + runGit(repoDir, "switch", "main"); + await client.connect(); + const created = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "checkout", + refName: "feature/existing-branch", + }, + }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + const cwd = workspace.workspaceDirectory; + const head = runGit(cwd, "rev-parse", "HEAD"); + const before = await client.getCheckoutDiff(cwd, { mode: "base" }); + expect(before.error).toBeNull(); + expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]); + expect((await client.getCheckoutStatus(cwd)).baseRef).toBe("main"); + expect((await client.archiveWorkspace(workspace.id)).error).toBeNull(); + expect(existsSync(cwd)).toBe(false); + await client.restoreWorkspace(workspace.id); + expect(runGit(cwd, "rev-parse", "HEAD")).toBe(head); + expect((await client.getCheckoutStatus(cwd)).baseRef).toBe("main"); + const after = await client.getCheckoutDiff(cwd, { mode: "base" }); + expect(after.error).toBeNull(); + expect(after.files.map((file) => file.path)).toEqual(["feature.txt"]); +}, 180000); + test("workspace.create keeps a branch-off name separate from its worktree slug", async () => { const daemon = await createTestPaseoDaemon(); const { repoDir, tempRoot } = createGitRepoWithBranch(); @@ -92,7 +260,7 @@ test("workspace.create keeps a branch-off name separate from its worktree slug", } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -126,7 +294,7 @@ test("workspace.create accepts a Git-valid branch-off name outside Paseo slug sy } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -168,7 +336,7 @@ test("workspace.create always creates a new worktree when the slug is already oc } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -213,7 +381,7 @@ test("workspace.create suffixes past an occupied detached worktree", async () => } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -255,7 +423,7 @@ test("workspace.create suffixes an occupied checkout branch", async () => { } finally { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); @@ -298,7 +466,7 @@ async function createRestorableWorkspace(baseRef: string) { onTestFinished(async () => { await client.close().catch(() => undefined); await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); createDivergentBases(repoDir); await client.connect(); diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 50c226cfb4..e317b5427d 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -82,6 +82,7 @@ class WorkspaceStatus { workspaceDirectory: workspace.cwd, projectKind: "git", workspaceKind: workspace.kind, + background: workspace.background, name: workspace.displayName, archivingAt: null, status: "done", @@ -571,7 +572,9 @@ describe("WorkspaceDirectory empty projects", () => { workspaceDirectory: workspace.cwd, projectKind: "non_git", workspaceKind: workspace.kind, + background: workspace.background, name: workspace.displayName, + ...(workspace.internal ? { background: true } : {}), archivingAt: null, status: "done", activityAt: null, @@ -644,6 +647,41 @@ describe("WorkspaceDirectory empty projects", () => { expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]); }); + + test("hides background workspaces and counts their project as empty unless includeBackground", async () => { + const directory = makeDirectory({ + projects: [project({ projectId: "hidden-only" })], + workspaces: [ + { + workspaceId: "ws-internal", + projectId: "hidden-only", + cwd: "/workspace/hidden-only", + kind: "directory", + displayName: "helper", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + background: true, + }, + ], + }); + + const hidden = await directory.listFetchEntries({ + type: "fetch_workspaces_request", + requestId: "r1", + filter: { query: "helper" }, + }); + expect(hidden.entries).toEqual([]); + expect(hidden.emptyProjects.map((p) => p.projectId)).toEqual(["hidden-only"]); + + const shown = await directory.listFetchEntries({ + type: "fetch_workspaces_request", + requestId: "r2", + filter: { includeBackground: true }, + }); + expect(shown.entries.map((entry) => entry.id)).toEqual(["ws-internal"]); + expect(shown.emptyProjects).toEqual([]); + }); }); test("Git observation targets exclude archived records without hydrating app descriptors", async () => { diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 13b9996489..8ccb4fa341 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -170,7 +170,7 @@ export class WorkspaceDirectory { /** * Per-workspace last-seen winning bucket + entered-at. Persists across * `buildDescriptorMap` calls inside the daemon process; reset on cold start. - * Server-internal; never crosses the wire. + * Server-private; never crosses the wire. */ private readonly bucketHistoryByWorkspaceId = new Map<string, WorkspaceBucketHistoryEntry>(); @@ -557,14 +557,20 @@ export class WorkspaceDirectory { // Project parents that have no active workspaces. The wire field is the // sidebar projection bucket for projects whose workspace list is currently // empty; it is not a separate domain record. - async listEmptyProjects(): Promise<WorkspaceProjectDescriptor[]> { + async listEmptyProjects(options?: { + includeBackground?: boolean; + }): Promise<WorkspaceProjectDescriptor[]> { const [persistedWorkspaces, persistedProjects] = await Promise.all([ this.deps.workspaceRegistry.list(), this.deps.projectRegistry.list(), ]); + // A project whose only workspaces are hidden reads as empty to that caller. const projectIdsWithActiveWorkspaces = new Set( persistedWorkspaces - .filter((workspace) => !workspace.archivedAt) + .filter( + (workspace) => + !workspace.archivedAt && (options?.includeBackground === true || !workspace.background), + ) .map((workspace) => workspace.projectId), ); return persistedProjects @@ -596,6 +602,8 @@ export class WorkspaceDirectory { })); } + // Every active workspace, background ones included. Callers that answer a + // listing go through matchesFilter so background workspaces stay opt-in. async listDescriptors(): Promise<WorkspaceDescriptorPayload[]> { return Array.from( ( @@ -611,6 +619,13 @@ export class WorkspaceDirectory { filter: FetchWorkspacesRequestFilter | undefined; }): boolean { const { workspace, filter } = input; + if ( + workspace.background && + filter?.includeBackground !== true && + filter?.query !== workspace.id + ) { + return false; + } if (!filter) { return true; } @@ -666,7 +681,7 @@ export class WorkspaceDirectory { const projectIdFilter = filter?.projectId?.trim(); const emptyProjects = cursorToken ? [] - : (await this.listEmptyProjects()).filter( + : (await this.listEmptyProjects({ includeBackground: filter?.includeBackground })).filter( (project) => !projectIdFilter || project.projectId === projectIdFilter, ); diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index a98dcda0fd..8ac1ef0018 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -63,6 +63,27 @@ afterEach(async () => { } }); +test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-"))); + const repo = path.join(root, "repo"); + mkdirSync(repo); + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(root, "home"), + }); + cleanup.push(async () => { + await service.dispose(); + rmSync(root, { recursive: true, force: true }); + }); + + expect((await service.getSnapshot(repo, { includeForge: false })).git.isGit).toBe(false); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" }); + + expect((await service.getCheckout(repo)).isGit).toBe(true); + const repoRoot = await service.resolveRepoRoot(repo); + expect(realpathSync.native(repoRoot)).toBe(realpathSync.native(repo)); +}, 30_000); + test("recursive observation updates tracked state and prunes ignored storms", async () => { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-"))); const repoDir = path.join(tempDir, "repo"); diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 65eb4a0ad5..ce052cdd7c 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -1819,6 +1819,46 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { service.dispose(); }); + test("invalidating worktrees discards warm listings across checkout roots and in-flight reads", async () => { + const worktrees = [ + { + path: "/tmp/paseo-home/worktrees/repo/feature", + createdAt: "2026-04-12T00:00:00.000Z", + branchName: "feature", + }, + ]; + const readStarted = createDeferred<void>(); + const oldRead = createDeferred<typeof worktrees>(); + const alternateCheckout = resolvePath("/tmp/alternate-checkout"); + let reads = 0; + const service = createService({ + listPaseoWorktrees: async () => { + reads += 1; + if (reads <= 2) return worktrees; + if (reads === 3) { + readStarted.resolve(); + return oldRead.promise; + } + return []; + }, + }); + try { + await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual(worktrees); + await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual(worktrees); + const pending = service.listWorktrees(REPO_CWD, { force: true, reason: "test" }); + await readStarted.promise; + service.invalidateWorktrees(); + await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]); + await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual([]); + oldRead.resolve(worktrees); + await expect(pending).resolves.toEqual(worktrees); + await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]); + } finally { + oldRead.resolve(worktrees); + await service.dispose(); + } + }); + test("listWorktrees shares one repo-root scoped read across sibling workspace cwds", async () => { const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-"))); const repoDir = join(tempDir, "repo"); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index e62720e1c1..a2b89bed39 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -241,6 +241,7 @@ export interface WorkspaceGitService { cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, ): Promise<WorkspaceGitWorktreeInfo[]>; + invalidateWorktrees(): void; getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>; resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>; resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>; @@ -764,6 +765,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { worktreesRoot: this.worktreesRoot, logger: this.logger, }); + const cached = this.peekSnapshot(normalizedCwd); + if (cached && cached.git.isGit !== status.isGit) { + // A fresh checkout read discovered a repository transition. Keep the + // shared snapshot current before exposing the new project capability. + await this.getSnapshot(normalizedCwd, { + force: true, + includeForge: false, + reason: "checkout-classification-changed", + }); + } if (!status.isGit) { return checkoutLiteFromGitSnapshot(normalizedCwd, { isGit: false, @@ -918,6 +929,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { : (snapshot.git.repoRoot ?? resolve(cwd)); } + invalidateWorktrees(): void { + this.assertNotDisposed(); + // Checkout paths can cache the same list under different roots, including bare repos. + // Detach in-flight reads too, so a pre-mutation result cannot refill the cache. + this.worktreeListCache.clear(); + } + async resolveDefaultBranch( cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 1bceb38715..52c7d41065 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -748,6 +748,7 @@ describe("WorkspaceReconciliationService", () => { cwd: missingWorkspace, kind: "directory", displayName: "orphan", + background: false, title: null, pinnedAt: null, branch: null, diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 80c328ce7b..04ffc1d7ac 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -28,6 +28,21 @@ describe("resolveWorkspaceName", () => { ); }); + test("createPersistedWorkspaceRecord defaults background to false and preserves explicit intent", () => { + const base = { + workspaceId: "ws-1", + projectId: "proj-1", + cwd: "/tmp/repo", + kind: "local_checkout" as const, + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }; + expect(createPersistedWorkspaceRecord(base).background).toBe(false); + expect(createPersistedWorkspaceRecord({ ...base, background: false }).background).toBe(false); + expect(createPersistedWorkspaceRecord({ ...base, background: true }).background).toBe(true); + }); + test("resolveWorkspaceDisplayName applies the same rule over the persisted record", () => { const record = createPersistedWorkspaceRecord({ workspaceId: "ws-1", diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 64f6f1e4a1..417af353fe 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -102,6 +102,8 @@ const PersistedWorkspaceRecordSchema = z.object({ .transform((value) => value ?? null), labels: z.array(z.string()).optional(), untrustedSource: UntrustedWorkspaceSourceSchema.optional(), + // Public discovery visibility. Contents retain their ordinary lifecycle. + background: z.boolean().optional().default(false), }); export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>; @@ -684,9 +686,12 @@ export function createPersistedWorkspaceRecord(input: { pinnedAt?: string | null; labels?: string[]; untrustedSource?: UntrustedWorkspaceSource; + background?: boolean; }): PersistedWorkspaceRecord { + const { background, ...rest } = input; return PersistedWorkspaceRecordSchema.parse({ - ...input, + ...rest, + background: background ?? false, title: input.title ?? null, branch: input.branch ?? null, worktreeRoot: input.worktreeRoot ?? null, diff --git a/packages/server/src/server/workspace-service-port-registry.test.ts b/packages/server/src/server/workspace-service-port-registry.test.ts index 3bf455ba57..b61bbb0f1d 100644 --- a/packages/server/src/server/workspace-service-port-registry.test.ts +++ b/packages/server/src/server/workspace-service-port-registry.test.ts @@ -33,7 +33,7 @@ describe("ensureWorkspaceServicePortPlan", () => { expect(allocatedServices).toEqual(["api", "web", "worker"]); }); - it("returns the existing plan without calling the allocator on later calls", async () => { + it("extends the existing plan with newly declared services without reallocating peers", async () => { let allocationCount = 0; const firstPlan = await ensureWorkspaceServicePortPlan({ @@ -54,9 +54,11 @@ describe("ensureWorkspaceServicePortPlan", () => { }, }); - expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries())); - expect(secondPlan.has("new-service")).toBe(false); - expect(allocationCount).toBe(2); + expect(Array.from(secondPlan.entries())).toEqual([ + ...firstPlan.entries(), + ["new-service", 4300], + ]); + expect(allocationCount).toBe(3); }); it("shares one first-plan build across concurrent callers", async () => { @@ -101,6 +103,103 @@ describe("ensureWorkspaceServicePortPlan", () => { expect(allocationCount).toBe(2); }); + it("extends a pending plan for a concurrent caller with another service", async () => { + const allocation = createDeferredPort(); + const workspaceId = "registry-concurrent-extension-workspace"; + const firstPlan = ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => allocation.promise, + }); + let allocations = 0; + const secondPlan = ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web" }], + allocatePort: async () => { + allocations += 1; + return 6101; + }, + }); + allocation.resolve(6100); + expect(Array.from((await firstPlan).entries())).toEqual([["backend-dev", 6100]]); + expect(Array.from((await secondPlan).entries())).toEqual([ + ["backend-dev", 6100], + ["web", 6101], + ]); + expect(allocations).toBe(1); + releaseWorkspaceServicePortPlan(workspaceId); + }); + + it("keeps the previous plan and reservations when an extension fails", async () => { + const workspaceId = "registry-failed-extension-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => 6200, + }); + let allocations = 0; + await expect( + ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web" }, { scriptName: "worker" }], + allocatePort: async () => { + allocations += 1; + if (allocations === 1) return 6201; + throw new Error("Allocation failed"); + }, + }), + ).rejects.toThrow("Allocation failed"); + const priorPlan = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => { + throw new Error("Existing port must survive"); + }, + }); + expect(Array.from(priorPlan.entries())).toEqual([["backend-dev", 6200]]); + const otherWorkspace = "registry-failed-extension-other-workspace"; + const reusedPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: otherWorkspace, + services: [{ scriptName: "web" }], + allocatePort: async ({ reservedPorts }) => { + expect(reservedPorts.has(6200)).toBe(true); + expect(reservedPorts.has(6201)).toBe(false); + return 6201; + }, + }); + expect(reusedPlan.get("web")).toBe(6201); + releaseWorkspaceServicePortPlan(workspaceId); + releaseWorkspaceServicePortPlan(otherWorkspace); + }); + + it("rejects a new explicit port that collides with an existing service", async () => { + const workspaceId = "registry-extension-explicit-collision-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => 6300, + }); + await expect( + ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6300 }], + allocatePort: async () => 6301, + }), + ).rejects.toThrow("Service 'web' has a duplicate port 6300"); + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6301 }], + allocatePort: async () => { + throw new Error("No allocation needed"); + }, + }); + expect(Array.from(plan.entries())).toEqual([ + ["backend-dev", 6300], + ["web", 6301], + ]); + releaseWorkspaceServicePortPlan(workspaceId); + }); + it("uses explicit configured ports without calling the allocator", async () => { let allocationCount = 0; @@ -312,6 +411,123 @@ describe("refreshWorkspaceServicePort", () => { ]); }); + it("preserves a concurrent service addition while refreshing a stopped service", async () => { + const workspaceId = "registry-refresh-and-extend-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => 6400, + }); + const allocation = createDeferredPort(); + const refreshing = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "backend-dev" }, + allocatePort: async () => allocation.promise, + }); + const extending = ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6402 }], + allocatePort: async () => { + throw new Error("Explicit addition needs no allocation"); + }, + }); + allocation.resolve(6401); + expect(await refreshing).toBe(6401); + await extending; + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [], + allocatePort: async () => { + throw new Error("No allocation needed"); + }, + }); + expect(Array.from(plan.entries())).toEqual([ + ["backend-dev", 6401], + ["web", 6402], + ]); + releaseWorkspaceServicePortPlan(workspaceId); + }); + + it.each(["reused port", "allocation failure"])( + "cancels a pending refresh and its waiter after workspace release (%s)", + async (outcome) => { + const workspaceId = "registry-cancelled-refresh-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "api" }], + allocatePort: async () => 6500, + }); + const allocation = createDeferredPort(); + const refreshing = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "api" }, + allocatePort: async () => { + const port = await allocation.promise; + if (outcome === "allocation failure") throw new Error("Allocation failed"); + return port; + }, + }); + const waiting = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "web", port: 6501 }, + allocatePort: async () => 6501, + }); + const assertions = Promise.all([ + expect(refreshing).rejects.toThrow("Workspace service port plan was released"), + expect(waiting).rejects.toThrow("Workspace service port plan was released"), + ]); + releaseWorkspaceServicePortPlan(workspaceId); + allocation.resolve(6500); + await assertions; + const reusedPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-after-cancelled-refresh-workspace", + services: [{ scriptName: "api" }], + allocatePort: async () => 6500, + }); + expect(reusedPlan.get("api")).toBe(6500); + releaseWorkspaceServicePortPlan("registry-after-cancelled-refresh-workspace"); + }, + ); + + it("lets a waiting service restart allocate after another service's restart fails", async () => { + const workspaceId = "registry-independent-refresh-failure-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "api" }, { scriptName: "web" }], + allocatePort: createSequentialPortAllocator(6600), + }); + const allocation = createDeferredPort(); + const failedRefresh = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "api" }, + allocatePort: async () => { + await allocation.promise; + throw new Error("API port script failed"); + }, + }); + const failedAssertion = expect(failedRefresh).rejects.toThrow("API port script failed"); + const waitingRefresh = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "web" }, + allocatePort: async () => 6602, + }); + allocation.resolve(0); + await failedAssertion; + expect(await waitingRefresh).toBe(6602); + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [], + allocatePort: async () => { + throw new Error("No allocation needed"); + }, + }); + expect(Array.from(plan.entries())).toEqual([ + ["api", 6600], + ["web", 6602], + ]); + releaseWorkspaceServicePortPlan(workspaceId); + }); + it("uses an explicit configured port without calling the allocator", async () => { let allocationCount = 0; diff --git a/packages/server/src/server/workspace-service-port-registry.ts b/packages/server/src/server/workspace-service-port-registry.ts index be41a8d123..ea6cebfa35 100644 --- a/packages/server/src/server/workspace-service-port-registry.ts +++ b/packages/server/src/server/workspace-service-port-registry.ts @@ -24,6 +24,24 @@ interface PendingWorkspaceServicePortPlanToken { isReleased: boolean; } +class WorkspaceServicePortPlanReleasedError extends Error { + constructor(workspaceId: string) { + super(`Workspace service port plan was released while being created for '${workspaceId}'`); + } +} + +async function waitForWorkspaceServicePortPlanUpdate( + pendingPlan: Promise<Map<string, number>>, +): Promise<void> { + try { + await pendingPlan; + } catch (error) { + // Allocation failure belongs to the original request. A waiting request + // retries against the rolled-back plan; workspace archival cancels both. + if (error instanceof WorkspaceServicePortPlanReleasedError) throw error; + } +} + const workspaceServicePortPlans = new Map<string, Map<string, number>>(); const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>(); const pendingWorkspaceServicePortPlanTokens = new Map< @@ -37,25 +55,19 @@ const MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS = 10; export async function ensureWorkspaceServicePortPlan( options: EnsureWorkspaceServicePortPlanOptions, ): Promise<ReadonlyMap<string, number>> { - const existingPlan = workspaceServicePortPlans.get(options.workspaceId); - if (existingPlan) { + const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId); + if (pendingPlan) { + await waitForWorkspaceServicePortPlanUpdate(pendingPlan); + return ensureWorkspaceServicePortPlan(options); + } + + const existingPlan = + workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(); + if (options.services.every((service) => existingPlan.has(service.scriptName))) { return new Map(existingPlan); } - let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId); - if (!pendingPlan) { - const token: PendingWorkspaceServicePortPlanToken = { isReleased: false }; - pendingPlan = createPendingWorkspaceServicePortPlan({ - workspaceId: options.workspaceId, - services: options.services, - allocatePort: options.allocatePort, - token, - }); - pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan); - pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token); - } - - return new Map(await pendingPlan); + return new Map(await startWorkspaceServicePortPlanUpdate({ ...options, existingPlan })); } export function requirePlannedWorkspaceServicePort( @@ -82,27 +94,62 @@ export function releaseWorkspaceServicePortPlan(workspaceId: string): void { dynamicPortsByWorkspace.delete(workspaceId); } -async function createPendingWorkspaceServicePortPlan(options: { - workspaceId: string; - services: readonly WorkspaceServicePortDeclaration[]; - allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>; - token: PendingWorkspaceServicePortPlanToken; -}): Promise<Map<string, number>> { +interface WorkspaceServicePortPlanUpdateOptions extends EnsureWorkspaceServicePortPlanOptions { + existingPlan: ReadonlyMap<string, number>; + refreshScriptName?: string; +} + +function startWorkspaceServicePortPlanUpdate( + options: WorkspaceServicePortPlanUpdateOptions, +): Promise<Map<string, number>> { + const token: PendingWorkspaceServicePortPlanToken = { isReleased: false }; + const plan = createPendingWorkspaceServicePortPlan({ ...options, token }); + pendingWorkspaceServicePortPlans.set(options.workspaceId, plan); + pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token); + return plan; +} + +async function createPendingWorkspaceServicePortPlan( + options: WorkspaceServicePortPlanUpdateOptions & { token: PendingWorkspaceServicePortPlanToken }, +): Promise<Map<string, number>> { + const previousDynamicPorts = new Map(dynamicPortsByWorkspace.get(options.workspaceId)); + const retainedPlan = new Map(options.existingPlan); + if (options.refreshScriptName !== undefined) retainedPlan.delete(options.refreshScriptName); try { - const plan = await buildWorkspaceServicePortPlan({ + const updatedPlan = await buildWorkspaceServicePortPlan({ workspaceId: options.workspaceId, services: options.services, allocatePort: options.allocatePort, + existingPlan: retainedPlan, }); + const plan = new Map(options.existingPlan); + for (const [scriptName, port] of updatedPlan) plan.set(scriptName, port); if (options.token.isReleased) { - throw new Error( - `Workspace service port plan was released while being created for '${options.workspaceId}'`, - ); + throw new WorkspaceServicePortPlanReleasedError(options.workspaceId); + } + for (const [scriptName, port] of previousDynamicPorts) { + if (plan.get(scriptName) !== port) { + releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port }); + } } workspaceServicePortPlans.set(options.workspaceId, plan); return plan; } catch (error) { - releaseWorkspaceServicePortPlan(options.workspaceId); + const dynamicPorts = dynamicPortsByWorkspace.get(options.workspaceId); + if (dynamicPorts) { + for (const [scriptName, port] of dynamicPorts) { + if (options.token.isReleased || previousDynamicPorts.get(scriptName) !== port) { + releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port }); + } + } + } + if (!options.token.isReleased) { + for (const [scriptName, port] of previousDynamicPorts) { + reserveDynamicPort({ workspaceId: options.workspaceId, scriptName, port }); + } + } + if (options.token.isReleased) + throw new WorkspaceServicePortPlanReleasedError(options.workspaceId); throw error; } finally { pendingWorkspaceServicePortPlans.delete(options.workspaceId); @@ -111,12 +158,16 @@ async function createPendingWorkspaceServicePortPlan(options: { } async function buildWorkspaceServicePortPlan(options: { + existingPlan: ReadonlyMap<string, number>; workspaceId: string; services: readonly WorkspaceServicePortDeclaration[]; allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>; }): Promise<Map<string, number>> { - const explicitPortOwners = new Map<number, string>(); + const explicitPortOwners = new Map<number, string>( + Array.from(options.existingPlan, ([scriptName, port]) => [port, scriptName]), + ); for (const service of options.services) { + if (options.existingPlan.has(service.scriptName)) continue; if (service.port === undefined) continue; if (explicitPortOwners.has(service.port)) { throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`); @@ -124,8 +175,9 @@ async function buildWorkspaceServicePortPlan(options: { explicitPortOwners.set(service.port, service.scriptName); } - const plan = new Map<string, number>(); + const plan = new Map(options.existingPlan); for (const service of options.services) { + if (plan.has(service.scriptName)) continue; if (service.port !== undefined) { plan.set(service.scriptName, service.port); continue; @@ -148,32 +200,20 @@ async function buildWorkspaceServicePortPlan(options: { export async function refreshWorkspaceServicePort( options: RefreshWorkspaceServicePortOptions, ): Promise<number> { - const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(); + const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId); + if (pendingPlan) { + await waitForWorkspaceServicePortPlanUpdate(pendingPlan); + return refreshWorkspaceServicePort(options); + } - const reservedPorts = new Set(plan.values()); - const previousPort = plan.get(options.service.scriptName); - if (previousPort !== undefined) { - reservedPorts.delete(previousPort); - } - const oldDynamicPort = dynamicPortsByWorkspace - .get(options.workspaceId) - ?.get(options.service.scriptName); - const port = await resolveServicePort({ - service: options.service, + const plan = await startWorkspaceServicePortPlanUpdate({ workspaceId: options.workspaceId, + services: [options.service], allocatePort: options.allocatePort, - reservedPorts, + existingPlan: workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(), + refreshScriptName: options.service.scriptName, }); - if (oldDynamicPort !== undefined && oldDynamicPort !== port) { - releaseDynamicPort({ - workspaceId: options.workspaceId, - scriptName: options.service.scriptName, - port: oldDynamicPort, - }); - } - plan.set(options.service.scriptName, port); - workspaceServicePortPlans.set(options.workspaceId, plan); - return port; + return requirePlannedWorkspaceServicePort(plan, options.service.scriptName); } async function resolveServicePort(options: { diff --git a/packages/server/src/server/worktree-bootstrap.posix.test.ts b/packages/server/src/server/worktree-bootstrap.posix.test.ts index a0a796c83d..41de520d09 100644 --- a/packages/server/src/server/worktree-bootstrap.posix.test.ts +++ b/packages/server/src/server/worktree-bootstrap.posix.test.ts @@ -422,12 +422,12 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { api: { type: "service", command: - "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('api-env.json.tmp', 'api-env.json'); setTimeout(()=>{}, 30000)\"", }, web: { type: "service", command: - "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('web-env.json.tmp', 'web-env.json'); setTimeout(()=>{}, 30000)\"", }, }, }), diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index c878ef1387..5e5ac1762e 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -837,6 +837,54 @@ describe("runAsyncWorktreeBootstrap", () => { }); }); + it("starts a service added to paseo.json without restarting the daemon", async () => { + const backend = { + type: "service" as const, + command: "uv run uvicorn app.main:app --reload --port $PASEO_PORT", + }; + commitPaseoScripts({ "backend-dev": backend }); + + const serviceProxy = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const options = { + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-added-service", + daemonPort: 6767, + serviceProxy, + runtimeStore, + terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), + }; + const backendResult = await spawnWorkspaceScript({ ...options, scriptName: "backend-dev" }); + + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + "backend-dev": backend, + web: { type: "service", command: "pnpm run dev -- --port $PASEO_PORT" }, + }, + }), + ); + const webResult = await spawnWorkspaceScript({ ...options, scriptName: "web" }); + + expect(webResult.port).toEqual(expect.any(Number)); + expect(webResult.port).not.toBe(backendResult.port); + expect(terminalRecords[1]?.sentInputs).toEqual(["pnpm run dev -- --port $PASEO_PORT\r"]); + expect(createTerminalCalls[1]?.env?.PASEO_PORT).toBe(String(webResult.port)); + expect(createTerminalCalls[1]?.env?.PASEO_SERVICE_BACKEND_DEV_PORT).toBe( + String(backendResult.port), + ); + expect(serviceProxy.listRoutes()).toHaveLength(2); + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "backend-dev" })).toMatchObject({ + lifecycle: "running", + terminalId: backendResult.terminalId, + }); + }); + it("spawns services with public aliases and public service URLs", async () => { commitPaseoScripts( { diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 8e69908f59..4bd49b1c2d 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -593,13 +593,10 @@ export async function runWorktreeAutoTerminals(options: { workspaceId: string; worktree: WorktreeConfig; workspaceCwd: string; + runtimeEnv: WorktreeRuntimeEnv; terminalManager: TerminalManager | null; logger?: Logger; }): Promise<void> { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: options.worktree.worktreePath, - branchName: options.worktree.branchName, - }); await runWorktreeTerminalBootstrap( { agentId: options.workspaceId, @@ -610,7 +607,7 @@ export async function runWorktreeAutoTerminals(options: { appendTimelineItem: async () => true, logger: options.logger, }, - runtimeEnv, + options.runtimeEnv, ); } diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 744ba0726a..b146c59f31 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -2017,6 +2017,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2088,6 +2089,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2164,6 +2166,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2240,6 +2243,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2b9f8a0ffd..82f0113bd5 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -4,6 +4,7 @@ import { basename } from "node:path"; import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; import { type GitSetupOptions, + type CreateAgentWorktreeTarget, type FirstAgentContext, type ChangeRequestCheckoutSource, type SessionInboundMessage, @@ -320,7 +321,42 @@ function validateNormalizedGitOptions(input: ValidateNormalizedGitOptionsInput): } } -export function normalizeGitOptions( +export function normalizeAgentCreationGitOptions( + gitOptions?: GitSetupOptions, + legacyWorktreeName?: string, + worktree?: CreateAgentWorktreeTarget, +): NormalizedGitOptions | null { + if (worktree && gitOptions) { + throw new Error("create_agent_request worktree cannot be combined with git options"); + } + if (worktree) { + switch (worktree.mode) { + case "branch-off": + gitOptions = { + createWorktree: true, + createNewBranch: true, + newBranchName: worktree.newBranch, + baseBranch: worktree.base, + action: "branch-off", + refName: worktree.base, + }; + break; + case "checkout-branch": + gitOptions = { createWorktree: true, action: "checkout", refName: worktree.branch }; + break; + case "checkout-pr": + gitOptions = { + createWorktree: true, + action: "checkout", + githubPrNumber: worktree.prNumber, + }; + break; + } + } + return normalizeGitOptions(gitOptions, legacyWorktreeName); +} + +function normalizeGitOptions( gitOptions?: GitSetupOptions, legacyWorktreeName?: string, ): NormalizedGitOptions | null { @@ -465,7 +501,10 @@ export async function handlePaseoWorktreeArchiveRequest( "emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService" > & { emit: EmitSessionMessage; - workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">; + workspaceGitService: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "invalidateWorktrees" + >; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>; }, msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>, @@ -778,7 +817,6 @@ export async function handleWorkspaceSetupRunRequest( slug: basename(worktree.worktreePath), worktreePath: worktree.worktreePath, workspaceCwd: workspace.cwd, - runAutoTerminals: true, }, signal, ), @@ -818,7 +856,6 @@ export async function runWorktreeSetupInBackground( slug: string; worktreePath: string; workspaceCwd?: string; - runAutoTerminals?: boolean; }, signal?: AbortSignal, ): Promise<void> { @@ -859,18 +896,18 @@ export async function runWorktreeSetupInBackground( } else { const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; const setupCommands = getWorktreeSetupCommands(workspaceCwd); + const runtimeEnv = await resolveWorktreeRuntimeEnv({ + worktreePath: worktree.worktreePath, + branchName: worktree.branchName, + repoRootPath: options.repoRoot, + }); + dependencies.terminalManager?.registerCwdEnv({ + cwd: workspaceCwd, + env: runtimeEnv, + }); if (setupCommands.length === 0) { emitSetupProgress("completed", null); } else { - const runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: worktree.worktreePath, - branchName: worktree.branchName, - repoRootPath: options.repoRoot, - }); - dependencies.terminalManager?.registerCwdEnv({ - cwd: workspaceCwd, - env: runtimeEnv, - }); setupResults = await runWorktreeSetupCommands({ worktreePath: workspaceCwd, branchName: worktree.branchName, @@ -885,15 +922,14 @@ export async function runWorktreeSetupInBackground( }); emitSetupProgress("completed", null); } - if (options.runAutoTerminals) { - await runWorktreeAutoTerminals({ - workspaceId, - worktree, - workspaceCwd, - terminalManager: dependencies.terminalManager, - logger: dependencies.sessionLogger, - }); - } + await runWorktreeAutoTerminals({ + workspaceId, + worktree, + workspaceCwd, + runtimeEnv, + terminalManager: dependencies.terminalManager, + logger: dependencies.sessionLogger, + }); } } catch (error) { if (error instanceof WorktreeSetupError) { diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index cedc5013c9..4f76cb1887 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -93,7 +93,10 @@ export interface ArchiveCommandDependencies extends Omit< ArchiveDependencies, "workspaceGitService" > { - workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">; + workspaceGitService: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "invalidateWorktrees" + >; } export interface ArchiveCommandInput { diff --git a/packages/server/src/services/forge-service.ts b/packages/server/src/services/forge-service.ts index fba9833cd8..8499cc8756 100644 --- a/packages/server/src/services/forge-service.ts +++ b/packages/server/src/services/forge-service.ts @@ -39,6 +39,8 @@ export interface PullRequestCheckoutTarget { headRepositorySshUrl: string | null; headRepositoryUrl: string | null; isCrossRepository: boolean; + /** "pull-ref" when headRefName is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */ + headRefKind?: "branch" | "pull-ref"; } export interface PullRequestCheckoutRef { @@ -441,9 +443,9 @@ export interface ForgeService { getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>; /** * Refs to fetch for a change-request checkout when the resolved checkout - * target carries none. Adapters that expose a universal change-request head - * ref (GitHub's refs/pull/N/head) return it here; others let the shell fall - * back to the head branch. + * target carries none. Adapters with a universal change-request head ref + * (refs/pull/N/head) return it here; others let the shell fall back to the + * head branch. */ defaultCheckoutRefs?(params: { changeRequestNumber: number; @@ -451,7 +453,7 @@ export interface ForgeService { }): PullRequestCheckoutRef[]; /** * Local branch name for a checked-out change request when the adapter - * disambiguates cross-repository heads (GitHub prefixes the fork owner). + * disambiguates cross-repository heads (prefixes the fork owner). * Returns undefined to keep the head ref name as-is. */ buildPrLocalBranchName?(params: { diff --git a/packages/server/src/services/gitea-service.test.ts b/packages/server/src/services/gitea-service.test.ts index 768f1a7f05..9e3a3ecc62 100644 --- a/packages/server/src/services/gitea-service.test.ts +++ b/packages/server/src/services/gitea-service.test.ts @@ -2297,11 +2297,13 @@ describe("createGiteaService", () => { checkoutRefs: [ { remoteName: "origin", remoteRef: "refs/pull/5/head" }, { remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, ], headOwnerLogin: null, headRepositorySshUrl: null, headRepositoryUrl: null, isCrossRepository: false, + headRefKind: "branch", }); }); @@ -2332,17 +2334,215 @@ describe("createGiteaService", () => { headRefName: "feat/sample-change", checkoutRefs: [ { remoteName: "origin", remoteRef: "refs/pull/5/head" }, - { remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, ], headOwnerLogin: "contributor", headRepositorySshUrl: "git@gitea.com:contributor/sample-repo.git", headRepositoryUrl: "https://gitea.com/contributor/sample-repo", isCrossRepository: true, + headRefKind: "branch", }); expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]); }); + it("skips the origin branch fallback and tracking for an AGit pull request", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok( + JSON.stringify({ + flow: 1, + head: { ref: "refs/pull/5/head", repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }), + ) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target).toMatchObject({ + checkoutRefs: [ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ], + isCrossRepository: false, + headRefKind: "pull-ref", + }); + }); + + it("names the local branch pr-<number> end to end for an agit pull request", async () => { + // the tea CLI reports head as the raw pull ref too when there's no real + // branch, same as the Forgejo API's head.ref + const { service } = makeService((args) => + args[0] === "api" + ? ok( + JSON.stringify({ + flow: 1, + head: { ref: "refs/pull/5/head", repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }), + ) + : ok(JSON.stringify({ ...STATUS_PR_VIEW, head: "refs/pull/5/head" })), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: target.headRefName, + checkoutTarget: target, + }); + + expect(localBranchName).toBe("pr-5"); + }); + + it("skips the origin branch fallback when the head branch was deleted", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok( + JSON.stringify({ + head: { ref: "refs/pull/5/head", repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }), + ) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target).toMatchObject({ + checkoutRefs: [ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ], + isCrossRepository: false, + headRefKind: "pull-ref", + }); + }); + + it("keeps a pull request from a deleted fork cross-repository", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok(JSON.stringify({ head: { repo: null }, base: { repo: { id: 1 } } })) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target).toMatchObject({ + checkoutRefs: [ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ], + headOwnerLogin: null, + isCrossRepository: true, + headRefKind: "branch", + }); + }); + + it("keeps the origin branch fallback when the server sends no head ref", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok(JSON.stringify({ head: { repo: { id: 1 } }, base: { repo: { id: 1 } } })) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target.checkoutRefs).toContainEqual({ + remoteName: "origin", + remoteRef: "refs/heads/feat/sample-change", + }); + expect(target.headRefKind).toBe("branch"); + }); + + it("prefixes the local branch name with the fork owner for a cross-repository checkout", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "patch-1", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "patch-1", + headOwnerLogin: "contributor", + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + }, + }); + + expect(localBranchName).toBe("contributor/patch-1"); + }); + + it("names the local branch pr-<number> for a same-repo pull-ref-only head", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "refs/pull/5/head", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "refs/pull/5/head", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + headRefKind: "pull-ref", + }, + }); + + expect(localBranchName).toBe("pr-5"); + }); + + it("prefixes pr-<number> with the fork owner for a pull-ref-only head", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "refs/pull/5/head", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "refs/pull/5/head", + headOwnerLogin: "contributor", + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + headRefKind: "pull-ref", + }, + }); + + expect(localBranchName).toBe("contributor/pr-5"); + }); + + it("does not double the pr number for a deleted fork's pull-ref-only head", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "refs/pull/5/head", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "refs/pull/5/head", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + headRefKind: "pull-ref", + }, + }); + + expect(localBranchName).toBe("pr-5"); + }); + + it("returns the pull head ref for default checkout refs", () => { + const { service } = makeService(() => ok("")); + + expect(service.defaultCheckoutRefs?.({ changeRequestNumber: 5, headRef: "patch-1" })).toEqual([ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ]); + }); + it("creates a pull request and parses the resulting URL and index", async () => { const { service, calls } = makeService((args) => { if (args[0] === "pr" && args[1] === "create") { diff --git a/packages/server/src/services/gitea-service.ts b/packages/server/src/services/gitea-service.ts index 88b5caa004..892f9cc33a 100644 --- a/packages/server/src/services/gitea-service.ts +++ b/packages/server/src/services/gitea-service.ts @@ -5,6 +5,10 @@ import pLimit from "p-limit"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { findExecutable } from "../executable-resolution/executable-resolution.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { + buildForkLocalBranchName, + buildPullHeadCheckoutRefs, +} from "../utils/change-request-checkout.js"; import { execCommand } from "../utils/spawn.js"; import { createCachedCliPathResolver, @@ -75,6 +79,8 @@ const CURRENT_PR_HEAD_SHA_CACHE_TTL_MS = 30_000; // the sweep to the 250 most recent Actions tasks. const GITEA_ACTION_PAGE_LIMIT = 50; const GITEA_ACTION_MAX_PAGES = 5; +// PullRequestFlowAGit in forgejo models/issues/pull.go +const GITEA_PULL_REQUEST_FLOW_AGIT = 1; /** * Fields requested from `tea pr list -o json`. tea's default field set omits the @@ -225,8 +231,12 @@ const GiteaPullRequestRepoSchema = z const GiteaPullRequestApiSchema = z .object({ + flow: z.number().optional(), head: z - .object({ repo: GiteaPullRequestRepoSchema.nullable().optional() }) + .object({ + ref: z.string().optional(), + repo: GiteaPullRequestRepoSchema.nullable().optional(), + }) .passthrough() .optional(), base: z @@ -1435,17 +1445,30 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For return runJsonParse(args, runOptions, stdout, schema); } + function resolveHeadRefKind( + pr: z.infer<typeof GiteaPullRequestApiSchema>, + number: number, + ): "branch" | "pull-ref" { + // head.ref only becomes the branch name when the branch still exists, + // agit PRs and deleted branches keep the pull ref + const isPullRefOnly = + pr.flow === GITEA_PULL_REQUEST_FLOW_AGIT || pr.head?.ref === `refs/pull/${number}/head`; + return isPullRefOnly ? "pull-ref" : "branch"; + } + async function resolveForkCheckoutFacts( cwd: string, summary: PullRequestSummary, ): Promise<{ isCrossRepository: boolean; + headRefKind: "branch" | "pull-ref"; headOwnerLogin: string | null; headRepositorySshUrl: string | null; headRepositoryUrl: string | null; }> { const sameRepo = { isCrossRepository: false, + headRefKind: "branch" as const, headOwnerLogin: null, headRepositorySshUrl: null, headRepositoryUrl: null, @@ -1464,14 +1487,17 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For ); const headRepo = pr.head?.repo ?? null; const baseRepo = pr.base?.repo ?? null; - if (headRepo?.id == null || baseRepo?.id == null || headRepo.id === baseRepo.id) { - return sameRepo; + const headRefKind = resolveHeadRefKind(pr, summary.number); + if (baseRepo?.id == null || headRepo?.id === baseRepo.id) { + return { ...sameRepo, headRefKind }; } + // headRepo is null when the fork was deleted return { isCrossRepository: true, - headOwnerLogin: headRepo.owner?.login ?? null, - headRepositorySshUrl: headRepo.ssh_url ?? null, - headRepositoryUrl: headRepo.html_url ?? null, + headRefKind, + headOwnerLogin: headRepo?.owner?.login ?? null, + headRepositorySshUrl: headRepo?.ssh_url ?? null, + headRepositoryUrl: headRepo?.html_url ?? null, }; } @@ -1997,17 +2023,29 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For return summary.headRefName; }, + defaultCheckoutRefs({ changeRequestNumber }) { + return buildPullHeadCheckoutRefs(changeRequestNumber); + }, + + buildPrLocalBranchName({ headRef, checkoutTarget }) { + return buildForkLocalBranchName({ headRef, ...checkoutTarget }); + }, + async getPullRequestCheckoutTarget( input: GetPullRequestOptions, ): Promise<PullRequestCheckoutTarget> { const summary = await this.getPullRequest(input); - const checkoutRefs = [ - { remoteName: "origin", remoteRef: `refs/pull/${summary.number}/head` }, - ]; - if (summary.headRefName) { + const fork = await resolveForkCheckoutFacts(input.cwd, summary); + const [originPullRef, upstreamPullRef] = buildPullHeadCheckoutRefs(summary.number); + const checkoutRefs = [originPullRef]; + // for a fork PR a branch with that name on origin belongs to the base repo + const headBranchOnOrigin = + !fork.isCrossRepository && fork.headRefKind === "branch" && Boolean(summary.headRefName); + if (headBranchOnOrigin) { checkoutRefs.push({ remoteName: "origin", remoteRef: `refs/heads/${summary.headRefName}` }); } - const fork = await resolveForkCheckoutFacts(input.cwd, summary); + // upstream can hold a different PR with the same number, so it goes last + checkoutRefs.push(upstreamPullRef); return { number: summary.number, baseRefName: summary.baseRefName, @@ -2017,6 +2055,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For headRepositorySshUrl: fork.headRepositorySshUrl, headRepositoryUrl: fork.headRepositoryUrl, isCrossRepository: fork.isCrossRepository, + headRefKind: fork.headRefKind, }; }, diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index ab71115f36..3109bcc63c 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -797,6 +797,7 @@ describe("ForgeService", () => { headRepositorySshUrl: "git@github.com:therainisme/paseo.git", headRepositoryUrl: "https://github.com/therainisme/paseo", isCrossRepository: true, + headRefKind: "branch", }); expect(runner.calls).toHaveLength(2); diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index b6de0cbd6e..2dd3fdc269 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -6,6 +6,10 @@ import { } from "@getpaseo/protocol/git-remote"; import { findExecutable } from "../executable-resolution/executable-resolution.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { + buildForkLocalBranchName, + buildPullHeadCheckoutRefs, +} from "../utils/change-request-checkout.js"; import { execCommand } from "../utils/spawn.js"; import { resolveSshHostname } from "../utils/ssh-hostname.js"; import { @@ -2104,17 +2108,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }, defaultCheckoutRefs({ changeRequestNumber }) { - return [ - { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, - { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, - ]; + return buildPullHeadCheckoutRefs(changeRequestNumber); }, buildPrLocalBranchName({ headRef, checkoutTarget }) { - const owner = checkoutTarget.isCrossRepository - ? normalizeGitHubOwnerForBranch(checkoutTarget.headOwnerLogin) - : null; - return owner ? `${owner}/${headRef}` : headRef; + return buildForkLocalBranchName({ headRef, ...checkoutTarget }); }, supportsCrossRepoCheckoutWithoutRefs: true, @@ -2663,11 +2661,6 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G return api; } -function normalizeGitHubOwnerForBranch(owner: string | null): string | null { - const normalized = owner?.trim().toLowerCase() ?? ""; - return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; -} - function getGithubStatusFacts( status: PullRequestCommandStatus | null | undefined, ): GitHubPullRequestStatusFacts | null { @@ -3529,6 +3522,7 @@ function toPullRequestCheckoutTarget( headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null, headRepositoryUrl: pullRequest.headRepository?.url || null, isCrossRepository: pullRequest.isCrossRepository, + headRefKind: "branch", }; } diff --git a/packages/server/src/services/gitlab-service.test.ts b/packages/server/src/services/gitlab-service.test.ts index c74224e444..b9b6d447d4 100644 --- a/packages/server/src/services/gitlab-service.test.ts +++ b/packages/server/src/services/gitlab-service.test.ts @@ -477,6 +477,7 @@ describe("createGitLabService", () => { headRepositorySshUrl: null, headRepositoryUrl: null, isCrossRepository: false, + headRefKind: "branch", }); expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]); }); @@ -1328,6 +1329,132 @@ describe("createGitLabService", () => { ); }); + it("finds a merge request by number even when its title and description omit it", async () => { + const { service } = makeService((args) => { + // GitLab text search does not search the IID. Model both API behaviors. + if (args.includes("--search")) return ok("[]"); + if (args[0] === "api" && args[1].includes("iids[]=14")) { + return ok(JSON.stringify([OPEN_MR])); + } + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: " 14 ", + kinds: ["change_request"], + }); + expect(result.items).toEqual([ + expect.objectContaining({ + kind: "change_request", + number: 14, + title: OPEN_MR.title, + headRefName: OPEN_MR.source_branch, + baseRefName: OPEN_MR.target_branch, + }), + ]); + }); + + it("finds issues by number through the same search interface", async () => { + const { service } = makeService((args) => { + if (args.includes("--search")) return ok("[]"); + if (args[0] === "api" && args[1].includes("iids[]=7")) { + return ok(JSON.stringify([OPEN_ISSUE])); + } + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: "7", + kinds: ["issue"], + }); + expect(result.items).toEqual([ + expect.objectContaining({ kind: "issue", number: 7, title: OPEN_ISSUE.title }), + ]); + }); + + it.each([ + { kind: "change_request" as const, item: OPEN_MR }, + { kind: "issue" as const, item: OPEN_ISSUE }, + ])( + "preserves numeric text matches after the exact $kind number, without duplicates", + async ({ kind, item }) => { + const textMatches = [ + { + ...item, + iid: 90, + title: `References ${item.iid}`, + updated_at: "2026-07-01T00:00:00.000Z", + }, + item, + { + ...item, + iid: 91, + description: `References ${item.iid}`, + updated_at: "2026-06-30T00:00:00.000Z", + }, + ]; + const { service } = makeService((args) => { + if (args.includes("--search")) return ok(JSON.stringify(textMatches)); + if (args[0] === "api") return ok(JSON.stringify([item])); + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: String(item.iid), + limit: 2, + kinds: [kind], + }); + expect( + result.items.map((resultItem) => ({ kind: resultItem.kind, number: resultItem.number })), + ).toEqual([ + { kind, number: item.iid }, + { kind, number: 90 }, + ]); + }, + ); + + it.each([ + { kind: "change_request" as const, item: OPEN_MR }, + { kind: "issue" as const, item: OPEN_ISSUE }, + ])("keeps $kind text matches when the queried number is not open", async ({ kind, item }) => { + const { service } = makeService((args) => { + if (args.includes("--search")) { + return ok(JSON.stringify([{ ...item, description: "References 2024" }])); + } + if (args[0] === "api") return ok("[]"); + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: "2024", + kinds: [kind], + }); + expect(result.items.map(({ number }) => number)).toEqual([item.iid]); + }); + + it("keeps closed and missing merge requests out of number search results", async () => { + const closedMr = { ...OPEN_MR, state: "closed" }; + const { service } = makeService((args) => { + if (args.includes("--search")) return ok("[]"); + if (args[0] !== "api") throw new Error(`unexpected glab args: ${args.join(" ")}`); + const endpoint = new URL(args[1], "https://gitlab.example.com/api/v4/"); + const matches = + endpoint.searchParams.get("iids[]") === String(closedMr.iid) && + endpoint.searchParams.get("state") !== "opened"; + return ok(JSON.stringify(matches ? [closedMr] : [])); + }); + for (const query of ["14", "999999"]) { + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query, + limit: 10, + kinds: ["change_request"], + }); + expect(result.authState).toBe("authenticated"); + expect(result.items).toEqual([]); + } + }); + it("searches issues and merge requests and maps them to neutral results", async () => { const { service, calls } = makeService((args) => { if (args[0] === "issue") return ok(JSON.stringify([OPEN_ISSUE])); diff --git a/packages/server/src/services/gitlab-service.ts b/packages/server/src/services/gitlab-service.ts index f0b166cdda..c5f54be2cb 100644 --- a/packages/server/src/services/gitlab-service.ts +++ b/packages/server/src/services/gitlab-service.ts @@ -882,6 +882,31 @@ function throwFirstNonGlabAuthSearchRejection(results: PromiseSettledResult<unkn } } +function buildGitLabListArgs({ + kind, + input, +}: { + kind: "mr" | "issue"; + input: ListPullRequestsOptions | ListIssuesOptions; +}): string[] { + const query = input.query?.trim(); + // issue list uses -O for JSON; its -F flag controls details/ids/urls. + // mr list uses -F for JSON instead. + const args = [kind, "list", kind === "mr" ? "-F" : "-O", "json"]; + if (query) { + args.push("--search", query); + } + if (typeof input.limit === "number") { + args.push("-P", String(input.limit)); + } + return args; +} + +function gitLabNumberQuery(query: string | undefined): string | null { + const trimmed = query?.trim(); + return trimmed && /^\d+$/.test(trimmed) ? trimmed : null; +} + export function createGitLabService(options: CreateGitLabServiceOptions = {}): ForgeService { const runner = options.runner ?? runGlabCommand; const resolveGlab = createCachedCliPathResolver(options.resolveGlabPath ?? resolveGlabPath); @@ -1057,42 +1082,51 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F } } + async function runList<T extends { iid: number }>({ + kind, + input, + schema, + }: { + kind: "mr" | "issue"; + input: ListPullRequestsOptions | ListIssuesOptions; + schema: z.ZodType<T[]>; + }): Promise<T[]> { + const textSearch = runJson(buildGitLabListArgs({ kind, input }), { cwd: input.cwd }, schema); + const numberQuery = gitLabNumberQuery(input.query); + if (!numberQuery) return textSearch; + + // GitLab text search omits IIDs, so supplement it with an open-item lookup. + const resource = kind === "mr" ? "merge_requests" : "issues"; + const [numberMatches, textMatches] = await Promise.all([ + runJson( + ["api", `projects/:id/${resource}?state=opened&iids[]=${numberQuery}`], + { cwd: input.cwd }, + schema, + ), + textSearch, + ]); + const seen = new Set<number>(); + const items = [...numberMatches, ...textMatches].filter((item) => { + if (seen.has(item.iid)) return false; + seen.add(item.iid); + return true; + }); + return typeof input.limit === "number" ? items.slice(0, input.limit) : items; + } + async function runMergeRequestList( input: ListPullRequestsOptions, ): Promise<PullRequestSummary[]> { - const args = ["mr", "list", "-F", "json"]; - const query = input.query?.trim(); - if (query) { - args.push("--search", query); - } - if (typeof input.limit === "number") { - args.push("-P", String(input.limit)); - } - const mergeRequests = await runJson( - args, - { cwd: input.cwd }, - z.array(GitLabMergeRequestSchema), - ); + const mergeRequests = await runList({ + kind: "mr", + input, + schema: z.array(GitLabMergeRequestSchema), + }); return mergeRequests.map(toPullRequestSummary); } - /** - * `glab issue list` toggles JSON output with `-O/--output` (text|json); its - * `-F/--output-format` flag means something else (details|ids|urls) and - * silently falls back to the human-readable table for an unknown value. This - * differs from `glab mr list`, where `-F/--output` is the JSON toggle. Using - * the wrong flag here would emit a text table that fails JSON parsing. - */ async function runIssueList(input: ListIssuesOptions): Promise<IssueSummary[]> { - const args = ["issue", "list", "-O", "json"]; - const query = input.query?.trim(); - if (query) { - args.push("--search", query); - } - if (typeof input.limit === "number") { - args.push("-P", String(input.limit)); - } - const issues = await runJson(args, { cwd: input.cwd }, z.array(GitLabIssueSchema)); + const issues = await runList({ kind: "issue", input, schema: z.array(GitLabIssueSchema) }); return issues.map(toIssueSummary); } @@ -1170,6 +1204,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F mr.target_project_id !== undefined && mr.target_project_id !== null && mr.source_project_id !== mr.target_project_id, + headRefKind: "branch", }; }, @@ -1359,9 +1394,16 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F }); } } - items.sort( - (left, right) => parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt), - ); + const numberQuery = gitLabNumberQuery(input.query); + items.sort((left, right) => { + if (numberQuery) { + const numberMatchOrder = + Number(right.number === Number(numberQuery)) - + Number(left.number === Number(numberQuery)); + if (numberMatchOrder !== 0) return numberMatchOrder; + } + return parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt); + }); return { items, diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 9881f4bdfd..751fb1b33a 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { buildTerminalEnvironment, + assertTerminalCommandCanStart, createTerminal, ensureNodePtySpawnHelperExecutableForCurrentPlatform, resolveDefaultTerminalShell, @@ -24,7 +25,9 @@ import { } from "node:fs"; import { spawnSync } from "node:child_process"; import * as pty from "node-pty"; -import { join } from "node:path"; +import xterm from "@xterm/headless"; +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; +import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; import { stripVTControlCharacters } from "node:util"; @@ -50,6 +53,10 @@ function getLines(state: ReturnType<TerminalSession["getState"]>): string[] { return state.grid.map(rowToText); } +function rowHasChar(row: TerminalRow, char: string): boolean { + return row.some((cell) => cell.char === char); +} + // Wait for terminal state to match expected lines async function waitForLines( session: TerminalSession, @@ -523,6 +530,44 @@ describe("createTerminal", () => { expect(withoutFlags.scrollbackWrapped).toBeUndefined(); }); + it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => { + // Each Hangul syllable is a wide char: xterm stores it as a 2-cell pair + // whose second cell has no chars. Capturing that continuation as " " made + // every restored syllable three columns wide (paseo#3434). + const text = "통합 구현은 전체 테스트를 통과했습니다"; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 80, + rows: 10, + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`, + ], + }), + ); + + const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "다")); + + // Replay the snapshot the way the client does on tab switch and read the + // row back the way a selection copy would. + const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true }); + try { + await new Promise<void>((resolve) => + restored.write(renderTerminalSnapshotToAnsi(state), resolve), + ); + expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text); + } finally { + restored.dispose(); + } + + // The continuation cell must stay empty so the restore path can skip it. + expect(state.grid[0][0].char).toBe("통"); + expect(state.grid[0][1].char).toBe(""); + }); + it("captures exit diagnostics from the terminal buffer", async () => { const session = trackSession( await createTerminal({ @@ -593,6 +638,151 @@ describe("createTerminal", () => { expect(exitInfo.lastOutputLines.length).toBeGreaterThan(0); expect(exitInfo.lastOutputLines[exitInfo.lastOutputLines.length - 1]).toBe("line-2999"); }); + + it.runIf(isPlatform("linux", "darwin"))( + "rejects a profile command that is not installed instead of opening a terminal", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Codex", + command: "paseo-qa-absent-codex", + args: [], + }), + ).rejects.toThrow("paseo-qa-absent-codex: command not found"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "opens a command found only on the PATH carried by the env option", + async () => { + // execve resolves the command against the child's PATH, which carries the + // per-cwd registered environment. A version-manager shim lives there and + // not necessarily on the daemon's own PATH. + const binDir = mkdtempSync(join(tmpdir(), "terminal-envpath-bin-")); + temporaryDirs.push(binDir); + const toolPath = join(binDir, "paseo-qa-envpath-tool"); + writeFileSync(toolPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(toolPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Shim", + command: "paseo-qa-envpath-tool", + args: [], + env: { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` }, + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "reports command not found when the command is on neither PATH", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Codex", + command: "paseo-qa-absent-codex", + args: [], + env: { PATH: `/paseo-qa-empty${delimiter}${process.env.PATH ?? ""}` }, + }), + ).rejects.toThrow("paseo-qa-absent-codex: command not found"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "opens a relative command path resolved against the terminal cwd", + async () => { + // execve resolves a path-ish command against the child's cwd, never the + // daemon's own working directory. + const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-cmd-")); + temporaryDirs.push(projectDir); + mkdirSync(join(projectDir, "scripts")); + const scriptPath = join(projectDir, "scripts", "dev.sh"); + writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(projectDir), + name: "Dev script", + command: "./scripts/dev.sh", + args: [], + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))("opens an absolute command path", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "terminal-absolute-cmd-")); + temporaryDirs.push(projectDir); + const scriptPath = join(projectDir, "run.sh"); + writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Absolute", + command: scriptPath, + args: [], + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }); + + it("leaves the command check to the terminal itself on Windows", async () => { + // Windows resolves inside resolveTerminalSpawnCommand, which has its own + // shim handling and its own fallback for an unresolved command. + await expect( + assertTerminalCommandCanStart({ + command: "paseo-qa-absent-codex", + cwd: realpathSync(tmpdir()), + env: { PATH: "/paseo-qa-empty" }, + platform: "win32", + }), + ).resolves.toBeUndefined(); + }); + + it.runIf(isPlatform("linux", "darwin"))( + "still opens a terminal for an installed command that exits non-zero", + async () => { + // A profile running a failing test suite must keep opening a tab with its + // output. Only a command that cannot start at all is reported as an error, + // and "exited non-zero" does not distinguish the two: a missing binary and + // `false` both surface as exit code 1 from node-pty. + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Failing suite", + command: "false", + args: [], + }), + ); + + const exitInfo = await new Promise<{ exitCode: number | null }>((resolve) => { + session.onExit((info) => resolve({ exitCode: info.exitCode })); + }); + + expect(exitInfo).toEqual({ exitCode: 1 }); + }, + ); }); describe.skipIf(isPlatform("win32"))("send input", () => { @@ -1344,3 +1534,43 @@ describe.runIf(isPlatform("win32"))(".cmd shim argv round-trip on Windows", () = expect(await roundTrip("first\nsecond")).toEqual(["first second"]); }); }); + +it.runIf(isPlatform("linux", "darwin"))( + "opens a profile command from a PATH entry relative to the terminal cwd", + async () => { + const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-path-")); + mkdirSync(join(projectDir, "bin")); + const script = join(projectDir, "bin", "paseo-relative-path-tool"); + writeFileSync(script, "#!/bin/sh\nprintf 'paseo-launch-ok\\n'\nexec /bin/sleep 30\n"); + chmodSync(script, 0o755); + let session: TerminalSession | undefined; + try { + session = await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(projectDir), + command: "paseo-relative-path-tool", + env: { PATH: "bin" }, + }); + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(session.cwd).toBe(realpathSync(projectDir)); + expect(session.getExitInfo()).toBeNull(); + } finally { + await session?.killAndWait({ gracefulTimeoutMs: 1000, forceTimeoutMs: 500 }); + rmSync(projectDir, { recursive: true, force: true }); + } + }, +); + +it.runIf(isPlatform("linux", "darwin"))( + "checks the exact command rather than accepting a trimmed executable name", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + command: " node ", + }), + ).rejects.toThrow(" node : command not found"); + }, +); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b9d1b74017..24f243b6ea 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -1,14 +1,25 @@ import * as pty from "node-pty"; import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; -import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { + accessSync, + chmodSync, + constants, + existsSync, + mkdirSync, + readFileSync, + statSync, +} from "node:fs"; import { tmpdir, userInfo } from "node:os"; import { basename, delimiter, dirname, extname, join, resolve as resolvePath } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { writePrivateFileAtomicSync } from "../server/private-files.js"; -import { findExecutable } from "../executable-resolution/executable-resolution.js"; +import { + findExecutable, + findExecutableWithoutProbing, +} from "../executable-resolution/executable-resolution.js"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js"; @@ -33,6 +44,20 @@ export interface TerminalExitInfo { lastOutputLines: string[]; } +// The field is assigned explicitly rather than declared as a constructor +// parameter property: the terminal worker loads this module under +// --experimental-strip-types, which erases types but cannot emit the +// assignment a parameter property implies. +export class TerminalCommandNotFoundError extends Error { + readonly command: string; + + constructor(command: string) { + super(`${command}: command not found`); + this.name = "TerminalCommandNotFoundError"; + this.command = command; + } +} + export interface TerminalCommandFinishedInfo { exitCode: number | null; } @@ -580,7 +605,9 @@ function extractCell(terminal: TerminalType, row: number, col: number): Terminal const bg = bgMode !== 0 ? cell.getBgColor() : undefined; return { - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, @@ -639,7 +666,9 @@ function extractScrollback( const fg = fgMode !== 0 ? cell.getFgColor() : undefined; const bg = bgMode !== 0 ? cell.getBgColor() : undefined; rowCells.push({ - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, @@ -882,6 +911,62 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] { return lines.slice(-limit); } +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) { + return false; + } + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +export interface AssertTerminalCommandCanStartInput { + command: string | undefined; + // The terminal's own cwd and environment, not the daemon's: execve in the + // child resolves against those, so anything else produces false negatives. + cwd: string; + env: Record<string, string>; + platform?: NodeJS.Platform; +} + +// node-pty spawns directly through execve on POSIX, with no shell, so a command +// that does not exist fails inside the spawn helper and surfaces as exit code 1 +// with no output -- indistinguishable from `false` or any program that simply +// failed. Resolving it up front is the only signal that separates "cannot start" +// from "ran and exited non-zero", and it lets the create report the failure +// instead of handing back a terminal that dies immediately. Windows keeps +// resolving inside resolveTerminalSpawnCommand, which has its own shim handling +// and its own fallback for an unresolved command. +export async function assertTerminalCommandCanStart( + input: AssertTerminalCommandCanStartInput, +): Promise<void> { + const { command, cwd, env } = input; + const platform = input.platform ?? process.platform; + if (command === undefined || platform === "win32") { + return; + } + + // A path-ish command is resolved by execve against the child's cwd, never + // through PATH and never against the daemon's working directory. + if (command.includes("/")) { + if (isExecutableFile(resolvePath(cwd, command))) { + return; + } + throw new TerminalCommandNotFoundError(command); + } + + const resolved = await findExecutableWithoutProbing(command, { + path: env[getPathEnvKey(env)], + cwd, + }); + if (!resolved) { + throw new TerminalCommandNotFoundError(command); + } +} + export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> { const { cwd, @@ -941,19 +1026,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te const { command: spawnCommand, args: spawnArgs } = command ? await resolveTerminalSpawnCommand(command, args) : { command: resolvedShell, args: [] as string[] }; + const terminalEnv = buildTerminalEnvironment({ + shell: spawnCommand, + env: { + ...env, + ...activityEnv, + PASEO_WORKSPACE_ID: workspaceId, + }, + }); + + await assertTerminalCommandCanStart({ command, cwd, env: terminalEnv }); + const ptyProcess = pty.spawn(spawnCommand, spawnArgs, { name: "xterm-256color", cols, rows, cwd, - env: buildTerminalEnvironment({ - shell: spawnCommand, - env: { - ...env, - ...activityEnv, - PASEO_WORKSPACE_ID: workspaceId, - }, - }), + env: terminalEnv, }); function emitTitleChange(nextTitle: string | undefined): void { diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 2dc999c0c3..e11c9eca9f 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as session.send({ type: "input", data: `${command}\r` }); - await waitForCondition(() => existsSync(markerPath), 10000); - - expect(readFileSync(markerPath, "utf8")).toBe("shell-ok"); + await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok"); }); it("lists subdirectory terminals when querying the workspace root", async () => { @@ -970,3 +968,139 @@ it("removes a killed worker terminal from terminalExit without duplicate snapsho }, ]); }); + +it("does not resurrect a terminal that exited before its create response was handled", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) }); + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + + // The worker delivers all three messages in one tick, so the awaiting + // createTerminal continuation resumes only after the exit was processed. + const terminal = { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: null, + }; + const state = createTerminalState(); + const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] }; + worker.emitWorkerMessage({ type: "terminalCreated", terminal, state }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: { terminal, state }, + }); + worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo }); + + const session = await created; + + expect(manager.getTerminal("terminal-a")).toBeUndefined(); + expect(await manager.getTerminals("/workspace")).toEqual([]); + expect(session.getExitInfo()).toEqual(exitInfo); + expect(snapshots).toEqual([ + { cwd: "/workspace", terminalIds: ["terminal-a"] }, + { cwd: "/workspace", terminalIds: [] }, + ]); +}); + +it("replays the exit to a listener attached after a create that lost the race", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + + const terminal = { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: null, + }; + const state = createTerminalState(); + const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] }; + worker.emitWorkerMessage({ type: "terminalCreated", terminal, state }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: { terminal, state }, + }); + worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo }); + + const session = await created; + + // The session controller subscribes only after createTerminal resolves; that + // subscription is what turns into a terminal_exit for the client. + const observedExits: Array<{ exitCode: number | null }> = []; + session.onExit((info) => { + observedExits.push({ exitCode: info.exitCode }); + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(observedExits).toEqual([{ exitCode: 0 }]); +}); + +it("rejects createTerminal and registers nothing when the worker cannot start the command", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) }); + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + command: "paseo-qa-absent-codex", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: false, + error: "paseo-qa-absent-codex: command not found", + }); + + await expect(created).rejects.toThrow("paseo-qa-absent-codex: command not found"); + expect(manager.getTerminal("terminal-a")).toBeUndefined(); + expect(await manager.getTerminals("/workspace")).toEqual([]); + expect(snapshots).toEqual([]); +}); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 0f0243d7a8..ca84cfe622 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -156,6 +156,9 @@ export function createWorkerTerminalManager( const recordsById = new Map<string, WorkerTerminalRecord>(); const terminalIdsByCwd = new Map<string, Set<string>>(); const terminalActivityTokenById = new Map<string, string>(); + // Creation owns a reference to the session independently of the live-terminal + // index. Only terminalCreated registers it; terminalExit removes it permanently. + const pendingCreationsById = new Map<string, { session: TerminalSession | null }>(); const terminalsChangedListeners = new Set<TerminalsChangedListener>(); const terminalActivityListeners = new Set<TerminalActivityListener>(); const terminalWorkspaceContributionChangedListeners = @@ -542,10 +545,14 @@ export function createWorkerTerminalManager( function handleWorkerEvent(message: TerminalWorkerToParentMessage): void { switch (message.type) { case "terminalCreated": { - registerRecord({ + const session = registerRecord({ info: asRequiredWorkerTerminalInfo(message.terminal), state: message.state, }); + const pendingCreation = pendingCreationsById.get(message.terminal.id); + if (pendingCreation) { + pendingCreation.session = session; + } emitTerminalsChanged({ cwd: message.terminal.cwd, terminals: listTerminalItemsForCwd(message.terminal.cwd), @@ -686,12 +693,10 @@ export function createWorkerTerminalManager( const activityToken = createActivityToken(); const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null; terminalActivityTokenById.set(terminalId, activityToken); - let result: { - terminal: RequiredWorkerTerminalInfo; - state: TerminalState; - }; + const creation = { session: null as TerminalSession | null }; + pendingCreationsById.set(terminalId, creation); try { - result = (await sendRequest({ + await sendRequest({ type: "createTerminal", options: { ...options, @@ -699,16 +704,17 @@ export function createWorkerTerminalManager( activityToken, activityUrl: terminalActivityUrl, }, - })) as { - terminal: RequiredWorkerTerminalInfo; - state: TerminalState; - }; + }); + if (!creation.session) { + throw new Error("Terminal worker completed creation without a terminalCreated event"); + } + return creation.session; } catch (error) { terminalActivityTokenById.delete(terminalId); throw error; + } finally { + pendingCreationsById.delete(terminalId); } - const session = registerRecord({ info: result.terminal, state: result.state }); - return session; }, registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void { diff --git a/packages/server/src/utils/change-request-checkout.test.ts b/packages/server/src/utils/change-request-checkout.test.ts new file mode 100644 index 0000000000..74a57c2445 --- /dev/null +++ b/packages/server/src/utils/change-request-checkout.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + buildForkLocalBranchName, + normalizeForgeOwnerForBranch, +} from "./change-request-checkout.js"; + +describe("normalizeForgeOwnerForBranch", () => { + it("lowercases a valid login", () => { + expect(normalizeForgeOwnerForBranch("Contributor")).toBe("contributor"); + }); + + it("allows dots and underscores", () => { + expect(normalizeForgeOwnerForBranch("a.b_c")).toBe("a.b_c"); + }); + + it("rejects a login starting with a dash", () => { + expect(normalizeForgeOwnerForBranch("-abc")).toBeNull(); + }); + + it("rejects a login starting with a dot", () => { + expect(normalizeForgeOwnerForBranch(".hidden")).toBeNull(); + }); + + it("rejects a login containing '..'", () => { + expect(normalizeForgeOwnerForBranch("a..b")).toBeNull(); + }); + + it("rejects a login ending in .lock", () => { + expect(normalizeForgeOwnerForBranch("contributor.lock")).toBeNull(); + }); + + it("rejects a login with a path traversal segment", () => { + expect(normalizeForgeOwnerForBranch("../evil")).toBeNull(); + }); + + it("rejects an empty or null login", () => { + expect(normalizeForgeOwnerForBranch(null)).toBeNull(); + expect(normalizeForgeOwnerForBranch("")).toBeNull(); + expect(normalizeForgeOwnerForBranch(" ")).toBeNull(); + }); +}); + +describe("buildForkLocalBranchName", () => { + const fork = { headRef: "patch-1", number: 42, isCrossRepository: true }; + + it("keeps the head ref for a same-repository head", () => { + expect( + buildForkLocalBranchName({ ...fork, isCrossRepository: false, headOwnerLogin: "arthur" }), + ).toBe("patch-1"); + }); + + it("prefixes a fork head with the lowercased owner", () => { + expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur" })).toBe("arthur/patch-1"); + }); + + it("falls back to the pr number when the owner is unknown", () => { + expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: null })).toBe("pr-42/patch-1"); + }); + + it("falls back to the pr number when the owner is not a safe ref segment", () => { + expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "-zaphod" })).toBe("pr-42/patch-1"); + }); + + // pull-ref-only heads (agit PRs, deleted branches) report headRef as the + // literal pull ref, e.g. refs/pull/42/head, not a real branch name + it("collapses a same-repository pull-ref-only head to pr-<number>", () => { + expect( + buildForkLocalBranchName({ + ...fork, + isCrossRepository: false, + headOwnerLogin: "arthur", + headRefKind: "pull-ref", + }), + ).toBe("pr-42"); + }); + + it("prefixes pr-<number> with the owner for a fork pull-ref-only head", () => { + expect( + buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur", headRefKind: "pull-ref" }), + ).toBe("arthur/pr-42"); + }); + + it("does not double the pr number when the fork owner is unknown and there is no head branch", () => { + expect( + buildForkLocalBranchName({ ...fork, headOwnerLogin: null, headRefKind: "pull-ref" }), + ).toBe("pr-42"); + }); +}); diff --git a/packages/server/src/utils/change-request-checkout.ts b/packages/server/src/utils/change-request-checkout.ts new file mode 100644 index 0000000000..12c779571b --- /dev/null +++ b/packages/server/src/utils/change-request-checkout.ts @@ -0,0 +1,47 @@ +import type { PullRequestCheckoutRef } from "../services/forge-service.js"; + +// upstream for clones where origin is your fork +export function buildPullHeadCheckoutRefs(number: number): PullRequestCheckoutRef[] { + return [ + { remoteName: "origin", remoteRef: `refs/pull/${number}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${number}/head` }, + ]; +} + +// forgejo username pattern, covers github logins too +export function normalizeForgeOwnerForBranch(owner: string | null): string | null { + const normalized = owner?.trim().toLowerCase() ?? ""; + if (!/^[a-z0-9][a-z0-9_.-]*$/.test(normalized)) { + return null; + } + // git refuses .. and .lock, trailing dot too + if (normalized.includes("..") || normalized.endsWith(".") || normalized.endsWith(".lock")) { + return null; + } + return normalized; +} + +export interface ForkLocalBranchNameInput { + headRef: string; + number: number; + isCrossRepository: boolean; + headOwnerLogin: string | null; + /** "pull-ref" when headRef is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */ + headRefKind?: "branch" | "pull-ref"; +} + +export function buildForkLocalBranchName(params: ForkLocalBranchNameInput): string { + const pullRefFallback = `pr-${params.number}`; + // headRef is the literal pull ref (e.g. refs/pull/42/head) when there is no + // real branch to name after, so use the pr number instead + const headSegment = params.headRefKind === "pull-ref" ? pullRefFallback : params.headRef; + if (!params.isCrossRepository) { + return headSegment; + } + const owner = normalizeForgeOwnerForBranch(params.headOwnerLogin); + if (!owner) { + // don't double up pr-<n>/pr-<n> when the owner is also unknown + return headSegment === pullRefFallback ? headSegment : `${pullRefFallback}/${headSegment}`; + } + return `${owner}/${headSegment}`; +} diff --git a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts index fbb22fe3b9..576e9b0c80 100644 --- a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts +++ b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; -import { getCommitFileDiff } from "./checkout-git.js"; +import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js"; +import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js"; const tempDirs: string[] = []; @@ -44,6 +45,75 @@ function headSha(repoDir: string): string { } describe("getCommitFileDiff", () => { + it.each(["new.txt", "with spaces.txt"])("preserves a rename-only commit to %s", async (name) => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["mv", "old.txt", name], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + const sha = headSha(repoDir); + + const history = await listCheckoutCommits({ cwd: repoDir }); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: name, status: "renamed", additions: 0, deletions: 0 }, + ]); + expect(await getCommitFileDiff({ cwd: repoDir, sha, path: name })).toEqual({ + path: name, + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }); + }); + + it.each(["new.txt", "目录.txt"])( + "preserves the edits in a committed rename to %s", + async (name) => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\nkeep\nlast\n", "initial"); + git(["mv", "old.txt", name], repoDir); + writeFileSync(join(repoDir, name), "same\nKEEP\nlast\n"); + git(["add", "."], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], repoDir); + const sha = headSha(repoDir); + + const history = await listCheckoutCommits({ cwd: repoDir }); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: name, status: "renamed", additions: 1, deletions: 1 }, + ]); + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: name }); + expect(file).toMatchObject({ + path: name, + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "keep" }), + expect.objectContaining({ type: "add", content: "KEEP" }), + ]), + ); + }, + ); + + it.skipIf(process.platform === "win32")("preserves a rename to a quoted filename", async () => { + const repoDir = initRepo(); + const name = 'new "draft".txt'; + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["mv", "old.txt", name], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + expect(await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: name })).toEqual({ + path: name, + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }); + }); + it("returns the parsed diff for a modified file in a commit", async () => { const repoDir = initRepo(); commitFile(repoDir, "foo.txt", "a\nb\nc\n", "initial"); @@ -78,6 +148,78 @@ describe("getCommitFileDiff", () => { expect(file?.deletions).toBe(0); }); + it.each([ + ["exact", "line1\nline2\nline3\nline4\nline5\nline6\n"], + ["edited", "line1\nline2\nline3\nline4\nline5\nCHANGED\n"], + ])("shows an added %s copy as a new file", async (_kind, addedContent) => { + const repoDir = initRepo(); + commitFile(repoDir, "source.txt", "line1\nline2\nline3\nline4\nline5\nline6\n", "initial"); + commitFile(repoDir, "added.txt", addedContent, "add similar file"); + const sha = headSha(repoDir); + + const history = await listCheckoutCommits({ cwd: repoDir }); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: "added.txt", status: "added", additions: 6, deletions: 0 }, + ]); + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" }); + expect(file).toMatchObject({ + path: "added.txt", + isNew: true, + isDeleted: false, + additions: 6, + deletions: 0, + }); + expect( + file?.hunks + .flatMap((hunk) => hunk.lines) + .filter((line) => line.type !== "header") + .map(({ type, content }) => ({ type, content })), + ).toEqual( + addedContent + .trimEnd() + .split("\n") + .map((content) => ({ type: "add", content })), + ); + }); + + it("shares commit rename discovery across concurrent and later file requests", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["mv", "old.txt", "new.txt"], repoDir); + commitFile(repoDir, "added.txt", "added\n", "rename and add"); + const sha = headSha(repoDir); + + startGitCommandMetrics(); + try { + const [renamed, added] = await Promise.all([ + getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" }), + getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" }), + ]); + expect(renamed).toMatchObject({ isNew: false, additions: 0, deletions: 0 }); + expect(added).toMatchObject({ isNew: true, additions: 1, deletions: 0 }); + expect(await getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" })).toEqual(renamed); + } finally { + const metrics = stopGitCommandMetrics(); + expect(metrics.commands.filter(({ args }) => args.includes("--name-status"))).toHaveLength(1); + } + }); + + it("rediscovers renames when a symbolic commit ref moves", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "old.txt" })).toMatchObject({ + isNew: true, + additions: 1, + }); + git(["mv", "old.txt", "new.txt"], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "new.txt" })).toMatchObject({ + isNew: false, + additions: 0, + deletions: 0, + }); + }); + it("returns null for a path not changed in the commit", async () => { const repoDir = initRepo(); commitFile(repoDir, "foo.txt", "a\n", "initial"); @@ -89,6 +231,59 @@ describe("getCommitFileDiff", () => { expect(file).toBeNull(); }); + it("does not follow an unchanged file back to an earlier commit", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "foo.txt", "same\n", "initial"); + commitFile(repoDir, "other.txt", "other\n", "unrelated change"); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }), + ).toBeNull(); + }); + + it("returns the added file in a root commit and the deleted file in its deletion commit", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "foo.txt", "same\n", "initial"); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }), + ).toMatchObject({ + isNew: true, + isDeleted: false, + additions: 1, + deletions: 0, + }); + git(["rm", "foo.txt"], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "delete"], repoDir); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }), + ).toMatchObject({ + isNew: false, + isDeleted: true, + additions: 0, + deletions: 1, + }); + }); + + it("preserves a rename introduced by a merge against its first parent", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["checkout", "-b", "feature"], repoDir); + git(["mv", "old.txt", "new.txt"], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + git(["checkout", "main"], repoDir); + commitFile(repoDir, "main.txt", "main\n", "advance main"); + git(["merge", "--no-ff", "feature", "-m", "merge feature"], repoDir); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "new.txt" }), + ).toMatchObject({ + path: "new.txt", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }); + }); + it("returns a merge commit diff against its first parent", async () => { const repoDir = initRepo(); commitFile(repoDir, "README.md", "base\n", "initial"); diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index 5a006ae0eb..d614e32f9b 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -25,7 +25,10 @@ function git(args: string[], cwd?: string): void { } function commit(repoDir: string, message: string): void { - git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir); + git( + ["-c", "commit.gpgsign=false", "commit", "--author=Test User <test@test.com>", "-m", message], + repoDir, + ); } function commitFile(repoDir: string, name: string, content: string, message: string): void { @@ -92,6 +95,30 @@ function addBareRemote(repoDir: string, tempDir: string): string { } describe("listCheckoutCommits", () => { + it("keeps fixture authors independent of command-scoped Git configuration", async () => { + const overrides = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "Other User", + }; + const previous = Object.fromEntries( + Object.keys(overrides).map((key) => [key, process.env[key]]), + ); + let repoDir: string; + try { + Object.assign(process.env, overrides); + ({ repoDir } = initRepoOnMain()); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + expect(commits[0]?.authorName).toBe("Test User"); + }); + it("lists recent commits newest-first with on-remote flags and file stats", async () => { const { repoDir, tempDir } = initRepoOnMain(); git(["checkout", "-b", "feature"], repoDir); diff --git a/packages/server/src/utils/checkout-git.patch-format.test.ts b/packages/server/src/utils/checkout-git.patch-format.test.ts new file mode 100644 index 0000000000..61e864a401 --- /dev/null +++ b/packages/server/src/utils/checkout-git.patch-format.test.ts @@ -0,0 +1,416 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getCheckoutDiff, + getCheckoutShortstat, + getCommitFileDiff, + listCheckoutCommits, +} from "./checkout-git.js"; + +const repos: string[] = []; + +const EDITED_TEXT_DIFF = { + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], +}; + +afterEach(() => { + vi.unstubAllEnvs(); + for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true }); +}); + +it.skipIf(process.platform === "win32")( + "returns a quoted filename and its uncommitted diff using its actual path", + async () => { + const cwd = createChangedRepo("core.quotepath=false"); + const name = 'report "draft".txt'; + writeFileSync(join(cwd, "file.txt"), "before\n"); + writeFileSync(join(cwd, name), "before\n"); + git(cwd, "add", "--", name); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add quoted filename"); + writeFileSync(join(cwd, name), "after\n"); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured).toHaveLength(1); + expect(result.structured?.[0]).toMatchObject({ + path: name, + status: "ok", + ...EDITED_TEXT_DIFF, + }); + }, +); + +describe.skipIf(process.platform === "win32")("quoted paths from real Git", () => { + const names = [ + "normal.txt", + "with spaces.txt", + 'report "draft".txt', + "back\\slash.txt", + "tab\tname.txt", + "line\nname.txt", + " leading and trailing .txt ", + 'report "old => draft".txt', + ]; + + function createQuotedPathsRepo(): string { + const cwd = createChangedRepo("core.quotepath=false"); + writeFileSync(join(cwd, "file.txt"), "before\n"); + for (const name of names) writeFileSync(join(cwd, name), "before\n"); + git(cwd, "add", "--", ...names); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add filenames"); + git(cwd, "checkout", "-b", "feature"); + for (const name of names) writeFileSync(join(cwd, name), "after\n"); + return cwd; + } + + it("preserves tracked and untracked paths, diff lines and summary counts", async () => { + const cwd = createQuotedPathsRepo(); + const untrackedNames = names.map((name) => "untracked-" + name); + for (const name of untrackedNames) writeFileSync(join(cwd, name), "new file\n"); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured?.map((file) => file.path).sort()).toEqual( + [...names, ...untrackedNames].sort(), + ); + for (const name of names) { + expect(result.structured?.find((file) => file.path === name)).toMatchObject({ + path: name, + status: "ok", + ...EDITED_TEXT_DIFF, + }); + } + for (const name of untrackedNames) { + expect(result.structured?.find((file) => file.path === name)).toMatchObject({ + path: name, + status: "ok", + isNew: true, + isDeleted: false, + additions: 1, + deletions: 0, + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -0,0 +1 @@" }, + { type: "add", content: "new file" }, + ], + }, + ], + }); + } + expect(await getCheckoutShortstat(cwd)).toEqual({ + additions: names.length + untrackedNames.length, + deletions: names.length, + }); + }); + + it("opens base and commit diffs using the actual paths from the commit list", async () => { + const cwd = createQuotedPathsRepo(); + git(cwd, "add", "--", ...names); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit filenames"); + const sha = git(cwd, "rev-parse", "HEAD").trim(); + + const base = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + expect(base.structured?.map((file) => file.path).sort()).toEqual([...names].sort()); + for (const name of names) { + expect(base.structured?.find((file) => file.path === name)).toMatchObject({ + path: name, + status: "ok", + ...EDITED_TEXT_DIFF, + }); + } + + const history = await listCheckoutCommits({ cwd }); + const commit = history.commits[0]; + expect(commit.sha).toBe(sha); + expect(commit.files).toEqual( + [...names].sort().map((name) => ({ + path: name, + additions: 1, + deletions: 1, + status: "modified", + })), + ); + for (const file of commit.files) { + expect(await getCommitFileDiff({ cwd, sha, path: file.path })).toMatchObject({ + path: file.path, + ...EDITED_TEXT_DIFF, + }); + } + }); + + it("preserves quoted rename destinations and binary paths in diff and commit lists", async () => { + const cwd = createChangedRepo("core.quotepath=false"); + const oldPath = 'old "draft".txt'; + const newPath = 'new "draft".txt'; + const binaryPath = "image\tdata.bin"; + writeFileSync(join(cwd, "file.txt"), "before\n"); + writeFileSync(join(cwd, oldPath), "unchanged\n"); + writeFileSync(join(cwd, binaryPath), Buffer.from([0, 1, 2])); + git(cwd, "add", "--", oldPath, binaryPath); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add rename and binary files"); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "mv", "--", oldPath, newPath); + writeFileSync(join(cwd, binaryPath), Buffer.from([0, 3, 4])); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + expect( + result.structured?.map(({ path, oldPath: sourcePath, status }) => ({ + path, + oldPath: sourcePath, + status, + })), + ).toEqual([ + { path: binaryPath, oldPath: undefined, status: "binary" }, + { + path: newPath, + oldPath, + status: "ok", + }, + ]); + expect(result.structured?.[0]).toEqual({ + path: binaryPath, + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + status: "binary", + }); + + git(cwd, "add", "--", binaryPath); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename file and edit binary"); + const history = await listCheckoutCommits({ cwd }); + expect(history.commits[0].files).toEqual([ + { path: binaryPath, additions: 0, deletions: 0, status: "modified" }, + { path: newPath, additions: 0, deletions: 0, status: "renamed" }, + ]); + }); +}); + +it.each(["diff.mnemonicPrefix=true", "color.diff=always", "diff.noprefix=true"])( + "preserves batched paths, spaces and literal prefix directories with %s", + async (config) => { + const cwd = createChangedRepo(config); + mkdirSync(join(cwd, "a")); + for (const name of ["a/literal.txt", "with spaces.txt"]) { + writeFileSync(join(cwd, name), "before\n"); + } + git(cwd, "add", "a/literal.txt", "with spaces.txt"); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add files"); + for (const name of ["a/literal.txt", "with spaces.txt"]) { + writeFileSync(join(cwd, name), "after\n"); + } + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured?.map((file) => file.path)).toEqual([ + "a/literal.txt", + "file.txt", + "with spaces.txt", + ]); + for (const file of result.structured ?? []) { + expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + } + expect(git(cwd, "config", "--get", config.split("=")[0]).trim()).toBe(config.split("=")[1]); + }, +); + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); +} + +function createChangedRepo(config: string): string { + const cwd = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-patch-format-"))); + repos.push(cwd); + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "file.txt"), "before\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + git(cwd, "config", ...config.split("=")); + writeFileSync(join(cwd, "file.txt"), "after\n"); + return cwd; +} + +function createExternalDiffCommand() { + const directory = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-external-diff-"))); + repos.push(directory); + const script = join(directory, "external-diff.cjs"); + const marker = join(directory, "invoked"); + writeFileSync( + script, + 'require("node:fs").writeFileSync(process.argv[2], "invoked");\n' + + 'process.stdout.write("CUSTOM EXTERNAL DIFF\\n");\n', + ); + const command = [process.execPath, script, marker] + .map((argument) => `'${argument.replaceAll("'", "'\\''")}'`) + .join(" "); + return { command, marker }; +} + +function createExternalDiffRepo() { + vi.stubEnv("GIT_EXTERNAL_DIFF", undefined); + const cwd = createChangedRepo("color.diff=always"); + const { command, marker } = createExternalDiffCommand(); + git(cwd, "config", "diff.external", command); + expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n"); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + return { cwd, command, marker }; +} + +function expectStandardPatch(result: Awaited<ReturnType<typeof getCheckoutDiff>>) { + expect(result.structured).toMatchObject([ + { + path: "file.txt", + additions: 1, + deletions: 1, + status: "ok", + hunks: [ + { + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], + }, + ]); +} + +it("returns Git patch hunks when a repository configures an external diff command", async () => { + const { cwd, command, marker } = createExternalDiffRepo(); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command); +}); + +it("returns Git patch hunks against the base branch with an external diff command", async () => { + const { cwd, command, marker } = createExternalDiffRepo(); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + + const result = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command); +}); + +it("returns Git patch hunks when GIT_EXTERNAL_DIFF configures the external command", async () => { + const cwd = createChangedRepo("color.diff=always"); + const { command, marker } = createExternalDiffCommand(); + vi.stubEnv("GIT_EXTERNAL_DIFF", command); + expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n"); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(process.env.GIT_EXTERNAL_DIFF).toBe(command); +}); + +describe.each(["diff.mnemonicPrefix=true", "color.diff=always"])( + "patch format with %s", + (config) => { + it("returns displayable uncommitted diff lines with their file counts", async () => { + const cwd = createChangedRepo(config); + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured).toHaveLength(1); + expect(result.structured?.[0]).toMatchObject({ + path: "file.txt", + additions: 1, + deletions: 1, + }); + expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + }); + + it("returns displayable committed changes against the base branch", async () => { + const cwd = createChangedRepo(config); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const result = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expect(result.structured?.[0]).toMatchObject({ + path: "file.txt", + additions: 1, + deletions: 1, + }); + expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]), + ); + }); + + it("returns displayable lines when opening an individual commit file", async () => { + const cwd = createChangedRepo(config); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const file = await getCommitFileDiff({ + cwd, + sha: git(cwd, "rev-parse", "HEAD").trim(), + path: "file.txt", + }); + + expect(file).toMatchObject({ path: "file.txt", additions: 1, deletions: 1 }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]), + ); + }); + }, +); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index e5cb02d46e..e361c41921 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -523,6 +523,7 @@ describe("checkout git utilities", () => { it("honors commit signing configuration", async () => { execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir }); + execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir }); execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "signed\n"); @@ -538,10 +539,129 @@ describe("checkout git utilities", () => { }); expect(diff.structured).toContainEqual( - expect.objectContaining({ path: "renamed.txt", oldPath: "file.txt" }), + expect.objectContaining({ + path: "renamed.txt", + oldPath: "file.txt", + additions: 0, + deletions: 0, + hunks: [], + }), ); }); + it.each([ + { mode: "uncommitted", oldPath: "to-rename.txt", path: "renamed.txt" }, + { mode: "base", oldPath: "to-rename.txt", path: "renamed.txt" }, + { mode: "uncommitted", oldPath: "old [file].txt", path: "new [file].txt" }, + ] as const)( + "shows only the edited line of a rename ($mode, $path)", + async ({ mode, oldPath, path }) => { + const original = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`); + commitFile(repoDir, oldPath, original.join("\n") + "\n", "file to rename"); + if (mode === "base") { + execFileSync("git", ["checkout", "-b", "feature/rename"], { cwd: repoDir }); + } + execFileSync("git", ["mv", oldPath, path], { cwd: repoDir }); + const edited = [...original]; + edited[5] = "edited line 6"; + writeFileSync(join(repoDir, path), edited.join("\n") + "\n"); + execFileSync("git", ["add", path], { cwd: repoDir }); + + expect( + execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(), + ).toMatch(/R\d+\t/); + if (mode === "base") { + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], { + cwd: repoDir, + }); + } + + const diff = await getCheckoutDiff(repoDir, { + mode, + ...(mode === "base" ? { baseRef: "main" } : {}), + includeStructured: true, + }); + + expect(diff.structured).toEqual([ + expect.objectContaining({ + path, + oldPath, + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + status: "ok", + }), + ]); + expect(diff.diff).toContain(`rename from ${oldPath}`); + expect(diff.diff).toContain(`rename to ${path}`); + expect(diff.diff).toContain("-line 6\n+edited line 6"); + expect(diff.diff).not.toContain("new file mode"); + }, + ); + + it("includes a changed copy source only once in the diff", async () => { + const original = + Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n"; + commitFile(repoDir, "source.txt", original, "copy source"); + execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir }); + writeFileSync(join(repoDir, "copy.txt"), original); + writeFileSync(join(repoDir, "source.txt"), original.replace("line 6", "edited line 6")); + execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir }); + expect( + execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(), + ).toContain("C100\tsource.txt\tcopy.txt"); + + const diff = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + expect(diff.structured).toEqual([ + expect.objectContaining({ + path: "copy.txt", + oldPath: "source.txt", + additions: 0, + deletions: 0, + }), + expect.objectContaining({ path: "source.txt", additions: 1, deletions: 1 }), + ]); + expect(diff.diff.match(/^diff --git a\/source.txt b\/source.txt$/gm)).toHaveLength(1); + expect(diff.diff).toContain("copy from source.txt"); + expect(diff.diff).toContain("copy to copy.txt"); + }); + + it("keeps a small copy patch when its edited source exceeds the patch limit", async () => { + const original = + Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n"; + commitFile(repoDir, "source.txt", original, "copy source"); + execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir }); + writeFileSync(join(repoDir, "copy.txt"), original); + writeFileSync(join(repoDir, "source.txt"), original + "added source line\n".repeat(80_000)); + execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir }); + expect( + execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(), + ).toContain("C100\tsource.txt\tcopy.txt"); + + const diff = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + expect(diff.structured).toEqual([ + expect.objectContaining({ + path: "copy.txt", + oldPath: "source.txt", + additions: 0, + deletions: 0, + hunks: [], + status: "ok", + }), + expect.objectContaining({ path: "source.txt", status: "too_large", hunks: [] }), + ]); + expect(diff.diff).toContain("copy from source.txt"); + expect(diff.diff).toContain("copy to copy.txt"); + expect(diff.diff).not.toContain("+added source line"); + }); + it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => { setupRemoteTrackingMain(repoDir, tempDir); @@ -1507,8 +1627,12 @@ const x = 1; expect(diff.diff).toContain("# generated.js: diff too large omitted"); expect(diff.diff).toContain(`-export const value = "old";`); expect(diff.diff).toContain(`+export const value = "new";`); - expect(commands).toContain("diff --numstat HEAD"); - expect(commands).toContain("diff HEAD -- :(literal)generated.js :(literal)small.ts"); + expect(commands).toContain( + "diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ -z --numstat HEAD", + ); + expect(commands).toContain( + "diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts", + ); expect(metrics.maxConcurrent).toBeLessThanOrEqual(8); }); @@ -3576,6 +3700,7 @@ const x = 1; cwd: repoDir, }); execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir }); + execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir }); execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir }); await expect(mergeToBase(repoDir, { baseRef: "main", mode: "squash" })).rejects.toThrow( diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index e1246a90a5..ce00702908 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,3 +1,4 @@ +import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; @@ -31,6 +32,7 @@ import { import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand, type RunGitCommand } from "./run-git-command.js"; import { readGitFileContents } from "./git-file-contents.js"; +import { unquoteGitPath } from "./git-quoted-path.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { branchNameFromRef, @@ -514,22 +516,18 @@ async function listCheckoutFileChanges( const { stdout: nameStatusOut } = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, - extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)], + extra: ["-z", "--name-status", ...getCheckoutDiffRefArgs(refs)], }), { cwd, envOverlay: READ_ONLY_GIT_ENV }, ); - for (const line of nameStatusOut - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)) { - // `--name-status` uses TAB separators, which preserves filenames with spaces. - const tabParts = line.split("\t"); - const rawStatus = (tabParts[0] ?? "").trim(); + const nameStatusFields = nameStatusOut.split("\0"); + for (let index = 0; index < nameStatusFields.length; ) { + const rawStatus = nameStatusFields[index++]; if (!rawStatus) continue; if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) { - const oldPath = tabParts[1]; - const newPath = tabParts[2]; + const oldPath = nameStatusFields[index++]; + const newPath = nameStatusFields[index++]; if (newPath) { changes.push({ path: newPath, @@ -542,7 +540,7 @@ async function listCheckoutFileChanges( continue; } - const path = tabParts[1]; + const path = nameStatusFields[index++]; if (!path) continue; const code = rawStatus[0]; changes.push({ @@ -555,16 +553,13 @@ async function listCheckoutFileChanges( if (refs.includeUntracked) { const { stdout: untrackedOut } = await runGitCommand( - ["ls-files", "--others", "--exclude-standard"], + ["ls-files", "-z", "--others", "--exclude-standard"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }, ); - for (const file of untrackedOut - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)) { + for (const file of untrackedOut.split("\0").filter(Boolean)) { changes.push({ path: file, status: "U", @@ -622,22 +617,34 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string type FileStat = { additions: number; deletions: number; isBinary: boolean } | null; function normalizeNumstatPath(pathField: string): string { + if (/^"(?:\\.|[^"\\])*"$/.test(pathField)) { + return unquoteGitPath(pathField); + } + const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/); if (braceRenameMatch) { const [, prefix, , renamed, suffix] = braceRenameMatch; - return `${prefix}${renamed}${suffix}`; + return unquoteGitPath(`${prefix}${renamed}${suffix}`); } const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/); if (inlineRenameMatch) { - return inlineRenameMatch[2] ?? pathField; + return unquoteGitPath(inlineRenameMatch[2] ?? pathField); } - return pathField; + return unquoteGitPath(pathField); } +// Parsed patches need a stable wire format regardless of terminal presentation settings. +const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"]; + function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] { - return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra]; + return [ + "diff", + ...GIT_PATCH_FORMAT_ARGS, + ...(args.ignoreWhitespace ? ["-w"] : []), + ...args.extra, + ]; } const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB @@ -660,7 +667,7 @@ async function getTrackedNumstatByPath( const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, - extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)], + extra: ["-z", "--numstat", ...getCheckoutDiffRefArgs(refs)], }), { cwd, @@ -671,21 +678,21 @@ async function getTrackedNumstatByPath( ); const stats = new Map<string, FileStat>(); - const lines = result.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - for (const line of lines) { - const parts = line.split("\t"); + const records = result.stdout.split("\0"); + for (let index = 0; index < records.length; index += 1) { + const parts = records[index].split("\t"); if (parts.length < 3) { continue; } const additionsField = parts[0] ?? ""; const deletionsField = parts[1] ?? ""; - const rawPath = parts.slice(2).join("\t"); - const path = normalizeNumstatPath(rawPath); + let path = parts.slice(2).join("\t"); + if (!path) { + // A rename has an empty path here, followed by separate old/new NUL fields. + path = records[index + 2]; + index += 2; + } if (!path) { continue; @@ -713,12 +720,19 @@ async function getTrackedDiffTextForPath(input: { cwd: string; refsForDiff: CheckoutDiffRefs; path: string; + oldPath?: string; ignoreWhitespace: boolean; }): Promise<{ path: string; text: string; truncated: boolean }> { + const paths = input.oldPath ? [input.oldPath, input.path] : [input.path]; const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace: input.ignoreWhitespace, - extra: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", `:(literal)${input.path}`], + extra: [ + ...(input.oldPath ? ["--diff-filter=RC"] : []), + ...getCheckoutDiffRefArgs(input.refsForDiff), + "--", + ...paths.map((path) => `:(literal)${path}`), + ], }), { cwd: input.cwd, @@ -727,9 +741,22 @@ async function getTrackedDiffTextForPath(input: { }, ); + // A source path can participate in another rename/copy in the same request. + // Return only the requested destination; the filter keeps source edits out of its budget. + let text = result.stdout; + if (input.oldPath && !result.truncated) { + text = result.stdout + .split(/(?=^diff --git )/m) + .filter((section) => { + const hunkStart = section.indexOf("\n@@ "); + const header = hunkStart < 0 ? section : section.slice(0, hunkStart); + return parseDiff(header)[0]?.path === input.path; + }) + .join(""); + } return { path: input.path, - text: result.stdout, + text, truncated: result.truncated, }; } @@ -1159,13 +1186,16 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] { async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> { try { - const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - const entries = parseWorktreeList(stdout); const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`; - return entries.find((entry) => entry.branchRef === ref)?.path ?? null; + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname)%00%(worktreepath)%00", ref], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + for (const record of stdout.split("\0\n")) { + const [reference, worktreePath] = record.split("\0"); + if (reference === ref) return worktreePath || null; + } + return null; } catch { return null; } @@ -1560,11 +1590,15 @@ export async function resolveRepositoryDefaultBranch( ); return localName; } catch { + await getRunGitCommand(context)(["show-ref", "--verify", "--quiet", ref], { + cwd: repoRoot, + envOverlay: READ_ONLY_GIT_ENV, + }); return remoteShort; } } } catch { - // ignore + // A stale origin/HEAD must fall through to the available local branches. } const { stdout } = await getRunGitCommand(context)(["branch", "--format=%(refname:short)"], { @@ -2117,13 +2151,13 @@ export async function getCheckoutSnapshotFacts( const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB -const RELAY_MAX_FRAME_BYTES = 32 * 1024 * 1024; const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024; // Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is // binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for // the surrounding WebSocket JSON envelope after inverting that exact wire expansion. export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES = - maxBase64EncryptedPlaintextByteLength(RELAY_MAX_FRAME_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; + maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - + CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; interface StructuredDiffAccumulator { files: ParsedDiffFile[]; @@ -2388,7 +2422,7 @@ function parseRawStatusLine(line: string, statuses: Map<string, CheckoutCommitFi if (!path) { return; } - statuses.set(path, status); + statuses.set(unquoteGitPath(path), status); } // A `--numstat` line: `<adds>\t<dels>\t<path>` (renames use `old => new`, binary @@ -2609,6 +2643,60 @@ export async function listCheckoutCommits({ return { baseRef: comparisonBaseRef, commits }; } +const commitRenamesCache = new TTLCache<string, Promise<ReadonlyMap<string, string>>>({ + max: 100, + ttl: 60_000, + checkAgeOnGet: true, +}); + +function getCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<string, string>> { + // Symbolic refs and abbreviated IDs can resolve to a different commit later. + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) { + return readCommitRenames(cwd, sha); + } + const key = JSON.stringify([cwd, sha]); + const cached = commitRenamesCache.get(key); + if (cached) { + return cached; + } + const lookup = readCommitRenames(cwd, sha).catch((error: unknown) => { + if (commitRenamesCache.get(key) === lookup) { + commitRenamesCache.delete(key); + } + throw error; + }); + // Store the pending lookup too, so concurrent file requests share the scan. + commitRenamesCache.set(key, lookup); + return lookup; +} + +// Discover renames across the commit before narrowing the patch to a file. +// --follow also detects copies, which would disagree with history's -M-only list. +async function readCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<string, string>> { + const result = await runGitCommand( + [ + "show", + sha, + "--format=", + "--diff-merges=first-parent", + "--name-status", + "-z", + "-M", + "--diff-filter=R", + ], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + if (result.truncated) { + throw new Error("Commit renames exceeded the git output limit"); + } + const renames = new Map<string, string>(); + const fields = result.stdout.split("\0"); + for (let index = 0; index + 2 < fields.length; index += 3) { + renames.set(fields[index + 2]!, fields[index + 1]!); + } + return renames; +} + /** * Fetches the unified diff of a single file as introduced by one commit and * parses it into the same {@link ParsedDiffFile} shape the diff subscription @@ -2630,10 +2718,23 @@ export async function getCommitFileDiff({ sha: string; path: string; }): Promise<ParsedDiffFile | null> { + const worktreeRoot = await requireGitWorktreeRoot(cwd); + const renames = await getCommitRenames(worktreeRoot, sha); + const oldPath = renames.get(path); + const paths = oldPath === undefined ? [path] : [oldPath, path]; const { stdout } = await runGitCommand( - ["show", sha, "--format=", "--diff-merges=first-parent", "--", path], + [ + "show", + sha, + "--format=", + "--diff-merges=first-parent", + "-M", + ...GIT_PATCH_FORMAT_ARGS, + "--", + ...paths.map((filePath) => `:(literal)${filePath}`), + ], { - cwd, + cwd: worktreeRoot, envOverlay: READ_ONLY_GIT_ENV, }, ); @@ -2642,13 +2743,13 @@ export async function getCommitFileDiff({ return null; } - const parsedFiles = await parseAndHighlightDiff(stdout, cwd, { - getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path), - getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path), + const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, { + getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path), + getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path), }); - // `--` scopes the diff to a single pathspec, so there is at most one real - // entry. Pick by path to drop any stray header-only section the parser emits. + // Including the rename source lets Git pair it with the destination. Select + // the requested entry if another change also matches the source path. const file = parsedFiles.find((candidate) => candidate.path === path) ?? null; if (!file) { return null; @@ -2701,16 +2802,13 @@ async function countUntrackedAdditions( ): Promise<number> { try { const { stdout } = await getRunGitCommand(context)( - ["ls-files", "--others", "--exclude-standard"], + ["ls-files", "-z", "--others", "--exclude-standard"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }, ); - const files = stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); + const files = stdout.split("\0").filter(Boolean); let additions = 0; for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) { @@ -3302,26 +3400,29 @@ async function processTrackedChanges( let trackedDiffText = ""; let trackedDiffBytes = 0; - const renamedPaths = new Set( - trackedChanges.filter((change) => change.oldPath).map((change) => change.path), + const oldPathByPath = new Map( + trackedChanges + .filter((change) => change.oldPath) + .map((change) => [change.path, change.oldPath]), ); for (let start = 0; start < trackedDiffPaths.length; start += TRACKED_DIFF_BATCH_SIZE) { const paths = trackedDiffPaths.slice(start, start + TRACKED_DIFF_BATCH_SIZE); - // Keep rename/copy path filtering identical to the existing per-file command. + // Rename/copy detection needs both paths. const ordinary = await getTrackedDiffTexts({ cwd, refsForDiff, - paths: paths.filter((path) => !renamedPaths.has(path)), + paths: paths.filter((path) => !oldPathByPath.has(path)), ignoreWhitespace, }); const renamed = await Promise.all( paths - .filter((path) => renamedPaths.has(path)) + .filter((path) => oldPathByPath.has(path)) .map((path) => getTrackedDiffTextForPath({ cwd, refsForDiff, path, + oldPath: oldPathByPath.get(path), ignoreWhitespace, }), ), @@ -3388,7 +3489,7 @@ export async function getCheckoutDiff( compare: CheckoutDiffCompare, context?: CheckoutContext, ): Promise<CheckoutDiffResult> { - await requireGitRepo(cwd); + cwd = await requireGitWorktreeRoot(cwd); const refsForDiff = await resolveCheckoutDiffRefs(cwd, compare, context); if (!refsForDiff) { @@ -3518,13 +3619,13 @@ export async function commitAll(cwd: string, message: string): Promise<void> { } export async function discardChanges(cwd: string, pathspecs: string[]): Promise<void> { - await requireGitRepo(cwd); + const worktreeRoot = await requireGitWorktreeRoot(cwd); if (pathspecs.length === 0) { return; } try { await runGitCommand(["--literal-pathspecs", "reset", "-q", "HEAD", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } catch { @@ -3532,7 +3633,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< await runGitCommand( ["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }, ); @@ -3544,7 +3645,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< const status = await runGitCommand( ["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }, ); @@ -3570,13 +3671,13 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< } if (tracked.length > 0) { await runGitCommand(["--literal-pathspecs", "checkout", "-q", "--", ...tracked], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } if (untracked.length > 0) { await runGitCommand(["--literal-pathspecs", "clean", "-fd", "-q", "--", ...untracked], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 68a2693562..711a721e48 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -697,6 +697,105 @@ describe("absolute directory-path configuration", () => { }); }); +describe("home search with excluded discovery paths", () => { + let homeDir: string; + let libraryDir: string; + + beforeEach(() => { + homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-excluded-"))); + libraryDir = path.join(homeDir, "Library"); + mkdirSync(path.join(libraryDir, "sub", "match-me"), { recursive: true }); + mkdirSync(path.join(homeDir, "projects", "other"), { recursive: true }); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + function searchHome(query: string) { + return searchDirectoryEntries({ + root: homeDir, + query, + pathFormat: "absolute", + includeDirectories: true, + includeFiles: false, + pathQueryPolicy: "rooted", + rootAliases: ["~"], + blankQueryBehavior: "none", + confidentResultScanThreshold: 5_000, + excludedDiscoveryPaths: [libraryDir], + }).then((entries) => entries.map((entry) => entry.path)); + } + + it("does not discover entries inside an excluded path", async () => { + await expect(searchHome("match")).resolves.toEqual([]); + await expect(searchHome("~")).resolves.toEqual([path.join(homeDir, "projects")]); + }); + + it("excludes by path, so a nested directory with the same name is still discovered", async () => { + const nested = path.join(homeDir, "projects", "game", "Library", "match-me"); + mkdirSync(nested, { recursive: true }); + + await expect(searchHome("match")).resolves.toEqual([nested]); + }); + + // POSIX-only: creates and follows a symlink fixture. + it.skipIf(isWindows)("does not discover an excluded path through a symlink", async () => { + symlinkSync(path.join(libraryDir, "sub"), path.join(homeDir, "link"), "dir"); + + await expect(searchHome("match")).resolves.toEqual([]); + }); + + it("resolves an exact typed path inside an excluded path", async () => { + await expect(searchHome("~/Library/sub/match-me")).resolves.toEqual([ + path.join(libraryDir, "sub", "match-me"), + ]); + }); + + it("lists one level when browsing inside an excluded path", async () => { + await expect(searchHome("~/Library/")).resolves.toEqual([ + libraryDir, + path.join(libraryDir, "sub"), + ]); + await expect(searchHome("~/Library/su")).resolves.toEqual([path.join(libraryDir, "sub")]); + }); +}); + +describe("rooted queries with a typed parent", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-typed-parent-"))); + mkdirSync(path.join(homeDir, "Developer", "pasta"), { recursive: true }); + mkdirSync(path.join(homeDir, "Archive", "Developer", "pasta"), { recursive: true }); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("walks only under the typed parent when it exists", async () => { + await expect( + searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pas" }), + ).resolves.toEqual([path.join(homeDir, "Developer", "pasta")]); + }); + + it("matches the whole tree when the typed parent does not exist", async () => { + await expect(searchAbsoluteDirectoryPaths({ homeDir, query: "~/Devel/pas" })).resolves.toEqual([ + path.join(homeDir, "Developer", "pasta"), + path.join(homeDir, "Archive", "Developer", "pasta"), + ]); + }); + + it("returns the typed directory first", async () => { + mkdirSync(path.join(homeDir, "Developer", "pasta-extra")); + + const results = await searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pasta" }); + + expect(results[0]).toBe(path.join(homeDir, "Developer", "pasta")); + }); +}); + describe("relative typed-entry configuration", () => { let tempRoot: string; let workspaceDir: string; diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 049b1011ac..e0443475bc 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -33,6 +33,9 @@ export interface SearchDirectoryEntriesOptions { maxEntriesScanned?: number; confidentResultScanThreshold?: number; respectGitIgnore?: boolean; + // Absolute paths that discovery never walks into. A path the caller types explicitly still + // resolves, and browsing inside one lists a single level at a time. + excludedDiscoveryPaths?: readonly string[]; } interface QueryPlan { @@ -55,6 +58,11 @@ interface RawChildEntry { kind: DirectorySuggestionKind | "symlink"; } +interface WalkStart { + visiblePath: string; + resolvedPath: string; +} + interface TraversedEntry extends ChildEntry { visiblePath: string; depth: number; @@ -138,6 +146,7 @@ export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ ".agents", ".claude", ".codex", + ".cursor", ".github", ".opencode", ".paseo", @@ -174,29 +183,64 @@ export async function searchDirectoryEntries( const gitIgnoredPaths = options.respectGitIgnore ? await loadGitIgnoredPaths(root) : new Set<string>(); - const input = buildSearchInput(options, root, gitIgnoredPaths); + const excludedDiscoveryPaths = await resolveExistingPaths(options.excludedDiscoveryPaths ?? []); + const input = buildSearchInput(options, root, gitIgnoredPaths, excludedDiscoveryPaths); if (!input) return []; const exact = - input.plan.browseExactPath || (input.matchMode === "suffix" && input.plan.isPathQuery) + input.plan.browseExactPath || + (input.plan.isPathQuery && (input.matchMode === "suffix" || resolvesTypedPath(input))) ? await findExactEntry(input) : null; if (exact && input.limit === 1) return [exact]; - const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery; - const browsesAbsoluteParent = input.plan.browseExactPath === true; - const ranked = - browsesRoot || browsesAbsoluteParent ? await searchChildren(input) : await searchTree(input); + const ranked = await searchCandidates(input); const results = sortAndFormat(ranked, input.root, input.pathFormat).slice(0, input.limit); return exact ? [exact, ...results.filter((entry) => !sameEntry(entry, exact))].slice(0, input.limit) : results; } +async function searchCandidates(input: SearchInput): Promise<RankedEntry[]> { + const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery; + const browsesAbsoluteParent = input.plan.browseExactPath === true; + if (browsesRoot || browsesAbsoluteParent) return searchChildren(input); + if (!resolvesTypedPath(input) || !input.plan.parentPart) return searchTree(input); + + // A typed parent such as `~/Developer/pas` anchors the walk under `~/Developer`. A parent that + // does not exist falls back to matching the typed path against the whole tree. + const typedParent = await resolveTypedParent(input); + if (!typedParent) return searchTree(input); + if (isInsideExcludedPath(typedParent.resolvedPath, input)) return searchChildren(input); + return searchTree(input, typedParent); +} + +// Only a rooted query under the rooted policy names a location. Under the slashes policy a query +// with a slash is still a fragment that may match anywhere in the tree. +function resolvesTypedPath(input: SearchInput): boolean { + return input.pathQueryPolicy === "rooted" && input.plan.isPathQuery; +} + +async function resolveTypedParent(input: SearchInput): Promise<WalkStart | null> { + const visiblePath = path.resolve(input.root, input.plan.parentPart); + const resolvedPath = await realpath(visiblePath).catch(() => null); + if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null; + const info = await stat(resolvedPath).catch(() => null); + return info?.isDirectory() ? { visiblePath, resolvedPath } : null; +} + +async function resolveExistingPaths(paths: readonly string[]): Promise<Set<string>> { + const resolved = await Promise.all( + paths.map((candidate) => realpath(path.resolve(candidate)).catch(() => null)), + ); + return new Set(resolved.filter((candidate): candidate is string => candidate !== null)); +} + function buildSearchInput( options: SearchDirectoryEntriesOptions, root: string, gitIgnoredPaths: Set<string>, + excludedDiscoveryPaths: Set<string>, ): SearchInput | null { const includeDirectories = options.includeDirectories ?? true; const includeFiles = options.includeFiles ?? false; @@ -218,6 +262,7 @@ function buildSearchInput( includeDirectories, includeFiles, matchMode: options.matchMode ?? "fuzzy", + pathQueryPolicy: options.pathQueryPolicy ?? "slashes", pathFormat: options.pathFormat, hiddenDirectoryNames: new Set(options.traversableHiddenDirectoryNames ?? []), limit: normalizeLimit(options.limit), @@ -225,6 +270,7 @@ function buildSearchInput( maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED, confidentResultScanThreshold: options.confidentResultScanThreshold, gitIgnoredPaths, + excludedDiscoveryPaths, }; } @@ -252,6 +298,7 @@ interface SearchInput { includeDirectories: boolean; includeFiles: boolean; matchMode: DirectorySuggestionMatchMode; + pathQueryPolicy: PathQueryPolicy; pathFormat: DirectorySuggestionPathFormat; hiddenDirectoryNames: Set<string>; limit: number; @@ -259,6 +306,7 @@ interface SearchInput { maxEntriesScanned: number; confidentResultScanThreshold: number | undefined; gitIgnoredPaths: Set<string>; + excludedDiscoveryPaths: Set<string>; } async function searchChildren(input: SearchInput): Promise<RankedEntry[]> { @@ -266,9 +314,13 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> { const parent = await realpath(visibleParent).catch(() => null); if (!parent || !isPathInsideRoot(input.root, parent)) return []; if (isGitIgnoredPath(parent, input)) return []; + // Browsing inside an excluded path is explicit: the caller typed it, so list what is there. + const discoveryInput = isInsideExcludedPath(parent, input) + ? { ...input, excludedDiscoveryPaths: new Set<string>() } + : input; const entries = await readChildren(parent); return entries.flatMap((entry) => { - if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input)) + if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput)) return []; const candidate: TraversedEntry = { ...entry, @@ -279,17 +331,20 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> { }); } -async function searchTree(input: SearchInput): Promise<RankedEntry[]> { +async function searchTree( + input: SearchInput, + start: WalkStart = { visiblePath: input.root, resolvedPath: input.root }, +): Promise<RankedEntry[]> { if (!(input.maxEntriesScanned > 0)) return []; - const roots = (await readChildren(input.root)).filter((entry) => + const roots = (await readChildren(start.resolvedPath)).filter((entry) => staysInsideRoot(entry, input.root), ); - const visited = new Set<string>([input.root]); + const visited = new Set<string>([start.resolvedPath]); const branches = roots.flatMap((entry) => shouldDiscover(entry, input) ? [ walkBranch( - { ...entry, visiblePath: path.join(input.root, entry.name), depth: 1 }, + { ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 }, input, visited, ), @@ -370,6 +425,7 @@ function staysInsideRoot(entry: ChildEntry, root: string): boolean { function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { if (isNewlyGitIgnored(entry, input)) return false; + if (isNewlyExcluded(entry, input)) return false; if (entry.kind === "file") { return input.includeFiles && !entry.name.startsWith("."); } @@ -386,6 +442,21 @@ function isNewlyGitIgnored(entry: ChildEntry, input: SearchInput): boolean { return input.gitIgnoredPaths.has(entry.resolvedPath); } +// Same reasoning as Git-ignored paths: only a symlink can land inside an excluded path without +// first passing through it by name. +function isNewlyExcluded(entry: ChildEntry, input: SearchInput): boolean { + if (input.excludedDiscoveryPaths.size === 0) return false; + if (entry.viaSymlink) return isInsideExcludedPath(entry.resolvedPath, input); + return input.excludedDiscoveryPaths.has(entry.resolvedPath); +} + +function isInsideExcludedPath(absolutePath: string, input: SearchInput): boolean { + for (const excludedPath of input.excludedDiscoveryPaths) { + if (isPathInsideRoot(excludedPath, absolutePath)) return true; + } + return false; +} + function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean { let candidate = absolutePath; while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) { diff --git a/packages/server/src/utils/git-quoted-path.ts b/packages/server/src/utils/git-quoted-path.ts new file mode 100644 index 0000000000..cb5a9d45eb --- /dev/null +++ b/packages/server/src/utils/git-quoted-path.ts @@ -0,0 +1,34 @@ +const GIT_PATH_ESCAPE_BYTES: Record<string, number> = { + a: 7, + b: 8, + t: 9, + n: 10, + v: 11, + f: 12, + r: 13, + '"': 34, + "\\": 92, +}; + +// core.quotepath=false still quotes control characters, double quotes and backslashes. +export function unquoteGitPath(value: string): string { + if (!value.startsWith('"') || !value.endsWith('"')) return value; + + const encoded = value.slice(1, -1); + const parts: Buffer[] = []; + let offset = 0; + for (const match of encoded.matchAll(/\\([0-7]{1,3}|[abfnrtv"\\])/g)) { + parts.push(Buffer.from(encoded.slice(offset, match.index))); + const escape = match[1]; + let byte: number; + if (/^[0-7]/.test(escape)) { + byte = Number.parseInt(escape, 8); + } else { + byte = GIT_PATH_ESCAPE_BYTES[escape]; + } + parts.push(Buffer.from([byte])); + offset = match.index + match[0].length; + } + parts.push(Buffer.from(encoded.slice(offset))); + return Buffer.concat(parts).toString("utf8"); +} diff --git a/packages/server/src/utils/git-rev-parse-path.test.ts b/packages/server/src/utils/git-rev-parse-path.test.ts new file mode 100644 index 0000000000..7614b7dec8 --- /dev/null +++ b/packages/server/src/utils/git-rev-parse-path.test.ts @@ -0,0 +1,26 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; + +describe("Git path output", () => { + it.each(["\n", "\r\n", ""])("preserves path whitespace with delimiter %j", (delimiter) => { + expect(parseGitRevParsePath(`/repo ${delimiter}`)).toBe("/repo "); + expect(parseGitRevParsePath(` relative path ${delimiter}`)).toBe(" relative path "); + }); + + it.each([ + "", + "\n", + "\r\n", + "--path-format=absolute\n", + "--path-format=absolute\n/repo\n", + "/repo\n/another\n", + "/repo\n\n", + ])("rejects invalid output %j", (output) => { + expect(parseGitRevParsePath(output)).toBeNull(); + }); + + it("resolves a relative path without stripping its trailing space", () => { + expect(resolveGitRevParsePath("/base", "repo \n")).toBe(resolve("/base", "repo ")); + }); +}); diff --git a/packages/server/src/utils/git-rev-parse-path.ts b/packages/server/src/utils/git-rev-parse-path.ts index b799815056..2f027d5020 100644 --- a/packages/server/src/utils/git-rev-parse-path.ts +++ b/packages/server/src/utils/git-rev-parse-path.ts @@ -1,18 +1,9 @@ import { resolve } from "node:path"; export function parseGitRevParsePath(stdout: string): string | null { - const trimmed = stdout.trim(); - if (!trimmed) { - return null; - } - - const lines = trimmed.split(/\r?\n/); - if (lines.length !== 1) { - return null; - } - - const path = lines[0]?.trim() ?? ""; - if (!path || path.startsWith("--")) { + // Remove Git's record delimiter, not whitespace belonging to the path. + const path = stdout.replace(/\r?\n$/, ""); + if (!path || /[\r\n]/.test(path) || path.startsWith("--")) { return null; } diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts index f72f5d82b2..bb279d6c2f 100644 --- a/packages/server/src/utils/path.test.ts +++ b/packages/server/src/utils/path.test.ts @@ -42,6 +42,35 @@ describe("path equivalence", () => { ); }); + test.each([ + { root: "/repo", candidate: "/repo/..notes.txt", inside: true }, + { root: "/repo", candidate: "/repo/..drafts/notes.md", inside: true }, + { root: "/repo", candidate: "/repo/...drafts/notes.md", inside: true }, + { root: "/repo", candidate: "/repo/docs/..notes.txt", inside: true }, + { root: "/repo", candidate: "/repo", inside: true }, + { root: "/repo", candidate: "/repo/../sibling/notes.md", inside: false }, + { root: "/repo", candidate: "/repo/..drafts/../../outside.txt", inside: false }, + { root: "/repo", candidate: "/repo-other/notes.md", inside: false }, + { root: "C:\\Repo", candidate: "c:/repo/..notes.txt", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\..drafts\\notes.md", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\...drafts\\notes.md", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\..\\sibling\\notes.md", inside: false }, + { root: "C:\\Repo", candidate: "C:\\Repo-other\\notes.md", inside: false }, + { root: "C:\\Repo", candidate: "D:\\Repo\\..notes.txt", inside: false }, + { + root: String.raw`\\server\share\repo`, + candidate: String.raw`\\server\share\repo\..drafts\notes.md`, + inside: true, + }, + { + root: String.raw`\\?\C:\Repo`, + candidate: "C:\\Repo\\..notes.txt", + inside: true, + }, + ])("checks named path containment for $candidate", ({ root, candidate, inside }) => { + expect(isPathInsideRoot(root, candidate)).toBe(inside); + }); + test("preserves the casing of Windows relative suffixes", () => { expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD"); expect( diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index 577e48efb4..a0ec908fbb 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -114,10 +114,11 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows); const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate); - if ( - comparableRelative !== "" && - (comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative)) - ) { + const isOutsideRoot = + comparableRelative === ".." || + comparableRelative.startsWith(`..${platformPath.sep}`) || + platformPath.isAbsolute(comparableRelative); + if (isOutsideRoot) { return null; } diff --git a/packages/server/src/utils/spawn.launch-regression.test.ts b/packages/server/src/utils/spawn.launch-regression.test.ts index 847214c624..4943e68990 100644 --- a/packages/server/src/utils/spawn.launch-regression.test.ts +++ b/packages/server/src/utils/spawn.launch-regression.test.ts @@ -126,10 +126,15 @@ async function runFixture(params: { command: string; args: string[]; shell?: boolean; + env?: NodeJS.ProcessEnv; }): Promise<SpawnResult> { const child = spawnProcess(params.command, params.args, { env: { ...process.env, + ...params.env, + // Terminal color settings must not add Node warnings to the argv fixture's stderr. + NO_COLOR: undefined, + FORCE_COLOR: undefined, PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]), }, stdio: ["ignore", "pipe", "pipe"], @@ -216,6 +221,8 @@ async function findAndLaunch(fixture: LaunchFixture): Promise<{ const child = spawnProcess(found, fixture.args, { env: { ...process.env, + NO_COLOR: undefined, + FORCE_COLOR: undefined, PASEO_EXPECTED_ARGV_JSON: fixture.expectedArgvJson, PASEO_ARGV_SLICE_FROM: String(fixture.sliceFrom), }, @@ -331,18 +338,25 @@ describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => { }); describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => { - test("direct launch with a space-containing executable works on this platform", async () => { - const fixture = makeFixture(); + test.each([ + { colors: "inherited", env: {} }, + { colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } }, + ])( + "direct launch with a space-containing executable works with $colors colors", + async ({ env }) => { + const fixture = makeFixture(); - const result = await runFixture({ - command: fixture.fakeDaemonNode, - args: [fixture.assertScript, ...fixture.expectedArgs], - shell: false, - }); + const result = await runFixture({ + command: fixture.fakeDaemonNode, + args: [fixture.assertScript, ...fixture.expectedArgs], + shell: false, + env, + }); - expect(result.error).toBeNull(); - expect(result.code).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout.trim()).toBe("ARGV_OK"); - }); + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }, + ); }); diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 5673c1412a..2b9181bb77 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -265,6 +265,38 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); + it.each([false, true])( + "shows committed changes for an existing branch (already checked out: %s)", + async (alreadyCheckedOut) => { + execFileSync("git", ["checkout", "-b", "feature/existing"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "feature change\n"); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { + cwd: repoDir, + }); + if (!alreadyCheckedOut) { + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + } + + const result = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "existing-feature", + source: { kind: "checkout-branch", branchName: "feature/existing" }, + runSetup: false, + paseoHome, + }); + const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); + const diff = await getCheckoutDiff(result.worktreePath, { mode: "base" }, { paseoHome }); + expect(diff.diff).toContain("+feature change"); + expect(status).toMatchObject({ + isGit: true, + baseRef: "main", + aheadBehind: { ahead: 1, behind: 0 }, + }); + expect(result.comparisonBaseRef).toBe("main"); + }, + ); + it("checks out an existing local branch that is not checked out elsewhere", async () => { execFileSync("git", ["branch", "dev"], { cwd: repoDir }); @@ -284,15 +316,65 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { .trim(); expect(currentBranch).toBe("dev"); + // baseRefName must be the repo's default branch, not the checked-out branch + // itself — otherwise the diff panel compares the branch against itself and + // always shows no changes. const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, - baseRefName: "dev", + baseRefName: "main", changeRequestLookupTarget: { headRef: "dev", localBranchName: "dev" }, }); }); + it("shows committed changes when origin HEAD points to a deleted default branch", async () => { + execFileSync("git", ["branch", "-m", "main", "master"], { cwd: repoDir }); + execFileSync( + "git", + ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"], + { cwd: repoDir }, + ); + execFileSync("git", ["checkout", "-b", "feature/stale-default"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "feature change\n"); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "master"], { cwd: repoDir }); + const created = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "stale-default", + source: { kind: "checkout-branch", branchName: "feature/stale-default" }, + runSetup: false, + paseoHome, + }); + const diff = await getCheckoutDiff(created.worktreePath, { mode: "base" }, { paseoHome }); + expect(diff.diff).toContain("+feature change"); + expect(created.comparisonBaseRef).toBe("master"); + }); + + it("falls back to the currently checked out branch when no default branch can be resolved", async () => { + // No origin remote and no local main/master — resolveRepositoryDefaultBranch + // can't find a default branch, so the fix must fall back to whatever is + // currently checked out in the main repo (here: "develop"). + execFileSync("git", ["branch", "-m", "main", "develop"], { cwd: repoDir }); + execFileSync("git", ["branch", "feature-x"], { cwd: repoDir }); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "feature-x-worktree", + source: { kind: "checkout-branch", branchName: "feature-x" }, + runSetup: true, + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(true); + const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" }); + }); + it("checks out an existing local branch whose name contains uppercase letters and dots", async () => { execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir }); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 479d4cfeab..82505dc800 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -10,11 +10,13 @@ import { type WorktreeConfig, } from "./worktree"; import { execFileSync } from "child_process"; +import { rm } from "node:fs/promises"; import { mkdtempSync, mkdirSync, rmSync, existsSync, + readFileSync, realpathSync, symlinkSync, writeFileSync, @@ -89,10 +91,68 @@ describe("paseo worktree manager", () => { }); }); - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); + it.each([ + { existing: [], preferred: "main/patch-1", expected: "main-1/patch-1" }, + { existing: ["main-1"], preferred: "main/patch-1", expected: "main-2/patch-1" }, + { + existing: ["main-1/patch-1"], + preferred: "main/patch-1", + expected: "main-1/patch-1-2", + }, + { + existing: ["bob/patch-1/child"], + preferred: "bob/patch-1", + expected: "bob/patch-1-1", + }, + ])( + "checks out a fork when $preferred conflicts with existing branch paths", + async ({ existing, preferred, expected }) => { + const git = (...args: string[]) => + execFileSync("git", args, { cwd: repoDir, encoding: "utf8", stdio: "pipe" }).trim(); + git("config", "core.autocrlf", "false"); + const baseHead = git("rev-parse", "main"); + for (const branch of existing) git("branch", branch); + git("checkout", "-b", "fixture-pr"); + writeFileSync(join(repoDir, "file.txt"), "from-pr\n"); + git("add", "file.txt"); + git("-c", "commit.gpgsign=false", "commit", "-m", "fork patch"); + const prHead = git("rev-parse", "HEAD"); + git("checkout", "main"); + const remote = join(tempDir, "origin.git"); + git("clone", "--bare", repoDir, remote); + git("remote", "add", "origin", remote); + git(`--git-dir=${remote}`, "update-ref", "refs/pull/1/head", prHead); + git("pack-refs", "--all"); + + const result = await createWorktreePrimitive({ + cwd: repoDir, + paseoHome, + worktreeSlug: "fork-review", + runSetup: false, + source: { + kind: "checkout-change-request", + forge: "gitea", + changeRequestNumber: 1, + headRef: "patch-1", + localBranchName: preferred, + baseRefName: "main", + checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1/head" }], + }, + }); + + expect(result.branchName).toBe(expected); + expect(git("rev-parse", result.branchName)).toBe(prHead); + expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n"); + for (const branch of ["main", ...existing]) { + expect(git("rev-parse", branch)).toBe(baseHead); + } + }, + ); + it("treats a worktree as paseo-owned even when its .git admin is missing", async () => { const created = await createLegacyWorktreeForTest({ branchName: "orphan-admin-branch", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index f7f28205e0..39b64d8137 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -39,6 +39,7 @@ import { writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; import { runGitCommand } from "./run-git-command.js"; +import { getCurrentBranch, resolveRepositoryDefaultBranch } from "./checkout-git.js"; import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; @@ -1279,10 +1280,7 @@ export const createWorktree = async ({ return { branchName: sourcePlan.branchName, worktreePath, - comparisonBaseRef: - source.kind === "checkout-branch" - ? null - : (sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName), + comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName, }; }; @@ -1399,11 +1397,12 @@ async function resolveWorktreeSourcePlan({ case "checkout-branch": { await validateGitBranchName(cwd, source.branchName); await ensureLocalBranch(cwd, source.branchName); + const baseRef = await resolveCheckoutBranchBaseRef(cwd, source.branchName); if (await isBranchCheckedOut(cwd, source.branchName)) { const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName); return { branchName, - metadataBaseRefName: source.branchName, + metadataBaseRefName: baseRef, changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ headRef: branchName, localBranchName: branchName, @@ -1414,7 +1413,7 @@ async function resolveWorktreeSourcePlan({ return { branchName: source.branchName, - metadataBaseRefName: source.branchName, + metadataBaseRefName: baseRef, changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ headRef: source.branchName, localBranchName: source.branchName, @@ -1669,6 +1668,21 @@ function normalizeRequiredBaseBranch(baseBranch: string): string { return normalizedBaseBranch; } +// The checked-out branch itself isn't a base to diff against — resolve the +// repository's default branch so the diff panel has a real comparison target. +// If the default branch can't be determined (no origin/HEAD, no local +// main/master — e.g. a local-only repo based on "develop"), fall back to +// whichever branch is currently checked out in the main repo, since that's +// almost always the branch this one was intended to be compared against. +async function resolveCheckoutBranchBaseRef(cwd: string, branchName: string): Promise<string> { + const defaultBranch = await resolveRepositoryDefaultBranch(cwd); + if (defaultBranch) { + return defaultBranch; + } + const currentBranch = await getCurrentBranch(cwd); + return currentBranch && currentBranch !== branchName ? currentBranch : branchName; +} + async function resolveBaseBranchForWorktree( cwd: string, requestedBaseBranch: string, @@ -1764,13 +1778,30 @@ async function localBranchExists(cwd: string, branchName: string): Promise<boole } async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise<string> { - let newBranchName = candidateBranch; + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname:strip=2)", "refs/heads/"], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + const existingBranches = new Set(stdout.trim().split("\n").filter(Boolean)); + const candidateSegments = candidateBranch.split("/"); + const segments = [...candidateSegments]; let suffix = 1; - while (await localBranchExists(cwd, newBranchName)) { - newBranchName = `${candidateBranch}-${suffix}`; - suffix++; + while (true) { + const branchName = segments.join("/"); + const parentConflict = segments.findIndex((_, index) => + existingBranches.has(segments.slice(0, index + 1).join("/")), + ); + const childConflict = [...existingBranches].some((branch) => + branch.startsWith(`${branchName}/`), + ); + if (parentConflict === -1 && !childConflict) { + return branchName; + } + // Git refs form a namespace: a branch cannot also be another branch's + // parent directory. Rename the conflicting segment, preserving the rest. + const conflictIndex = parentConflict === -1 ? segments.length - 1 : parentConflict; + segments[conflictIndex] = `${candidateSegments[conflictIndex]}-${suffix++}`; } - return newBranchName; } async function isBranchCheckedOut(cwd: string, branchName: string): Promise<boolean> { diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 4b25764445..c80345c79e 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -19,9 +19,9 @@ export default defineConfig({ setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")], pool: "forks", fileParallelism: false, - // Windows runners intermittently starve subprocess-heavy Git tests at the - // default worker count, leaving child processes alive past their deadlines. - maxWorkers: process.platform === "win32" ? 2 : undefined, + // Even two concurrent Git-heavy suites can exhaust Windows test deadlines. + // Run files serially so their subprocesses do not compete across suites. + maxWorkers: process.platform === "win32" ? 1 : undefined, exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"], }, }); diff --git a/packages/website/README.md b/packages/website/README.md new file mode 100644 index 0000000000..5a938ca2f4 --- /dev/null +++ b/packages/website/README.md @@ -0,0 +1,22 @@ +# Paseo website + +## Plugin install counts + +Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or +`?intent=install`, using `/api/plugins/resolve/<owner>/<slug>` or +`/plugins/<owner>/<slug>.json`. Ordinary resolves do not count. These numbers +measure install intent, not successful artifact installation or npm downloads. + +The worker requires Cloudflare's `CF-Connecting-IP` header to count a report. +It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key +with a one hour TTL, never the raw IP. An existing key skips the count; repeats +do not extend the hour. Missing or empty IP headers do not count. + +Accepted reports increment the existing all-time total and UTC daily buckets in +`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day +and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped +to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts) +reads that endpoint into the published plugin details and index; the website +reads windowed counts from KV through its five-minute cache. Existing counts +remain intact. The KV guard is advisory: concurrent requests and KV propagation +are not serialized. diff --git a/packages/website/e2e/overview.fixture.json b/packages/website/e2e/overview.fixture.json new file mode 100644 index 0000000000..2553fe83c6 --- /dev/null +++ b/packages/website/e2e/overview.fixture.json @@ -0,0 +1,32 @@ +{ + "id": "security/overview", + "name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">", + "description": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">", + "artifact": { + "kind": "npm", + "package": "@alhassanaraouf/paseo-base2tone-theme", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz", + "integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg==" + }, + "license": "MIT", + "repository": { + "url": "javascript:alert(1)" + }, + "categories": ["themes"], + "author": { + "github": "security", + "name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">" + }, + "media": [ + "data:image/svg+xml,<svg onload=\"globalThis.__overviewExecuted=1\"/>", + "http://example.com/image.png" + ], + "submittedAt": "2026-09-24T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-24T17:12:05.467Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "## Overview security\n\n</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">", + "icon": "data:image/png;base64,AAAA" +} diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 92a48d0c84..6eb9f7d816 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -1,4 +1,6 @@ -import { expect, test, type Page } from "playwright/test"; +import securityPlugin from "./overview.fixture.json" with { type: "json" }; +import { expect, test, type Page, type Locator } from "playwright/test"; +import registry from "./registry.fixture.json" with { type: "json" }; import { CATEGORIES } from "../src/plugins/categories"; async function openPlugins(page: Page) { @@ -6,6 +8,58 @@ async function openPlugins(page: Page) { await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); } +function pluginCards(page: Page, section: string): Locator { + return page + .getByRole("region", { name: section }) + .getByRole("link") + .filter({ hasNotText: /^See all$/ }); +} + +test("scrolls mobile plugin carousels without widening the page", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openPlugins(page); + await expectPageWithinViewport(page); + await scrollPluginCarousel(page, "Featured"); + await scrollPluginCarousel(page, "What’s new"); + await expectPageWithinViewport(page); +}); + +// Graphite's fixture description ends in raw README HTML with an unbreakable srcset URL. +test("keeps browse grids within a phone's width when a description has an unbreakable word", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/plugins/all"); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expectPageWithinViewport(page); +}); + +async function expectPageWithinViewport(page: Page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + await page.evaluate(() => document.documentElement.clientWidth), + ); + await page.evaluate(() => window.scrollTo({ left: 1000, behavior: "instant" })); + expect(await page.evaluate(() => window.scrollX)).toBe(0); +} + +async function scrollPluginCarousel(page: Page, section: string) { + const cards = pluginCards(page, section); + // The cards share a non-semantic scrolling container. + const strip = cards.first().locator(".."); + await strip.evaluate((element) => + element.scrollIntoView({ block: "center", behavior: "instant" }), + ); + expect(await strip.evaluate((element) => element.scrollWidth)).toBeGreaterThan( + await strip.evaluate((element) => element.clientWidth), + ); + await expect(cards.last()).not.toBeInViewport(); + await strip.evaluate((element) => + element.scrollTo({ left: element.scrollWidth, behavior: "instant" }), + ); + await expect(cards.last()).toBeInViewport({ ratio: 1 }); + await expect(cards.first()).not.toBeInViewport(); +} + test("browses from the directory into a category, a plugin, and its author", async ({ page, context, @@ -32,18 +86,23 @@ test("browses from the directory into a category, a plugin, and its author", asy await page.getByRole("link", { name: /Fresh Worktrees/ }).click(); await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/); await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1); - await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1); + await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toHaveCount(1); await expect( - page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), ).toBeVisible(); await page.getByRole("button", { name: "Copy to clipboard" }).click(); await expect .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied)) - .toBe("paseo plugin install omercnet/fresh-worktrees"); + .toBe("paseo plugin add omercnet/fresh-worktrees"); await expect(page.getByRole("link", { name: "Git", exact: true })).toHaveAttribute( "href", "/plugins/category/git", ); + // The source is a folder in a monorepo; issues are filed on the repository. + await expect(page.getByRole("link", { name: "Report a bug" })).toHaveAttribute( + "href", + "https://github.com/omercnet/paseo-plugins/issues/new", + ); await page.getByRole("link", { name: "Omer Cohen" }).click(); await expect(page).toHaveURL(/\/plugins\/omercnet$/); @@ -52,7 +111,9 @@ test("browses from the directory into a category, a plugin, and its author", asy await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0); }); -test("ranks plugins on browse pages by installs in a window or by newest", async ({ page }) => { +test("ranks plugins on browse pages by installs in a window, by newest, or by latest update", async ({ + page, +}) => { await page.goto("/plugins/all"); await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); await expect(page.getByRole("link", { name: "Most installed" })).toHaveAttribute( @@ -72,13 +133,28 @@ test("ranks plugins on browse pages by installs in a window or by newest", async await expect(page.getByRole("main").getByRole("link", { name: /Base2Tone/ })).toContainText( "ago", ); + + await page.getByRole("link", { name: "Recently updated" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(/^All plugins\s*2$/); + await expect(page.getByRole("main").getByRole("link", { name: /Defer|OMP/ })).toHaveText([ + /Defer/, + /OMP/, + ]); +}); + +test("shows each plugin's author with their avatar on browse cards", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /Defer/ }); + await expect(card).toContainText("Tom Gringauz"); + await expect(card.locator('img[src="https://github.com/tomgrin10.png?size=80"]')).toHaveCount(1); }); test("filters by search and clears to all plugins", async ({ page }) => { await page.goto("/"); await openPlugins(page); - await searchPlugins(page, "graphite"); + await submitSearch(page, "graphite"); await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); await expect( page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), @@ -96,7 +172,7 @@ test("filters by search and clears to all plugins", async ({ page }) => { test("keeps the directory's ranking window when searching", async ({ page }) => { await page.goto("/plugins?window=month"); - await searchPlugins(page, "graphite"); + await submitSearch(page, "graphite"); await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( "aria-current", @@ -104,6 +180,35 @@ test("keeps the directory's ranking window when searching", async ({ page }) => ); }); +test("searches for a term typed before the page finished loading", async ({ page }) => { + const loadScripts = await holdScripts(page); + await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" }); + await searchPlugins(page, "graphite"); + await loadScripts(); + await expect(page.getByRole("button", { name: "Clear search" })).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); +}); + +test("filters browse results for a term typed before the page finished loading", async ({ + page, +}) => { + await page.goto("/"); + const loadScripts = await holdScripts(page); + await page.goto("/plugins/all", { waitUntil: "domcontentloaded" }); + const entries = await historyLength(page); + await searchPlugins(page, "gra"); + await loadScripts(); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + expect(await historyLength(page)).toBe(entries + 1); + + await page.goBack(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); +}); + test("clears the search with the clear button", async ({ page }) => { await page.goto("/plugins/all"); const searchbox = page.getByRole("searchbox", { name: "Search plugins" }); @@ -120,13 +225,52 @@ test("clears the search with the clear button", async ({ page }) => { await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); }); -test("replaces history while typing a search", async ({ page }) => { +test("searches from the directory on submit, and Back returns to the directory", async ({ + page, +}) => { + await page.goto("/"); + await openPlugins(page); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins$/); + expect(await historyLength(page)).toBe(entries); + + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + expect(await historyLength(page)).toBe(entries + 1); + + await page.goBack(); + await expect(page).toHaveURL(/\/plugins$/); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +}); + +test("filters browse results as you type in one history entry", async ({ page }) => { await page.goto("/"); await page.goto("/plugins/all"); - await searchPlugins(page, "graphite"); - await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + expect(await historyLength(page)).toBe(entries + 1); + await page.goBack(); - await expect(page).toHaveURL(/\/$/); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue(""); + + await page.goForward(); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); + + await page.reload(); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); }); test("keeps old category links working", async ({ page }) => { @@ -165,9 +309,9 @@ test.describe("search engine visits without JavaScript", () => { expect(response?.status()).toBe(200); expect(response?.headers()["cache-control"]).toBe("private, no-store"); expect(response?.headers()["x-robots-tag"]).toBeUndefined(); - await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); + await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toBeVisible(); await expect( - page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), ).toBeVisible(); await expectPageMetadata( page, @@ -256,10 +400,37 @@ test.describe("search engine visits without JavaScript", () => { }); }); +/** Holds the page's scripts so typing lands before hydration; the returned function loads them. */ +async function holdScripts(page: Page): Promise<() => Promise<void>> { + let release!: () => void; + const held = new Promise<void>((resolve) => (release = resolve)); + await page.route(/\.js($|\?)/, async (route) => { + await held; + await route.continue(); + }); + return async () => { + release(); + await page.waitForLoadState("load"); + }; +} + async function searchPlugins(page: Page, term: string) { await page.getByRole("searchbox", { name: "Search plugins" }).fill(term); } +async function submitSearch(page: Page, term: string) { + await searchPlugins(page, term); + await page.keyboard.press("Enter"); +} + +async function typeSearch(page: Page, term: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).pressSequentially(term); +} + +async function historyLength(page: Page): Promise<number> { + return page.evaluate(() => window.history.length); +} + async function expectPageMetadata(page: Page, title: string, path: string) { await expect(page).toHaveTitle(title); await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( @@ -284,19 +455,18 @@ async function expectPageMetadata(page: Page, title: string, path: string) { ); } -test("keeps the directory unlinked until the coordinated announcement", async ({ page }) => { +test("links the directory from the site navigation", async ({ page }) => { await page.goto("/"); await expect( - page.getByRole("banner").getByRole("link", { name: "Plugins", exact: true }), - ).toHaveCount(0); + page.getByRole("navigation").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveAttribute("href", "/plugins"); await expect( page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), - ).toHaveCount(0); - await expect(page.getByRole("link", { name: "Community plugins" })).toHaveAttribute( + ).toHaveAttribute("href", "/plugins"); + await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute( "href", - "https://paseo.cafe", + "/plugins", ); - await expect(page.locator('a[href="/plugins"]')).toHaveCount(0); const response = await page.goto("/plugins"); expect(response?.status()).toBe(200); await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); @@ -306,6 +476,159 @@ test("keeps the directory unlinked until the coordinated announcement", async ({ test.describe("registry fixture layout", () => { test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture"); + test("uses density-aware card thumbnails and keeps detail screenshots original", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!; + const source = plugin.media[0]; + await openPlugins(page); + await expectThumbnailCard( + page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }), + source, + plugin.id, + ); + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /Base2Tone/ }); + await expectThumbnailCard(card, source, plugin.id); + await card.click(); + await expect( + page.getByRole("img", { name: "Base2Tone screenshot 1", exact: true }), + ).toHaveAttribute("src", source); + }); + + test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!; + const [firstImage, video] = plugin.media; + await page.goto(`/plugins/${plugin.id}`); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + await expect(tiles).toHaveCount(3); + const layout = await tiles.evaluateAll(galleryTiles); + expect(layout.map((tile) => tile.href)).toEqual(plugin.media); + expect(new Set(layout.map((tile) => tile.size)).size).toBe(1); + const preview = tiles.nth(1).locator("video"); + await expect(preview).toHaveAttribute("preload", "metadata"); + await expect(preview).not.toHaveAttribute("controls"); + await expect(preview).not.toHaveAttribute("autoplay"); + expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true); + + const viewer = page.getByRole("dialog"); + await tiles.nth(1).click(); + const playing = viewer.getByLabel("Dracula video 2"); + await expect(playing).toHaveAttribute("src", video); + await expect(playing).toHaveAttribute("controls", ""); + await expect.poll(() => playing.evaluate(isPaused)).toBe(false); + // The fixture video is 320x240, so it has to scale up to fill the viewer. + await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + await expect(page.locator("dialog video")).toHaveCount(0); + + await tiles.first().click(); + await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute( + "src", + firstImage, + ); + await viewer.getByRole("button", { name: "Close" }).click(); + await expect(viewer).toBeHidden(); + expect(page.context().pages()).toHaveLength(1); + + const newTab = page.context().waitForEvent("page"); + await tiles.first().click({ modifiers: ["ControlOrMeta"] }); + await expect(await newTab).toHaveURL(firstImage); + await expect(viewer).toBeHidden(); + }); + + test("peeks the next gallery tile at the strip's edge, and fills the row with two", async ({ + page, + }) => { + // Wide screens show two tiles and part of the third; phones show one and part of the second. + for (const [width, expected] of [ + [1280, [1, 1, "peeks"]], + [375, [1, "peeks", 0]], + ] as const) { + await page.setViewportSize({ width, height: 720 }); + await page.goto("/plugins/omercnet/dracula"); + const three = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const shares = await three.evaluateAll(visibleShares); + expect(shares.map((share) => (share > 0.1 && share < 0.5 ? "peeks" : share))).toEqual( + expected, + ); + } + + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/plugins/gpambrozio/herald"); + const two = page.getByRole("link", { name: /^Herald screenshot \d$/ }); + expect(await two.evaluateAll(visibleShares)).toEqual([1, 1]); + }); + + test("steps through the viewer with arrow keys and buttons, wrapping at the ends", async ({ + page, + }) => { + await page.goto("/plugins/omercnet/dracula"); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const viewer = page.getByRole("dialog"); + const shows = (name: string) => + expect( + viewer.getByRole("img", { name, exact: true }).or(viewer.getByLabel(name, { exact: true })), + ).toBeVisible(); + + await tiles.nth(1).click(); + await expect.poll(() => viewer.getByLabel("Dracula video 2").evaluate(isPaused)).toBe(false); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 3"); + await expect(page.locator("dialog video")).toHaveCount(0); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 1"); + await viewer.getByRole("img").click(); + await page.keyboard.press("ArrowLeft"); + await shows("Dracula screenshot 3"); + + await viewer.getByRole("button", { name: "Previous" }).click(); + await shows("Dracula video 2"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 3"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 1"); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + }); + + test("keeps a video that fails to load visible in the viewer", async ({ page }) => { + const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!; + await page.route(plugin.media[0], (route) => route.abort()); + await page.goto(`/plugins/${plugin.id}`); + await page.getByRole("link", { name: "launchd Jobs video 1" }).click(); + const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1"); + await expect(video).toBeVisible(); + await expect(video).toHaveCSS("opacity", "1"); + await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0); + }); + + test("shows the plugin tile on cards when its media has no image", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ }); + // The only image is the author's avatar. + await expect(card.locator("img, video")).toHaveCount(1); + await expect(card.locator('img[src^="https://github.com/gpambrozio.png"]')).toHaveCount(1); + await card.click(); + await expect( + page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"), + ).toHaveAttribute( + "src", + registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0], + ); + }); + + test("features listed plugins above What's new in the registry's order", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); + const featured = page.getByRole("region", { name: "Featured" }); + await expect(featured).toContainText("A selection of hand picked plugins"); + await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]); + }); + test("lists the nine categories in order with counts, and the newest plugins first", async ({ page, }) => { @@ -315,8 +638,145 @@ test.describe("registry fixture layout", () => { CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)), ); await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0"); - await expect( - page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }), - ).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]); + await expect(pluginCards(page, "What’s new")).toHaveText([ + /Base2Tone/, + /Sayr/, + /PromptKit/, + /Defer/, + ]); + }); + + test("lists only plugins updated since they were listed under Recently updated", async ({ + page, + }) => { + await openPlugins(page); + await expect(pluginCards(page, "Recently updated")).toHaveText([/Defer/, /OMP/]); + await page + .getByRole("region", { name: "Recently updated" }) + .getByRole("link", { name: "See all" }) + .click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/); }); }); + +function galleryTiles(links: Element[]) { + return links.map((link) => ({ + href: link.getAttribute("href"), + size: `${link.clientWidth}x${link.clientHeight}`, + })); +} + +/** How much of each tile's width the gallery strip shows, rounded to hundredths. */ +function visibleShares(links: Element[]) { + const strip = links[0].parentElement!.getBoundingClientRect(); + return links.map((link) => { + const tile = link.getBoundingClientRect(); + const shown = Math.min(tile.right, strip.right) - Math.max(tile.left, strip.left); + return Math.round((Math.max(shown, 0) / tile.width) * 100) / 100; + }); +} + +/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */ +function viewerFill(media: HTMLElement) { + const box = media.getBoundingClientRect(); + return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85)); +} + +function isPaused(video: HTMLVideoElement) { + return video.paused; +} + +async function expectThumbnailCard(card: Locator, source: string, id: string) { + // Card screenshots are decorative and hidden from the accessibility tree. + const image = card.locator("img").first(); + const path = (width: number) => + `/plugins/thumb/${width}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`; + await expect(image).toHaveAttribute("src", path(592)); + await expect(image).toHaveAttribute("srcset", `${path(592)} 1x, ${path(1184)} 2x`); + await expect(image).toHaveAttribute("loading", "lazy"); + await expect(image).toHaveAttribute("decoding", "async"); + await image.scrollIntoViewIfNeeded(); + await expect + .poll( + () => + image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0), + { timeout: 15_000 }, + ) + .toBe(true); + const box = await image.evaluate((element) => { + const rect = element.parentElement!.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(box.width).toBeGreaterThan(0); + expect(box.width / box.height).toBeCloseTo(1.6, 2); +} + +test("keeps author content inert through SSR and hydration", async ({ + page, + request, +}, testInfo) => { + await verifyPluginOverviewResponse(request); + await openUntrustedPlugin(page); + await expectPluginOverviewSafe(page); + await page.reload(); + await expectPluginOverviewSafe(page); + await testInfo.attach("plugin-overview", { + body: await page.screenshot(), + contentType: "image/png", + }); +}); + +async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) { + const response = await request.get("/plugins/security/overview"); + expect(response.status()).toBe(200); + const html = await response.text(); + expect(html).not.toContain("<script>globalThis.__overviewExecuted=1</script>"); + expect(html).toContain("\\x3C/script>"); +} + +async function openUntrustedPlugin(page: Page) { + await page.goto("/plugins/security/overview"); + await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible(); +} + +async function expectPluginOverviewSafe(page: Page) { + expect( + await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted), + ).toBeUndefined(); + // Security assertions inspect every emitted attribute, including inaccessible injected elements. + const violations = await page.locator(".docs-prose").evaluate((root) => { + const bad: string[] = []; + for (const element of root.querySelectorAll("*")) { + if ( + ["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes( + element.tagName, + ) + ) + bad.push(element.tagName); + for (const attr of element.attributes) { + if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name); + if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://")) + bad.push(attr.value); + } + if ( + element.tagName === "A" && + (element.getAttribute("rel") !== "noopener noreferrer nofollow" || + element.getAttribute("target") !== "_blank") + ) + bad.push("unsafe anchor"); + } + return bad; + }); + expect(violations).toEqual([]); + await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Report a bug" })).toHaveCount(0); + await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name); + await expect( + page.getByRole("link", { name: securityPlugin.author.name, exact: true }), + ).toHaveAttribute("href", "/plugins/security"); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + securityPlugin.description, + ); +} diff --git a/packages/website/e2e/registry-server.mjs b/packages/website/e2e/registry-server.mjs index 211fee9f38..228f3dd859 100644 --- a/packages/website/e2e/registry-server.mjs +++ b/packages/website/e2e/registry-server.mjs @@ -1,6 +1,15 @@ import { createServer } from "node:http"; import registry from "./registry.fixture.json" with { type: "json" }; +import securityPlugin from "./overview.fixture.json" with { type: "json" }; + +import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" }; +securityPlugin.readme += + "\n\n" + + overviewCorpus + .map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```") + .join("\n\n"); + // Serve the published registry contract so SSR and browser navigation use the same data. createServer((request, response) => { response.setHeader("Content-Type", "application/json"); @@ -8,7 +17,9 @@ createServer((request, response) => { response.end(JSON.stringify(registry)); return; } - const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`); + const plugin = [securityPlugin, ...registry.plugins].find( + (entry) => request.url === `/plugins/${entry.id}.json`, + ); response.statusCode = plugin ? 200 : 404; response.end(JSON.stringify(plugin ?? { error: "Plugin not found" })); }).listen(8188, "127.0.0.1"); diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json index 6b75599d3d..4ad4eabf1b 100644 --- a/packages/website/e2e/registry.fixture.json +++ b/packages/website/e2e/registry.fixture.json @@ -5,6 +5,12 @@ "url": "https://plugins.paseo.sh" }, "generatedAt": "2026-10-04T15:55:47.882Z", + "featured": [ + "omercnet/dracula", + "gpambrozio/herald", + "acme/not-published", + "gpambrozio/launchd-jobs" + ], "categories": [ { "slug": "daemon-management", @@ -74,13 +80,13 @@ "name": "alhassanaraouf", "github": "alhassanaraouf" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" ], "submittedAt": "2026-09-24T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-24T17:12:05.467Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-24T00:00:00.000Z", + "publishedAt": "2026-09-24T00:00:00.000Z", + "updatedAt": "2026-09-24T00:00:00.000Z", "installs": 0, "readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md." }, @@ -106,14 +112,14 @@ "name": "Tom Lundy", "github": "dorasto" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png", "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T10:39:28.935Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", "installs": 0, "readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks" }, @@ -139,14 +145,14 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T17:46:20.500Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions" }, @@ -172,14 +178,14 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T19:03:31.943Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel" }, @@ -205,13 +211,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ - "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png" + "media": [ + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T17:56:41.061Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel" }, @@ -237,13 +243,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T18:03:14.979Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter" }, @@ -269,11 +275,11 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T22:25:58.192Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it" }, @@ -298,14 +304,14 @@ "name": "hungcuong9125", "github": "hungcuong9125" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png", "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-30T02:57:56.473Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", "installs": 0, "readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer." }, @@ -331,14 +337,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:35.639Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster." }, @@ -364,14 +370,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:29.149Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace." }, @@ -397,14 +403,15 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png", + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:18.185Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo." }, @@ -430,14 +437,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:06.720Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-18T00:00:00.000Z", + "publishedAt": "2026-09-18T00:00:00.000Z", + "updatedAt": "2026-09-18T00:00:00.000Z", "installs": 0, "readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees" }, @@ -463,14 +470,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:36.267Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-10-03T09:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-10-03T09:00:00.000Z", "installs": 0, "readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling." }, @@ -496,11 +503,11 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:04.327Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces." }, @@ -526,14 +533,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:38:48.289Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-18T00:00:00.000Z", + "publishedAt": "2026-09-18T00:00:00.000Z", + "updatedAt": "2026-09-18T00:00:00.000Z", "installs": 0, "readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client." }, @@ -559,11 +566,11 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:15.719Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host." }, @@ -588,20 +595,20 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T09:59:16.553Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-10-03T15:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-10-03T15:00:00.000Z", "installs": 0, "readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset." }, { "id": "tomgrin10/graphite", "name": "Graphite", - "description": "Workspace-native Graphite stack status and PR action radar for Paseo.", + "description": "Workspace-native Graphite stack status and PR action radar for Paseo. <p align=\"center\"> <picture> <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/xpufx/paseo/main/plugins/slash/docs/screenshots/slash-logo-for-dark-bg.png\"> <source media=\"(prefers-color-scheme: light)\" srcset", "artifact": { "kind": "npm", "package": "paseo-graphite", @@ -619,11 +626,11 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-23T10:36:50.112Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", "installs": 0, "readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo." } diff --git a/packages/website/e2e/sponsors.spec.ts b/packages/website/e2e/sponsors.spec.ts new file mode 100644 index 0000000000..7b42316d54 --- /dev/null +++ b/packages/website/e2e/sponsors.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "playwright/test"; + +const PAGES = ["/", "/docs/connectivity", "/changelog"]; + +for (const path of PAGES) { + test(`${path} shows the sponsors and invites the next one`, async ({ page }) => { + await page.goto(path); + + const sponsors = page.getByRole("region", { name: "Sponsors", exact: true }); + await expect(sponsors.getByRole("link", { name: "Hoplite" })).toHaveAttribute( + "href", + "https://hoplite.sh/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor", + ); + await expect(sponsors.getByText("Cloud coding agents", { exact: true })).toBeVisible(); + await expect(sponsors.getByRole("link", { name: "Become a sponsor" })).toHaveAttribute( + "href", + "/sponsor#spot", + ); + }); +} diff --git a/packages/website/package.json b/packages/website/package.json index 3176fd25d5..1677b3c0fc 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.11.0-beta.5", + "version": "0.11.2", "private": true, "type": "module", "scripts": { @@ -38,6 +38,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", diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index c1cfbb4b6c..0ba1cdbc7f 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -1,188 +1,270 @@ { - "$ref": "#/definitions/PaseoConfigV1", - "definitions": { - "PaseoConfigV1": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "type": "number", + "const": 1 + }, + "daemon": { "type": "object", "properties": { - "$schema": { + "listen": { "type": "string" }, - "version": { - "type": "number", - "const": 1 + "hostnames": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, - "daemon": { + "allowedHosts": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "trustedProxies": { + "description": "Express trusted proxy setting for X-Forwarded-* headers. Defaults to [\"loopback\"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.", + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mcp": { "type": "object", "properties": { + "enabled": { + "type": "boolean" + }, + "injectIntoAgents": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "browserTools": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "git": { + "type": "object", + "properties": { + "maxProcessesPerSecond": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxProcessConcurrency": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "autoArchiveAfterMerge": { + "type": "boolean" + }, + "enableTerminalAgentHooks": { + "type": "boolean" + }, + "appendSystemPrompt": { + "type": "string" + }, + "terminalProfiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "type": "string" + } + }, + "required": ["id", "name", "command"], + "additionalProperties": {} + } + }, + "agentProfiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "color": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modeId": { + "type": "string" + }, + "thinkingOptionId": { + "type": "string" + }, + "featureValues": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "notes": { + "type": "string" + } + }, + "required": ["id", "name", "provider"], + "additionalProperties": {} + } + }, + "cors": { + "type": "object", + "properties": { + "allowedOrigins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "relay": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + }, + "publicEndpoint": { + "type": "string" + }, + "useTls": { + "type": "boolean" + }, + "publicUseTls": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "serviceProxy": { + "type": "object", + "properties": { + "enabled": { + "description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + "type": "boolean" + }, "listen": { + "description": "Optional service-only listener address. Presence enables the standalone service proxy listener.", "type": "string" }, - "hostnames": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "allowedHosts": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "trustedProxies": { - "description": "Express trusted proxy setting for X-Forwarded-* headers. Defaults to [\"loopback\"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.", - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "mcp": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "injectIntoAgents": { - "type": "boolean" - } - }, - "additionalProperties": true - }, - "git": { - "type": "object", - "properties": { - "maxProcessesPerSecond": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "maxProcessConcurrency": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false - }, - "autoArchiveAfterMerge": { - "type": "boolean" - }, - "enableTerminalAgentHooks": { - "type": "boolean" - }, - "appendSystemPrompt": { - "type": "string" - }, - "cors": { - "type": "object", - "properties": { - "allowedOrigins": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "relay": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "endpoint": { - "type": "string" - }, - "publicEndpoint": { - "type": "string" - }, - "useTls": { - "type": "boolean" - }, - "publicUseTls": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "serviceProxy": { - "type": "object", - "properties": { - "enabled": { - "description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", - "type": "boolean" - }, - "listen": { - "description": "Optional service-only listener address. Presence enables the standalone service proxy listener.", - "type": "string" - }, - "publicBaseUrl": { - "description": "Optional public base URL for service host aliases.", - "type": "string", - "format": "uri" - } - }, - "additionalProperties": false - }, - "auth": { - "type": "object", - "properties": { - "password": { - "type": "string", - "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" - } - }, - "additionalProperties": false + "publicBaseUrl": { + "description": "Optional public base URL for service host aliases.", + "type": "string", + "format": "uri" } }, "additionalProperties": false }, - "app": { + "auth": { "type": "object", "properties": { - "baseUrl": { - "type": "string" + "password": { + "type": "string", + "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" } }, "additionalProperties": false - }, - "worktrees": { + } + }, + "additionalProperties": false + }, + "app": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + } + }, + "additionalProperties": false + }, + "providers": { + "type": "object", + "properties": { + "openai": { "type": "object", "properties": { - "root": { + "apiKey": { "type": "string", "minLength": 1 - } - }, - "additionalProperties": false - }, - "providers": { - "type": "object", - "properties": { - "openai": { + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "stt": { "type": "object", "properties": { "apiKey": { @@ -192,42 +274,18 @@ "baseUrl": { "type": "string", "minLength": 1 - }, - "stt": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false } }, "additionalProperties": false }, - "local": { + "tts": { "type": "object", "properties": { - "modelsDir": { + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { "type": "string", "minLength": 1 } @@ -237,256 +295,466 @@ }, "additionalProperties": false }, - "agents": { + "local": { "type": "object", "properties": { - "providers": { - "type": "object", - "additionalProperties": { + "modelsDir": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "pluginRegistries": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "authorization": { + "type": "string", + "minLength": 1 + } + }, + "required": ["authorization"] + } + }, + "pluginsEnabled": { + "type": "boolean" + }, + "plugins": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "directory" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "enabled": { + "type": "boolean" + } + }, + "required": ["source", "path"], + "additionalProperties": false + } + ] + } + }, + "worktrees": { + "type": "object", + "properties": { + "root": { + "type": "string", + "minLength": 1 + }, + "servicePorts": { + "type": "object", + "properties": { + "range": { + "type": "string", + "pattern": "^(\\d{1,5})-(\\d{1,5})$" + }, + "portScript": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agents": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "extends": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "command": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "env": { "type": "object", - "properties": { - "extends": { - "type": "string" - }, - "label": { - "type": "string" - }, - "description": { - "type": "string" - }, - "command": { - "type": "array", - "items": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "minLength": 1 }, - "minItems": 1 - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "models": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "label": { - "type": "string", - "minLength": 1 - }, - "description": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "thinkingOptions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "description": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - } - }, - "required": ["id", "label"], - "additionalProperties": false - } - } - }, - "required": ["id", "label"], - "additionalProperties": false - } - }, - "additionalModels": { - "type": "array", - "items": { - "$ref": "#/definitions/PaseoConfigV1/properties/agents/properties/providers/additionalProperties/properties/models/items" - } - }, - "disallowedTools": { - "type": "array", - "items": { - "type": "string" - } - }, - "paseoTools": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "disabledTools": { - "type": "array", - "items": { - "type": "string" - } - } + "label": { + "type": "string", + "minLength": 1 }, - "additionalProperties": false + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "thinkingOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": ["id", "label"] + } + } }, + "required": ["id", "label"] + } + }, + "additionalModels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "thinkingOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": ["id", "label"] + } + } + }, + "required": ["id", "label"] + } + }, + "disallowedTools": { + "type": "array", + "items": { + "type": "string" + } + }, + "paseoTools": { + "type": "object", + "properties": { "enabled": { "type": "boolean" }, - "order": { - "type": "number" + "disabledTools": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "enabled": { + "type": "boolean" + }, + "order": { + "type": "number" + } + } + } + }, + "catalogRefreshTimeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647 + }, + "metadataGeneration": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "thinkingOptionId": { + "type": "string", + "minLength": 1 } }, + "required": ["provider"], "additionalProperties": false } - }, - "catalogRefreshTimeoutMs": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647 } }, "additionalProperties": false }, - "features": { + "skills": { "type": "object", "properties": { - "dictation": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "stt": { + "selection": { + "oneOf": [ + { "type": "object", "properties": { - "provider": { - "allOf": [ - { - "type": "string" - }, - { - "type": "string", - "enum": ["openai", "local"] - } - ] - }, - "model": { + "mode": { "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - }, - "confidenceThreshold": { - "type": "number" + "const": "all" } }, + "required": ["mode"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "custom" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "skills"], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "features": { + "type": "object", + "properties": { + "dictation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" } }, "additionalProperties": false + } + }, + "additionalProperties": false + }, + "voiceMode": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "voiceMode": { + "llm": { "type": "object", "properties": { - "enabled": { - "type": "boolean" + "provider": { + "type": "string" }, - "llm": { - "type": "object", - "properties": { - "provider": { - "type": "string" - }, - "model": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "stt": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "turnDetection": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "voice": { - "type": "string", - "enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] - }, - "speakerId": { - "type": "integer" - }, - "speed": { - "type": "number" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "webUi": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "distDir": { + "model": { "type": "string", "minLength": 1 } }, "additionalProperties": false + }, + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "turnDetection": { + "type": "object", + "properties": { + "provider": { + "type": "string" + } + }, + "additionalProperties": false + }, + "tts": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "voice": { + "type": "string", + "minLength": 1 + }, + "speakerId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "speed": { + "type": "number" + } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "log": { + "webUi": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "distDir": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "log": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error", "fatal"] + }, + "format": { + "type": "string", + "enum": ["pretty", "json"] + }, + "console": { "type": "object", "properties": { "level": { @@ -496,42 +764,32 @@ "format": { "type": "string", "enum": ["pretty", "json"] + } + }, + "additionalProperties": false + }, + "file": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error", "fatal"] }, - "console": { + "path": { + "type": "string", + "minLength": 1 + }, + "rotate": { "type": "object", "properties": { - "level": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" - }, - "format": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/format" - } - }, - "additionalProperties": false - }, - "file": { - "type": "object", - "properties": { - "level": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" - }, - "path": { + "maxSize": { "type": "string", "minLength": 1 }, - "rotate": { - "type": "object", - "properties": { - "maxSize": { - "type": "string", - "minLength": 1 - }, - "maxFiles": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false + "maxFiles": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "additionalProperties": false @@ -543,5 +801,6 @@ "additionalProperties": false } }, - "$schema": "http://json-schema.org/draft-07/schema#" + "additionalProperties": false, + "title": "PaseoConfigV1" } diff --git a/packages/website/public/sponsors/hoplite-on-dark.svg b/packages/website/public/sponsors/hoplite-on-dark.svg new file mode 100644 index 0000000000..55b1da0592 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-dark.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="543" height="132" viewBox="0 0 543.36 131.55" fill="none" role="img" aria-label="Hoplite"><g transform="translate(8 21.057) scale(0.573290)"><path d="M302.691 155.065H288.942C287.892 155.065 287.041 154.214 287.041 153.164V147.506C287.041 146.804 286.654 146.159 286.035 145.829L261.463 132.724C260.165 132.032 260.111 130.192 261.366 129.425L274.298 121.522C275.075 121.047 276.079 121.19 276.693 121.862L304.095 151.883C305.209 153.103 304.343 155.065 302.691 155.065Z" fill="#eaeaea"></path><path d="M252.762 108.228L233.61 107.75H98.3204C97.9834 107.75 97.6525 107.84 97.3615 108.01L37.0718 143.236L27.0043 148.614C26.3855 148.944 25.9991 149.589 25.9991 150.29V153.149C25.9991 154.205 25.1389 155.058 24.0833 155.05L4.18116 154.894C2.54933 154.881 1.69116 152.953 2.77368 151.732L47.3957 101.396C47.4864 101.293 47.5658 101.181 47.6325 101.062L53.6519 90.2923C54.5951 88.6046 52.7097 86.7502 51.0379 87.7212L35.8453 96.5457C24.2461 103.283 9.39029 99.5363 2.37343 88.1041L0.280818 84.6947C0.097197 84.3956 0 84.0514 0 83.7004V58.3505C0 56.4105 2.56456 55.7168 3.54252 57.3923L10.4681 69.2575C15.7292 78.271 27.2735 81.357 36.3303 76.171L58.8584 63.2712C59.4456 62.935 59.8097 62.312 59.8147 61.6355L59.8659 54.615C59.8766 53.1519 58.2994 52.2258 57.0268 52.948L33.8106 66.1245C28.937 68.8905 22.7405 66.8612 20.4473 61.7481L0.166472 16.5284C0.0567329 16.2838 7.51957e-06 16.0186 2.20646e-05 15.7505L0.000767057 2.01526C0.000821237 1.01636 0.810609 0.206616 1.80951 0.206616C17.8277 0.206616 33.7837 2.20008 49.3095 6.14104L70.7728 11.5891C71.9741 11.8941 73.1413 10.9862 73.1413 9.74669V1.59489C73.1413 0.828169 73.7628 0.206616 74.5296 0.206616C88.0714 0.206616 101.274 4.44343 112.287 12.3233L137.017 30.0176C137.339 30.2484 137.726 30.3725 138.123 30.3725H161.504C162.554 30.3725 163.405 29.5215 163.405 28.4717V25.5583C163.405 24.5085 162.554 23.6575 161.504 23.6575H138.446C136.536 23.6575 135.819 21.1555 137.44 20.1441L152.071 11.0128C163.603 3.81567 176.923 0 190.517 0C203.224 0 215.708 3.33443 226.723 9.67017L231.117 12.1975C232.347 12.9051 232.396 14.6625 231.207 15.4376L212.014 27.9505C211.476 28.3014 211.151 28.9004 211.151 29.5429V35.984C211.151 36.6551 210.797 37.2766 210.22 37.6189L203.831 41.4081C202.133 42.4151 200.215 40.4867 201.231 38.7944L202.078 37.3845C202.839 36.1175 201.927 34.5048 200.449 34.5048H192.32C190.287 34.5048 189.673 37.2658 191.514 38.1273L198.321 41.3127C199.687 41.9517 199.8 43.8498 198.521 44.6474L186.136 52.3701C185.834 52.5582 185.486 52.6579 185.13 52.6579H179.616C178.581 52.6579 177.742 53.497 177.742 54.532C177.742 55.567 178.581 56.4061 179.616 56.4061H189.794C190.136 56.4061 190.471 56.314 190.765 56.1395L267.664 10.4567C276.712 5.08166 288.289 6.78035 295.411 14.5282C295.556 14.6857 295.636 14.8917 295.636 15.1055V20.8068C295.636 22.457 297.595 23.3234 298.816 22.2134L303.82 17.6635C305.042 16.5534 307 17.4198 307 19.07V81.8818C307 82.9316 306.149 83.7827 305.099 83.7827H296.529C295.963 83.7827 295.427 83.5307 295.066 83.0953L276.998 61.3111C276.255 60.4154 274.889 60.3915 274.116 61.2606L259.05 78.1821C258.276 79.0519 258.458 80.4071 259.435 81.0408L290.165 100.974C291.35 101.742 291.313 103.489 290.096 104.206L242.218 132.42C240.989 133.144 239.43 132.307 239.355 130.882L238.675 117.953C238.617 116.865 239.484 115.952 240.573 115.952H248.301C248.767 115.952 249.216 115.781 249.564 115.472L253.977 111.549C255.266 110.404 254.486 108.271 252.762 108.228Z" fill="#eaeaea"></path><path d="M137.364 113.639H111.169C110.831 113.639 110.499 113.729 110.208 113.9L80.0946 131.554C78.7781 132.326 78.862 134.256 80.2405 134.911L107.275 147.751C107.938 148.066 108.361 148.734 108.361 149.468V153.164C108.361 154.214 109.212 155.065 110.262 155.065H126.237C127.886 155.065 128.753 153.109 127.645 151.888L112.427 135.099C111.618 134.207 111.832 132.797 112.87 132.185L138.329 117.177C139.997 116.194 139.299 113.639 137.364 113.639Z" fill="#eaeaea"></path></g><path fill="#eaeaea" d="M94.51 109.18Q93.17 109.18 92.00 108.67Q90.84 108.17 89.94 107.28Q89.05 106.39 88.55 105.22Q88.05 104.06 88.05 102.72Q88.05 101.38 88.55 100.21Q89.05 99.05 89.94 98.15Q90.84 97.26 92.00 96.76Q93.17 96.26 94.51 96.26Q95.85 96.26 97.01 96.76Q98.18 97.26 99.07 98.15Q99.96 99.05 100.47 100.21Q100.97 101.38 100.97 102.72Q100.97 104.06 100.47 105.22Q99.96 106.39 99.07 107.28Q98.18 108.17 97.01 108.67Q95.85 109.18 94.51 109.18ZM94.51 107.82Q95.57 107.82 96.49 107.42Q97.41 107.02 98.11 106.32Q98.82 105.62 99.21 104.69Q99.61 103.77 99.61 102.72Q99.61 101.65 99.21 100.73Q98.82 99.81 98.11 99.11Q97.41 98.40 96.49 98.01Q95.57 97.62 94.51 97.62Q93.45 97.62 92.53 98.01Q91.60 98.40 90.90 99.11Q90.20 99.81 89.81 100.73Q89.41 101.65 89.41 102.72Q89.41 103.77 89.81 104.69Q90.20 105.62 90.90 106.32Q91.60 107.02 92.53 107.42Q93.45 107.82 94.51 107.82ZM94.53 106.13Q93.63 106.13 92.92 105.71Q92.21 105.29 91.80 104.52Q91.38 103.74 91.38 102.72Q91.38 101.67 91.80 100.91Q92.21 100.14 92.92 99.72Q93.63 99.30 94.53 99.30Q95.63 99.30 96.42 99.92Q97.22 100.53 97.43 101.73H95.84Q95.71 101.15 95.36 100.88Q95.01 100.61 94.53 100.61Q93.80 100.61 93.36 101.19Q92.92 101.77 92.92 102.72Q92.92 103.64 93.36 104.21Q93.80 104.78 94.53 104.78Q95.05 104.78 95.41 104.49Q95.77 104.21 95.91 103.61H97.45Q97.27 104.87 96.45 105.50Q95.62 106.13 94.53 106.13Z"/><path fill="#eaeaea" d="M232.45 100.43V35.44H241.63V62.78H279.09V35.44H288.27V100.43H279.09V70.68H241.63V100.43ZM322.81 101.40Q315.31 101.40 309.76 98.31Q304.21 95.21 301.15 89.59Q298.09 83.98 298.09 76.35Q298.09 68.72 301.12 63.10Q304.14 57.49 309.73 54.41Q315.32 51.33 322.82 51.33Q330.46 51.33 335.96 54.37Q341.46 57.42 344.48 63.04Q347.49 68.66 347.49 76.36Q347.49 83.98 344.48 89.64Q341.46 95.30 335.92 98.35Q330.38 101.40 322.81 101.40ZM322.81 93.56Q330.27 93.56 334.39 89.01Q338.50 84.47 338.50 76.35Q338.50 68.16 334.39 63.66Q330.27 59.16 322.81 59.16Q315.39 59.16 311.24 63.69Q307.08 68.22 307.08 76.34Q307.08 84.47 311.24 89.01Q315.39 93.56 322.81 93.56ZM357.92 119.69V61.60H353.27V52.29H362.90V59.67H366.27Q368.71 55.61 372.85 53.47Q377.00 51.33 382.84 51.33Q390.08 51.33 395.15 54.37Q400.22 57.41 402.90 63.02Q405.58 68.64 405.58 76.36Q405.58 84.08 402.95 89.71Q400.32 95.33 395.39 98.36Q390.47 101.40 383.61 101.40Q377.90 101.40 373.67 99.13Q369.44 96.85 367.23 92.65H366.91V119.69ZM381.45 93.69Q388.69 93.69 392.64 89.12Q396.60 84.55 396.60 76.36Q396.60 68.18 392.64 63.60Q388.69 59.03 381.45 59.03Q374.87 59.03 370.89 62.79Q366.91 66.56 366.91 72.86V79.86Q366.91 86.17 370.89 89.93Q374.87 93.69 381.45 93.69ZM414.43 100.43V33.03H423.41V100.43ZM435.45 100.43V52.29H444.44V100.43ZM439.95 45.69Q437.68 45.69 436.09 44.11Q434.50 42.52 434.50 40.25Q434.50 37.99 436.09 36.40Q437.68 34.81 439.94 34.81Q442.21 34.81 443.80 36.40Q445.38 37.98 445.38 40.25Q445.38 42.51 443.80 44.10Q442.21 45.69 439.95 45.69ZM468.00 100.43Q464.18 100.43 462.05 98.48Q459.92 96.53 459.92 92.60V59.22H450.94V52.29H460.02V37.84H468.81V52.29H481.46V59.22H468.91V93.37H482.71V100.43ZM511.09 101.40Q503.59 101.40 498.05 98.31Q492.52 95.22 489.51 89.53Q486.50 83.84 486.50 76.20Q486.50 68.56 489.49 62.98Q492.49 57.41 497.93 54.37Q503.38 51.33 510.72 51.33Q517.71 51.33 522.80 54.13Q527.89 56.93 530.69 62.06Q533.49 67.20 533.49 74.24V77.89H495.12Q495.55 85.43 499.77 89.62Q503.99 93.82 511.01 93.82Q515.96 93.82 519.33 91.78Q522.69 89.73 523.97 85.78H533.09Q531.31 93.20 525.51 97.30Q519.72 101.40 511.09 101.40ZM495.49 71.34H524.82Q524.23 65.34 520.50 62.05Q516.77 58.77 510.67 58.77Q504.59 58.77 500.59 62.06Q496.59 65.35 495.49 71.34Z"/></svg> diff --git a/packages/website/public/sponsors/hoplite-on-light.svg b/packages/website/public/sponsors/hoplite-on-light.svg new file mode 100644 index 0000000000..144d1a59c9 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-light.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="543" height="132" viewBox="0 0 543.36 131.55" fill="none" role="img" aria-label="Hoplite"><g transform="translate(8 21.057) scale(0.573290)"><path d="M302.691 155.065H288.942C287.892 155.065 287.041 154.214 287.041 153.164V147.506C287.041 146.804 286.654 146.159 286.035 145.829L261.463 132.724C260.165 132.032 260.111 130.192 261.366 129.425L274.298 121.522C275.075 121.047 276.079 121.19 276.693 121.862L304.095 151.883C305.209 153.103 304.343 155.065 302.691 155.065Z" fill="#121212"></path><path d="M252.762 108.228L233.61 107.75H98.3204C97.9834 107.75 97.6525 107.84 97.3615 108.01L37.0718 143.236L27.0043 148.614C26.3855 148.944 25.9991 149.589 25.9991 150.29V153.149C25.9991 154.205 25.1389 155.058 24.0833 155.05L4.18116 154.894C2.54933 154.881 1.69116 152.953 2.77368 151.732L47.3957 101.396C47.4864 101.293 47.5658 101.181 47.6325 101.062L53.6519 90.2923C54.5951 88.6046 52.7097 86.7502 51.0379 87.7212L35.8453 96.5457C24.2461 103.283 9.39029 99.5363 2.37343 88.1041L0.280818 84.6947C0.097197 84.3956 0 84.0514 0 83.7004V58.3505C0 56.4105 2.56456 55.7168 3.54252 57.3923L10.4681 69.2575C15.7292 78.271 27.2735 81.357 36.3303 76.171L58.8584 63.2712C59.4456 62.935 59.8097 62.312 59.8147 61.6355L59.8659 54.615C59.8766 53.1519 58.2994 52.2258 57.0268 52.948L33.8106 66.1245C28.937 68.8905 22.7405 66.8612 20.4473 61.7481L0.166472 16.5284C0.0567329 16.2838 7.51957e-06 16.0186 2.20646e-05 15.7505L0.000767057 2.01526C0.000821237 1.01636 0.810609 0.206616 1.80951 0.206616C17.8277 0.206616 33.7837 2.20008 49.3095 6.14104L70.7728 11.5891C71.9741 11.8941 73.1413 10.9862 73.1413 9.74669V1.59489C73.1413 0.828169 73.7628 0.206616 74.5296 0.206616C88.0714 0.206616 101.274 4.44343 112.287 12.3233L137.017 30.0176C137.339 30.2484 137.726 30.3725 138.123 30.3725H161.504C162.554 30.3725 163.405 29.5215 163.405 28.4717V25.5583C163.405 24.5085 162.554 23.6575 161.504 23.6575H138.446C136.536 23.6575 135.819 21.1555 137.44 20.1441L152.071 11.0128C163.603 3.81567 176.923 0 190.517 0C203.224 0 215.708 3.33443 226.723 9.67017L231.117 12.1975C232.347 12.9051 232.396 14.6625 231.207 15.4376L212.014 27.9505C211.476 28.3014 211.151 28.9004 211.151 29.5429V35.984C211.151 36.6551 210.797 37.2766 210.22 37.6189L203.831 41.4081C202.133 42.4151 200.215 40.4867 201.231 38.7944L202.078 37.3845C202.839 36.1175 201.927 34.5048 200.449 34.5048H192.32C190.287 34.5048 189.673 37.2658 191.514 38.1273L198.321 41.3127C199.687 41.9517 199.8 43.8498 198.521 44.6474L186.136 52.3701C185.834 52.5582 185.486 52.6579 185.13 52.6579H179.616C178.581 52.6579 177.742 53.497 177.742 54.532C177.742 55.567 178.581 56.4061 179.616 56.4061H189.794C190.136 56.4061 190.471 56.314 190.765 56.1395L267.664 10.4567C276.712 5.08166 288.289 6.78035 295.411 14.5282C295.556 14.6857 295.636 14.8917 295.636 15.1055V20.8068C295.636 22.457 297.595 23.3234 298.816 22.2134L303.82 17.6635C305.042 16.5534 307 17.4198 307 19.07V81.8818C307 82.9316 306.149 83.7827 305.099 83.7827H296.529C295.963 83.7827 295.427 83.5307 295.066 83.0953L276.998 61.3111C276.255 60.4154 274.889 60.3915 274.116 61.2606L259.05 78.1821C258.276 79.0519 258.458 80.4071 259.435 81.0408L290.165 100.974C291.35 101.742 291.313 103.489 290.096 104.206L242.218 132.42C240.989 133.144 239.43 132.307 239.355 130.882L238.675 117.953C238.617 116.865 239.484 115.952 240.573 115.952H248.301C248.767 115.952 249.216 115.781 249.564 115.472L253.977 111.549C255.266 110.404 254.486 108.271 252.762 108.228Z" fill="#121212"></path><path d="M137.364 113.639H111.169C110.831 113.639 110.499 113.729 110.208 113.9L80.0946 131.554C78.7781 132.326 78.862 134.256 80.2405 134.911L107.275 147.751C107.938 148.066 108.361 148.734 108.361 149.468V153.164C108.361 154.214 109.212 155.065 110.262 155.065H126.237C127.886 155.065 128.753 153.109 127.645 151.888L112.427 135.099C111.618 134.207 111.832 132.797 112.87 132.185L138.329 117.177C139.997 116.194 139.299 113.639 137.364 113.639Z" fill="#121212"></path></g><path fill="#121212" d="M94.51 109.18Q93.17 109.18 92.00 108.67Q90.84 108.17 89.94 107.28Q89.05 106.39 88.55 105.22Q88.05 104.06 88.05 102.72Q88.05 101.38 88.55 100.21Q89.05 99.05 89.94 98.15Q90.84 97.26 92.00 96.76Q93.17 96.26 94.51 96.26Q95.85 96.26 97.01 96.76Q98.18 97.26 99.07 98.15Q99.96 99.05 100.47 100.21Q100.97 101.38 100.97 102.72Q100.97 104.06 100.47 105.22Q99.96 106.39 99.07 107.28Q98.18 108.17 97.01 108.67Q95.85 109.18 94.51 109.18ZM94.51 107.82Q95.57 107.82 96.49 107.42Q97.41 107.02 98.11 106.32Q98.82 105.62 99.21 104.69Q99.61 103.77 99.61 102.72Q99.61 101.65 99.21 100.73Q98.82 99.81 98.11 99.11Q97.41 98.40 96.49 98.01Q95.57 97.62 94.51 97.62Q93.45 97.62 92.53 98.01Q91.60 98.40 90.90 99.11Q90.20 99.81 89.81 100.73Q89.41 101.65 89.41 102.72Q89.41 103.77 89.81 104.69Q90.20 105.62 90.90 106.32Q91.60 107.02 92.53 107.42Q93.45 107.82 94.51 107.82ZM94.53 106.13Q93.63 106.13 92.92 105.71Q92.21 105.29 91.80 104.52Q91.38 103.74 91.38 102.72Q91.38 101.67 91.80 100.91Q92.21 100.14 92.92 99.72Q93.63 99.30 94.53 99.30Q95.63 99.30 96.42 99.92Q97.22 100.53 97.43 101.73H95.84Q95.71 101.15 95.36 100.88Q95.01 100.61 94.53 100.61Q93.80 100.61 93.36 101.19Q92.92 101.77 92.92 102.72Q92.92 103.64 93.36 104.21Q93.80 104.78 94.53 104.78Q95.05 104.78 95.41 104.49Q95.77 104.21 95.91 103.61H97.45Q97.27 104.87 96.45 105.50Q95.62 106.13 94.53 106.13Z"/><path fill="#121212" d="M232.45 100.43V35.44H241.63V62.78H279.09V35.44H288.27V100.43H279.09V70.68H241.63V100.43ZM322.81 101.40Q315.31 101.40 309.76 98.31Q304.21 95.21 301.15 89.59Q298.09 83.98 298.09 76.35Q298.09 68.72 301.12 63.10Q304.14 57.49 309.73 54.41Q315.32 51.33 322.82 51.33Q330.46 51.33 335.96 54.37Q341.46 57.42 344.48 63.04Q347.49 68.66 347.49 76.36Q347.49 83.98 344.48 89.64Q341.46 95.30 335.92 98.35Q330.38 101.40 322.81 101.40ZM322.81 93.56Q330.27 93.56 334.39 89.01Q338.50 84.47 338.50 76.35Q338.50 68.16 334.39 63.66Q330.27 59.16 322.81 59.16Q315.39 59.16 311.24 63.69Q307.08 68.22 307.08 76.34Q307.08 84.47 311.24 89.01Q315.39 93.56 322.81 93.56ZM357.92 119.69V61.60H353.27V52.29H362.90V59.67H366.27Q368.71 55.61 372.85 53.47Q377.00 51.33 382.84 51.33Q390.08 51.33 395.15 54.37Q400.22 57.41 402.90 63.02Q405.58 68.64 405.58 76.36Q405.58 84.08 402.95 89.71Q400.32 95.33 395.39 98.36Q390.47 101.40 383.61 101.40Q377.90 101.40 373.67 99.13Q369.44 96.85 367.23 92.65H366.91V119.69ZM381.45 93.69Q388.69 93.69 392.64 89.12Q396.60 84.55 396.60 76.36Q396.60 68.18 392.64 63.60Q388.69 59.03 381.45 59.03Q374.87 59.03 370.89 62.79Q366.91 66.56 366.91 72.86V79.86Q366.91 86.17 370.89 89.93Q374.87 93.69 381.45 93.69ZM414.43 100.43V33.03H423.41V100.43ZM435.45 100.43V52.29H444.44V100.43ZM439.95 45.69Q437.68 45.69 436.09 44.11Q434.50 42.52 434.50 40.25Q434.50 37.99 436.09 36.40Q437.68 34.81 439.94 34.81Q442.21 34.81 443.80 36.40Q445.38 37.98 445.38 40.25Q445.38 42.51 443.80 44.10Q442.21 45.69 439.95 45.69ZM468.00 100.43Q464.18 100.43 462.05 98.48Q459.92 96.53 459.92 92.60V59.22H450.94V52.29H460.02V37.84H468.81V52.29H481.46V59.22H468.91V93.37H482.71V100.43ZM511.09 101.40Q503.59 101.40 498.05 98.31Q492.52 95.22 489.51 89.53Q486.50 83.84 486.50 76.20Q486.50 68.56 489.49 62.98Q492.49 57.41 497.93 54.37Q503.38 51.33 510.72 51.33Q517.71 51.33 522.80 54.13Q527.89 56.93 530.69 62.06Q533.49 67.20 533.49 74.24V77.89H495.12Q495.55 85.43 499.77 89.62Q503.99 93.82 511.01 93.82Q515.96 93.82 519.33 91.78Q522.69 89.73 523.97 85.78H533.09Q531.31 93.20 525.51 97.30Q519.72 101.40 511.09 101.40ZM495.49 71.34H524.82Q524.23 65.34 520.50 62.05Q516.77 58.77 510.67 58.77Q504.59 58.77 500.59 62.06Q496.59 65.35 495.49 71.34Z"/></svg> diff --git a/packages/website/src/components/alternative-page.tsx b/packages/website/src/components/alternative-page.tsx index ec583fefaf..3322f869e1 100644 --- a/packages/website/src/components/alternative-page.tsx +++ b/packages/website/src/components/alternative-page.tsx @@ -32,7 +32,9 @@ function AlternativePageContent({ slug }: { slug: string }) { return ( <SiteShell width="default"> - <DocsMarkdown>{page.content}</DocsMarkdown> + <div className="comparison-prose"> + <DocsMarkdown>{page.content}</DocsMarkdown> + </div> </SiteShell> ); } diff --git a/packages/website/src/components/docs-source-footer.tsx b/packages/website/src/components/docs-source-footer.tsx index 69a9711e80..2b5ea3f131 100644 --- a/packages/website/src/components/docs-source-footer.tsx +++ b/packages/website/src/components/docs-source-footer.tsx @@ -1,3 +1,4 @@ +import { ExternalLink } from "lucide-react"; import type { Doc } from "~/docs"; const GITHUB_BASE_URL = "https://github.com/getpaseo/paseo/blob/main"; @@ -6,9 +7,15 @@ export function DocsSourceFooter({ doc }: { doc: Doc }) { const sourceUrl = `${GITHUB_BASE_URL}/${doc.sourcePath}`; return ( - <footer className="docs-source-footer"> - <a href={sourceUrl} target="_blank" rel="noreferrer"> - View this page on GitHub + <footer className="mt-12 border-t border-white/10 py-8 text-sm"> + <a + href={sourceUrl} + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1.5 font-medium text-white/60 underline-offset-4 transition-colors hover:text-foreground hover:underline" + > + View on GitHub + <ExternalLink aria-hidden="true" className="size-3.5" /> </a> </footer> ); diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index a1fcc18ef3..ff0964b751 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -4,13 +4,12 @@ import { ArrowRight, Blocks, Bot, - BookOpen, Braces, - Coffee, Compass, ExternalLink, Gem, GitFork, + LayoutPanelLeft, Laptop, Lock, Merge, @@ -19,6 +18,7 @@ import { Smartphone, Terminal, Users, + Workflow, type LucideIcon, } from "lucide-react"; import { @@ -132,13 +132,13 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { <TurnkeySection /> <AutomationSection /> <ExtensibleSection /> - <FAQ /> - <PhilosophySection /> + <SponsorsSection placement="page" /> <SponsorSection /> - <SponsorsSection /> + <PhilosophySection /> + <FAQ /> </div> </main> - <SiteFooter /> + <SiteFooter sponsors={false} /> </div> </CursorFieldProvider> ); @@ -468,46 +468,35 @@ function TurnkeySection() { title="Run it anywhere" description="Use Paseo locally, from another machine, or with a team" > - <div className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]"> - <div className="flex flex-col gap-6 border-b border-white/10 p-6 sm:flex-row sm:items-center sm:justify-between md:p-8"> - <div className="flex items-start gap-4"> - <div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground"> - <Monitor className="h-6 w-6" strokeWidth={1.5} /> - </div> - <div className="space-y-0.5"> - <h3 className="text-xl font-medium text-white/90">Desktop app</h3> - <p className="max-w-lg text-sm leading-relaxed text-white/50"> - The one click experience, download the app and it just works - </p> - </div> - </div> - </div> - - <div className="p-6 md:p-8"> - <div className="grid gap-4 md:grid-cols-3"> - <TurnkeyExtensionCard - icon={Smartphone} - title="Mobile and web" - description="Connect to the same workspaces from any client" - ctaHref="/download" - ctaLabel="Download" - /> - <TurnkeyExtensionCard - icon={Laptop} - title="Remote machines" - description="Run Paseo on a home lab, or a cloud machine" - ctaHref="/docs#server--cli" - ctaLabel="Docs" - /> - <TurnkeyExtensionCard - icon={Users} - title="Teams and triggers" - description="Share access or start work from GitHub, Slack, and Discord" - ctaHref="/hub" - ctaLabel="Paseo Hub" - showIntegrationIcons - /> - </div> + <div className="space-y-8"> + <ProductLead + icon={Monitor} + title="Desktop app" + description="The one click experience, download the app and it just works, no server required" + /> + <div className="grid gap-4 md:grid-cols-3"> + <TurnkeyExtensionCard + icon={Smartphone} + title="Mobile" + description="Full client and native, not a WebView" + ctaHref="/download" + ctaLabel="Download" + /> + <TurnkeyExtensionCard + icon={Laptop} + title="Remote machines" + description="Run Paseo on a home lab, or a cloud machine" + ctaHref="/docs#server--cli" + ctaLabel="Docs" + /> + <TurnkeyExtensionCard + icon={Users} + title="Teams and triggers" + description="Share access or start work from GitHub, Slack, and Discord" + ctaHref="/hub" + ctaLabel="Paseo Hub" + showIntegrationIcons + /> </div> </div> </FeatureSection> @@ -530,20 +519,8 @@ function TurnkeyExtensionCard({ showIntegrationIcons?: boolean; }) { return ( - <div className="flex min-h-48 flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5"> - <div className="mb-5 flex items-center gap-3 text-muted-foreground"> - <Icon className="h-5 w-5" strokeWidth={1.5} /> - {showIntegrationIcons ? ( - <> - <GitHubIcon className="h-4 w-4" /> - <SlackIcon className="h-4 w-4" /> - <DiscordIcon className="h-4 w-4" /> - </> - ) : null} - </div> - <h3 className="font-medium text-white/85">{title}</h3> - <p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p> - <div className="mt-auto pt-5"> + <FeatureCard icon={Icon} title={title} description={description}> + <div className="flex items-center justify-between"> <a href={ctaHref} className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-2.5 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90" @@ -551,7 +528,70 @@ function TurnkeyExtensionCard({ {ctaLabel} <ArrowRight className="h-3.5 w-3.5" /> </a> + {showIntegrationIcons ? INTEGRATION_ICONS : null} </div> + </FeatureCard> + ); +} + +const INTEGRATION_ICONS = ( + <span className="flex items-center gap-2 text-extra-muted-foreground"> + <GitHubIcon className="h-4 w-4" /> + <SlackIcon className="h-4 w-4" /> + <DiscordIcon className="h-4 w-4" /> + </span> +); + +/** The lead product of a section: an icon tile and title on the page background. */ +function ProductLead({ + icon: Icon, + title, + description, + actions, +}: { + icon: LucideIcon; + title: string; + description?: string; + actions?: React.ReactNode; +}) { + return ( + <div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-center gap-4"> + <div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground"> + <Icon className="h-6 w-6" strokeWidth={1.5} /> + </div> + <div className="space-y-0.5"> + <h3 className="text-xl font-medium text-white/90">{title}</h3> + {description ? ( + <p className="text-sm leading-relaxed text-white/50">{description}</p> + ) : null} + </div> + </div> + {actions ? <div className="flex shrink-0 items-center gap-3">{actions}</div> : null} + </div> + ); +} + +/** A small card: icon and title on one line, the blurb below, an optional action last. */ +function FeatureCard({ + icon: Icon, + title, + description, + children, +}: { + icon: LucideIcon; + title: string; + description: string; + children?: React.ReactNode; +}) { + return ( + <div className="flex flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5"> + <div className="flex items-center gap-3"> + <Icon className="h-5 w-5 shrink-0 text-extra-muted-foreground" strokeWidth={1.5} /> + <h3 className="font-medium text-white/85">{title}</h3> + </div> + <p className="mt-3 text-sm leading-relaxed text-white/45">{description}</p> + {children ? <div className="mt-auto pt-5">{children}</div> : null} </div> ); } @@ -793,95 +833,79 @@ function SdkAutomationExample() { function ExtensibleSection() { return ( - <FeatureSection title="Make it yours" description="Extend Paseo to work just the way you want"> - <div className="grid gap-4 md:grid-cols-2"> - <ExtensibleCard - icon={Puzzle} - title="Plugins" - description="Plugins can add server-side functionality and modify the client with custom components. They work across all clients, including mobile" - links={PLUGIN_CARD_LINKS} - /> - <ExtensibleCard - icon={GitFork} - title="Fork the repo" - description="Paseo is licensed under Apache 2.0. You can inspect the implementation, fork the project, and adapt it to your workflow or organization" - links={FORK_CARD_LINKS} - /> + <FeatureSection + title="Extend it with plugins" + description="Add new panels and functionality that work in desktop, web and mobile" + > + <div className="space-y-8"> + <ProductLead icon={Puzzle} title="Plugins" actions={PLUGIN_ACTIONS} /> + <div className="grid gap-4 md:grid-cols-3"> + {PLUGIN_CAPABILITIES.map((capability) => ( + <FeatureCard key={capability.title} {...capability} /> + ))} + </div> + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> + <p className="text-sm text-white/45">Paseo is Apache 2.0.</p> + <a + href="https://github.com/getpaseo/paseo" + target="_blank" + rel="noopener noreferrer" + className="inline-flex shrink-0 items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground" + > + <GitFork className="h-4 w-4" strokeWidth={1.5} /> + Fork the repo + <ExternalLink className="h-3.5 w-3.5 opacity-70" /> + </a> + </div> </div> </FeatureSection> ); } -interface ExtensibleCardLink { - href: string; - label: string; - icon: React.ComponentType<{ className?: string }>; - external?: boolean; - /** Accent links stand out without hover, for destinations worth noticing. */ - accent?: boolean; -} +const PLUGINS_BROWSE = PLUGINS_LINKED + ? { href: "/plugins", external: false } + : { href: "https://paseo.cafe", external: true }; -const PLUGIN_CARD_LINKS: ReadonlyArray<ExtensibleCardLink> = [ - { href: "/docs/plugins", label: "Plugin documentation", icon: BookOpen }, - PLUGINS_LINKED - ? { href: "/plugins", label: "Community plugins", icon: Blocks, accent: true } - : { - href: "https://paseo.cafe", - label: "Community plugins", - icon: Coffee, - external: true, - accent: true, - }, -]; +const PLUGIN_ACTIONS = ( + <> + <a + href={PLUGINS_BROWSE.href} + {...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} + className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90" + > + Browse plugins + <ArrowRight className="h-3.5 w-3.5" /> + </a> + <a + href="/docs/plugins" + className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10" + > + Plugin docs + </a> + </> +); -const FORK_CARD_LINKS: ReadonlyArray<ExtensibleCardLink> = [ - { - href: "https://github.com/getpaseo/paseo", - label: "View the repository", - icon: GitHubIcon, - external: true, - }, -]; - -function ExtensibleCard({ - icon: Icon, - title, - description, - links, -}: { +const PLUGIN_CAPABILITIES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string; - links: ReadonlyArray<ExtensibleCardLink>; -}) { - return ( - <div className="flex min-h-64 flex-col rounded-xl border border-white/10 bg-white/[0.025] p-6"> - <div className="mb-8 text-muted-foreground"> - <Icon className="h-6 w-6" strokeWidth={1.5} /> - </div> - <h3 className="text-lg font-medium text-white/85">{title}</h3> - <p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p> - <div className="mt-auto flex flex-col items-start gap-3 pt-6"> - {links.map((link) => ( - <a - key={link.href} - href={link.href} - {...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} - className={ - link.accent - ? "inline-flex items-center gap-2 text-sm text-emerald-300/85 transition-colors hover:text-emerald-200" - : "inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground" - } - > - <link.icon className="h-4 w-4" /> - {link.label} - {link.external ? <ExternalLink className="h-3.5 w-3.5 opacity-70" /> : null} - </a> - ))} - </div> - </div> - ); -} +}> = [ + { + icon: LayoutPanelLeft, + title: "UI", + description: "Screens, workspace panels, sidebar items, slash commands, and themes", + }, + { + icon: Workflow, + title: "Agent lifecycle", + description: "Change config and MCP servers, answer permissions, and follow up", + }, + { + icon: Bot, + title: "Providers", + description: "Add a coding agent as a provider, used like the built-in ones", + }, +]; const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string }> = [ { @@ -902,13 +926,12 @@ const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: { icon: Merge, title: "Unified", - description: - "Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, and more, all used the same way.", + description: "Every coding agent, used the same way. Switch providers any time.", }, { icon: Blocks, title: "Composable", - description: "Run agents on your laptop or a server, and connect from any device.", + description: "Run agents on a laptop or a server, and connect from any device.", }, { icon: Puzzle, @@ -923,18 +946,9 @@ function PhilosophySection() { title="Philosophy" description="What Paseo stands for, and what every feature is built on" > - <div className="grid gap-4 md:grid-cols-2"> + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {PRINCIPLES.map((principle) => ( - <div - key={principle.title} - className="rounded-xl border border-white/10 bg-white/[0.025] p-6" - > - <div className="mb-8 text-extra-muted-foreground"> - <principle.icon className="h-6 w-6" strokeWidth={1.5} /> - </div> - <h3 className="text-lg font-medium text-white/85">{principle.title}</h3> - <p className="mt-2 text-sm leading-relaxed text-white/45">{principle.description}</p> - </div> + <FeatureCard key={principle.title} {...principle} /> ))} </div> </FeatureSection> diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index 1ab11f9bdb..7c34376dbc 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -1,9 +1,12 @@ import { PLUGINS_LINKED } from "~/plugins/launch"; import { getAlternativePages } from "~/data/alternative-pages"; import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads"; +import { SponsorsSection } from "~/components/sponsorship"; interface SiteFooterProps { width?: "default" | "prose" | "wide"; + /** Pages that show the sponsors in their content turn the footer band off. */ + sponsors?: boolean; } const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = { @@ -12,14 +15,15 @@ const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = { wide: "max-w-7xl p-6 md:p-20 md:pt-0", }; -export function SiteFooter({ width = "default" }: SiteFooterProps) { +export function SiteFooter({ width = "default", sponsors = true }: SiteFooterProps) { const widthClasses = WIDTH_CLASSES[width]; const alternatives = getAlternativePages(); return ( <footer className={`${widthClasses} mx-auto mt-12 md:mt-16`}> + {sponsors ? <SponsorsSection placement="footer" /> : null} <div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-5 gap-8 text-sm"> <div className="space-y-3"> - <p className="text-white/60 font-medium">Product</p> + <p className="text-extra-muted-foreground">Product</p> <div className="space-y-2"> <a href="/blog" @@ -80,7 +84,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { </div> </div> <div className="space-y-3"> - <p className="text-white/60 font-medium">Agents</p> + <p className="text-extra-muted-foreground">Agents</p> <div className="space-y-2"> <a href="/claude-code" @@ -121,7 +125,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { </div> </div> <div className="space-y-3"> - <p className="text-white/60 font-medium">Alternatives</p> + <p className="text-extra-muted-foreground">Alternatives</p> <div className="space-y-2"> {alternatives.map((page) => ( <a @@ -135,7 +139,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { </div> </div> <div className="space-y-3"> - <p className="text-white/60 font-medium">Community</p> + <p className="text-extra-muted-foreground">Community</p> <div className="space-y-2"> <a href="https://discord.gg/jz8T2uahpH" @@ -164,7 +168,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { </div> </div> <div className="space-y-3"> - <p className="text-white/60 font-medium">Download</p> + <p className="text-extra-muted-foreground">Download</p> <div className="space-y-2"> <a href={appStoreUrl} diff --git a/packages/website/src/components/site-header.tsx b/packages/website/src/components/site-header.tsx index c691761b56..fc70e11f39 100644 --- a/packages/website/src/components/site-header.tsx +++ b/packages/website/src/components/site-header.tsx @@ -12,6 +12,14 @@ export function SiteHeader() { <span className="text-lg font-medium">Paseo</span> </a> <div className="flex flex-wrap items-center justify-center gap-4"> + {PLUGINS_LINKED && ( + <a + href="/plugins" + className="text-sm text-muted-foreground hover:text-foreground transition-colors" + > + Plugins + </a> + )} <a href="/blog" className="text-sm text-muted-foreground hover:text-foreground transition-colors" @@ -24,14 +32,6 @@ export function SiteHeader() { > Docs </a> - {PLUGINS_LINKED && ( - <a - href="/plugins" - className="text-sm text-muted-foreground hover:text-foreground transition-colors" - > - Plugins - </a> - )} <a href="/changelog" className="text-sm text-muted-foreground hover:text-foreground transition-colors" diff --git a/packages/website/src/components/site-shell.tsx b/packages/website/src/components/site-shell.tsx index 662f9f50f9..2c0a38f795 100644 --- a/packages/website/src/components/site-shell.tsx +++ b/packages/website/src/components/site-shell.tsx @@ -18,7 +18,7 @@ export function SiteShell({ children, width }: SiteShellProps) { return ( <div className="min-h-screen bg-background"> <main className={mainClasses}> - <div className="mb-12"> + <div className="mb-20"> <SiteHeader /> </div> {children} diff --git a/packages/website/src/components/sponsorship.tsx b/packages/website/src/components/sponsorship.tsx index 449742bffb..d82645a998 100644 --- a/packages/website/src/components/sponsorship.tsx +++ b/packages/website/src/components/sponsorship.tsx @@ -1,14 +1,15 @@ -import { ArrowRight, Check, ExternalLink } from "lucide-react"; +import { ArrowRight, Check, ExternalLink, Heart } from "lucide-react"; import { BuyMeACoffeeIcon, GitHubIcon, OpenCollectiveIcon } from "~/components/brand-icons"; import { BUY_ME_A_COFFEE_URL, GITHUB_SPONSORS_URL, - HOMEPAGE_SPONSORS, - HOMEPAGE_SPOT_PRICE, OPEN_COLLECTIVE_URL, + SPONSORS, SPONSOR_CONTACT_EMAIL, SPONSOR_SPOT_CHECKOUT_URL, + SPONSOR_SPOT_PRICE, openSpotCount, + sponsorHref, } from "~/data/sponsors"; function SectionHeading({ @@ -53,9 +54,15 @@ const MAINTAINER_LINK = ( export function FounderNote() { return ( <div className="max-w-2xl space-y-5 leading-relaxed text-white/70"> + <p>Paseo is an independent project used by tens of thousands of developers daily.</p> <p> - I build Paseo on my own. There are no investors, no board and no company behind it, and I - have turned down funding offers to keep it that way. + It is maintained by one person, full time, and shaped by the community. There are no + investors, no board and no company behind it. I have turned down funding offers to keep it + that way. + </p> + <p> + The community helps Paseo grow through feedback, testing, bug reports, code contributions + and plugins. Your sponsorship helps me keep improving and maintaining it. </p> <p> A tool that sits between you and your code, your keys and your machines has to stay neutral, @@ -67,7 +74,7 @@ export function FounderNote() { <a href="/hub" className="underline hover:text-white/90"> Paseo Hub </a> - , an optional hosted service. Your support is what lets me work on Paseo full time. + , an optional hosted service. Your support is what lets me keep working on it. </p> <p className="text-white/50">{MAINTAINER_LINK}, maintainer</p> </div> @@ -79,7 +86,6 @@ interface BackingOption { name: string; icon: React.ComponentType<{ className?: string }>; external: boolean; - primary?: boolean; } const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [ @@ -88,7 +94,6 @@ const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [ name: "GitHub Sponsors", icon: GitHubIcon, external: true, - primary: true, }, { href: OPEN_COLLECTIVE_URL, @@ -127,11 +132,7 @@ export function BackingOptions() { key={option.href} href={option.href} {...(option.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} - className={`flex items-center gap-4 rounded-xl border p-4 transition-colors ${ - option.primary - ? "border-white/25 bg-white/[0.06] hover:border-white/40 hover:bg-white/[0.08]" - : "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]" - }`} + className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] p-4 transition-colors hover:border-white/20 hover:bg-white/[0.05]" > <option.icon className="h-6 w-6 shrink-0 text-white/60" /> <p className="flex items-center gap-1.5 font-medium text-white"> @@ -148,30 +149,11 @@ export function BackingOptions() { ); } -function SponsorLogoRow() { - return ( - <ul className="grid grid-cols-2 gap-4"> - {HOMEPAGE_SPONSORS.map((sponsor) => ( - <li key={sponsor.href}> - <a - href={sponsor.href} - target="_blank" - rel="noopener noreferrer sponsored" - className="flex h-32 items-center justify-center rounded-xl border border-white/10 bg-white/[0.02] px-6 transition-colors hover:border-white/20 hover:bg-white/[0.05]" - > - <img src={sponsor.logo} alt={sponsor.name} className="max-h-10 max-w-full opacity-80" /> - </a> - </li> - ))} - </ul> - ); -} - const SPOT_FEATURES: ReadonlyArray<string> = [ - "Your logo on the homepage of paseo.sh, around 70,000 visitors a month", + "Your logo on the paseo.sh homepage and docs, around 70,000 visitors a month", "Your logo in the GitHub README, around 30,000 visitors a month", "An announcement in the Discord, around 2,000 members", - "A mention in the changelog", + "An announcement from me on X", ]; export function SponsorSpotSection() { @@ -181,11 +163,11 @@ export function SponsorSpotSection() { <SectionHeading as="h2" title="Sponsor as a company" - description="If your team relies on Paseo, a monthly sponsorship funds its development directly. As a thank you, your logo goes on the homepage and in the README." + description="Support Paseo financially and get visibility for your company" /> <div className="space-y-8"> <div className="flex items-end gap-2"> - <span className="text-4xl font-medium tracking-tight">{HOMEPAGE_SPOT_PRICE}</span> + <span className="text-4xl font-medium tracking-tight">{SPONSOR_SPOT_PRICE}</span> <span className="mb-1 text-sm text-white/45">a month</span> </div> <ul className="grid max-w-2xl gap-3 text-white/70"> @@ -237,18 +219,19 @@ export function SponsorSection() { <SectionHeading as="h2" title="Sponsor Paseo" /> <div className="space-y-10"> <div className="max-w-2xl space-y-5 leading-relaxed text-white/70"> + <p>Paseo is an independent project used by tens of thousands of developers daily.</p> <p> - I build Paseo on my own, with no investors and no company behind it, and I have turned - down funding offers to keep it that way. A tool that sits between you and your code, - your keys and your machines has to stay neutral, and funding creates pressure to - monetize. + It is maintained by one person, full time, and shaped by the community. There are no + investors and no company behind it. I have turned down funding to keep it that way. A + tool that sits between you and your code, your keys and your machines has to stay + neutral, and funding creates pressure to monetize. </p> <p> Paseo is self-funded through sponsorship and{" "} <a href="/hub" className="underline hover:text-white/90"> Paseo Hub </a> - , and your support is what lets me work on it full time. + . Your support is what lets me keep working on it. </p> <p className="text-white/50">{MAINTAINER_LINK}, maintainer</p> </div> @@ -258,23 +241,78 @@ export function SponsorSection() { ); } -/** Homepage: the companies sponsoring Paseo. Renders nothing until there is one. */ -export function SponsorsSection() { - if (HOMEPAGE_SPONSORS.length === 0) return null; +const SPONSORS_PLACEMENTS = { + page: { + section: "space-y-10", + heading: "gap-2.5 text-3xl font-medium tracking-tight", + heart: "size-6", + link: "", + logo: "h-14", + description: "mt-2 text-sm text-white/55", + button: "h-12 px-5 text-sm", + }, + footer: { + section: "space-y-5 border-t border-white/10 py-8", + heading: "gap-1.5 text-sm text-extra-muted-foreground", + heart: "size-3", + link: "opacity-85 transition-opacity hover:opacity-100", + logo: "h-10", + description: "mt-1.5 text-xs text-white/45", + button: "h-9 px-3.5 text-sm", + }, +} as const; + +interface SponsorsSectionProps { + /** `page` is a homepage section; `footer` is the band above other pages' footers. */ + placement: keyof typeof SPONSORS_PLACEMENTS; +} + +/** + * The companies sponsoring Paseo: one monochrome logo per sponsor and, while + * spots are open, a quiet invitation. Every page shows it once. + */ +export function SponsorsSection({ placement }: SponsorsSectionProps) { + const style = SPONSORS_PLACEMENTS[placement]; + const open = openSpotCount(); return ( - <section> - <SectionHeading as="h2" title="Sponsors" /> - <div className="space-y-4"> - <SponsorLogoRow /> - {openSpotCount() > 0 ? ( - <a - href="/sponsor#spot" - className="inline-block text-sm text-white/50 underline transition-colors hover:text-white/80" - > - Become a sponsor - </a> + <section aria-labelledby="sponsors-heading" className={style.section}> + <h2 id="sponsors-heading" className={`flex items-center ${style.heading}`}> + <Heart + aria-hidden="true" + className={`fill-rose-300/80 text-rose-300/80 ${style.heart}`} + strokeWidth={1.5} + /> + Sponsors + </h2> + <ul className="flex flex-wrap items-center gap-x-14 gap-y-8"> + {SPONSORS.map((sponsor) => ( + <li key={sponsor.url}> + <a + href={sponsorHref(sponsor, "website")} + target="_blank" + rel="noopener noreferrer" + className={`block ${style.link}`} + > + <img + src={sponsor.logo.onDark} + alt={sponsor.name} + className={`${style.logo} w-auto`} + /> + </a> + <p className={style.description}>{sponsor.description}</p> + </li> + ))} + {open > 0 ? ( + <li> + <a + href="/sponsor#spot" + className={`inline-flex items-center rounded-md border border-dashed border-white/15 text-white/45 transition-colors hover:border-white/30 hover:text-white/75 ${style.button}`} + > + Become a sponsor + </a> + </li> ) : null} - </div> + </ul> </section> ); } diff --git a/packages/website/src/content/alternatives/claude-desktop.md b/packages/website/src/content/alternatives/claude-desktop.md index 0d607daea7..13a403301c 100644 --- a/packages/website/src/content/alternatives/claude-desktop.md +++ b/packages/website/src/content/alternatives/claude-desktop.md @@ -9,7 +9,7 @@ order: 55 Claude Desktop is Anthropic's app for Claude Chat, Cowork, and Claude Code on macOS, Windows, and Linux. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -45,7 +45,7 @@ Both Claude Desktop and Paseo are available on macOS, Windows, and Linux. ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Claude has iOS and Android apps. Dispatch can start local Claude Code work through an active Claude Desktop host or start a cloud session on Anthropic's infrastructure. @@ -84,7 +84,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase" Both tools support parallel coding sessions, including Git worktrees. -Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions. +Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions. ## Voice @@ -94,25 +94,31 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel ## Comparison -| | Paseo | Claude Desktop | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ | -| License | Open source (Apache-2.0) | Not published as open source | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile coding workflow | Native Paseo workspace on iOS and Android | Dispatch and Cowork in Claude mobile | -| Coding agents | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code | -| Product account required | No | Claude account | -| Cloud agent | Cloud waitlist | Claude Cowork and remote sessions | -| Required cloud connection | No | Claude account and services | -| Machines you control | Laptop, workstation, VM, server, or home lab | Local machine or SSH host | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | No | -| Split panes and tabs | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser / preview | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | PR monitoring and merge workflows | -| CLI | Run, `--host`, ls, send, schedule, loop | Claude Code CLI | -| Remote transport | Direct connection or end-to-end encrypted relay | Anthropic Remote and cloud services | -| Application plugins | Server code and native client components | No | -| Self-hosted control plane | Daemon, web client, and relay | No | +| | Paseo | Claude Desktop | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| License | Open source (Apache-2.0) | Not published as open source | +| Account required | No | Claude account | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Claude app (Dispatch and Cowork) | +| CLI | Yes (everything the app does) | Yes (Claude Code CLI) | +| Remote machines | Yes (install the daemon anywhere) | Yes (SSH host, Anthropic-managed cloud sessions) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Anthropic Remote | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | Yes | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | Yes (file editor) | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | PR monitoring and merge workflows | +| In-app browser | Yes (element picker, agent browser tools) | Yes (live app preview) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | Yes (scheduled tasks) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Yes (in Claude's mobile and app surfaces) | +| Telemetry | None | - | See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Paseo vs Conductor](/alternatives/conductor). diff --git a/packages/website/src/content/alternatives/codex-app.md b/packages/website/src/content/alternatives/codex-app.md index 36a3b6935b..7f985af7f8 100644 --- a/packages/website/src/content/alternatives/codex-app.md +++ b/packages/website/src/content/alternatives/codex-app.md @@ -9,7 +9,7 @@ order: 54 OpenAI provides Codex through the ChatGPT desktop app on macOS, Windows, and Linux, with local, worktree, cloud, and remote workflows. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -45,7 +45,7 @@ OpenAI's desktop app and Paseo are available on macOS, Windows, and Linux. ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. ChatGPT Remote can start and continue Codex chats, approve actions, and review outputs from iOS and Android through a connected desktop host. @@ -55,7 +55,7 @@ Both tools support Git worktrees for parallel work. Codex App creates Codex-managed worktrees under `$CODEX_HOME/worktrees` and supports local environment setup scripts and project actions through `.codex` configuration. -Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web.fix-auth.my-app.localhost` so parallel services don't fight for ports. +Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web--fix-auth--my-app.localhost` so parallel services don't fight for ports. ## GitHub and review @@ -88,23 +88,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe ## Comparison -| | Paseo | Codex App | -| ------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------- | -| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile coding workflow | Native Paseo workspace on iOS and Android | ChatGPT Remote on iOS and Android | -| Providers | Codex, Claude Code, OpenCode, Pi + 30+ via ACP catalog + custom | Codex | -| Product account required | No | OpenAI account | -| Required Paseo cloud connection | No | Not applicable | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | No | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Commit, push, PR | -| CLI | Run, `--host`, ls, send, schedule, loop | Codex CLI | -| Remote transport | Direct connection or end-to-end encrypted relay | OpenAI Remote | -| Application plugins | Server code and native client components | No | -| Voice | Dictation and realtime voice | Dictation | -| Self-hosted control plane | Daemon, web client, and relay | No | +| | Paseo | Codex App | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary | +| Account required | No | OpenAI account | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | ChatGPT app (Remote) | +| CLI | Yes (everything the app does) | Yes (Codex CLI) | +| Remote machines | Yes (install the daemon anywhere) | Yes (connected host, OpenAI-managed cloud) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | OpenAI Remote | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Codex | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | Commit, push, PR | +| In-app browser | Yes (element picker, agent browser tools) | Yes | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Dictation | +| Telemetry | None | - | See also: [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [all supported providers](/agents). diff --git a/packages/website/src/content/alternatives/conductor.md b/packages/website/src/content/alternatives/conductor.md index 9c0a30af34..553d4e4d28 100644 --- a/packages/website/src/content/alternatives/conductor.md +++ b/packages/website/src/content/alternatives/conductor.md @@ -9,7 +9,7 @@ order: 50 Conductor is a proprietary macOS app for running Claude Code, Codex, Cursor, and OpenCode in parallel Git worktrees and managed cloud workspaces. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -67,11 +67,11 @@ Conductor lists its API as a Pro feature but does not document a user-facing CLI Both tools isolate parallel agents in git worktrees. -Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions. +Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions. ## Mobile -Paseo ships native iOS and Android apps today. Conductor lists its mobile app as coming soon under the Pro plan. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Conductor lists its mobile app as coming soon under the Pro plan. ## Voice @@ -79,21 +79,31 @@ Paseo supports local speech-to-text and text-to-speech. Conductor does not curre ## Comparison -| | Paseo | Conductor | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ | -| License | Open source (Apache-2.0) | Closed source | -| Platforms | macOS, Linux, Windows | macOS only | -| Native mobile | iOS, Android | Coming soon under Pro | -| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex, Cursor, OpenCode | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | — | -| Split panes and tabs | Yes | — | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | — | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes | -| CLI | Run, `--host`, ls, send, schedule, loop | — | -| Application plugins | Server code and native client components | No | -| Local voice | Yes | Not documented | -| Self-hosted daemon | Yes | — | +| | Paseo | Conductor | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------- | +| License | Open source (Apache-2.0) | Closed source | +| Account required | No | Yes | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS only) | +| Mobile app | Yes (native, full parity with desktop) | Coming soon under Pro | +| CLI | Yes (everything the app does) | API under Pro | +| Remote machines | Yes (install the daemon anywhere) | No (managed cloud workspaces under Pro) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | No | +| SSH access | Yes | No | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex, Cursor, OpenCode | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | - | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub | +| In-app browser | Yes (element picker, agent browser tools) | - | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | - | +| Telemetry | None | - | See also: [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/content/alternatives/happy-coder.md b/packages/website/src/content/alternatives/happy-coder.md index b92a3f651e..0c5bbd1b32 100644 --- a/packages/website/src/content/alternatives/happy-coder.md +++ b/packages/website/src/content/alternatives/happy-coder.md @@ -9,7 +9,7 @@ order: 53 Happy Coder connects Claude Code and Codex sessions across its macOS, web, iOS, and Android apps. It wraps the agent CLI on your laptop and syncs sessions over an end-to-end encrypted relay. Open source under MIT. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -37,7 +37,7 @@ Paseo's app handles commit, push, opening PRs, watching checks and reviews, and ## Mobile -Both tools ship native iOS and Android apps. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Happy Coder also ships native iOS and Android apps. ## Providers @@ -53,7 +53,7 @@ Happy Coder does not document an application extension API for adding both serve ## Worktrees and services -Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for the same port. +Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for the same port. Happy Coder can start an agent in a selected machine path, including an existing Git worktree. It does not currently create or manage the worktree lifecycle. @@ -79,21 +79,31 @@ Both products support voice interaction. Paseo can run speech-to-text and text-t ## Comparison -| | Paseo | Happy Coder | -| ---------------------------- | --------------------------------------------------------------------------------------- | --------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop app | macOS, Linux, Windows | macOS | -| Native mobile | iOS, Android | iOS, Android | -| Architecture | Daemon owns agent lifecycle | Wraps the agent CLI | -| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex | -| Split workspace | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser / preview | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | — | -| Managed Git worktrees | Yes | Existing worktree paths | -| Per-worktree dev server URLs | Yes | — | -| CLI | Run, `--host`, ls, send, schedule, loop | Launch and control sessions | -| Application plugins | Server code and native client components | No | -| Voice | Local or configured cloud speech | Yes | +| | Paseo | Happy Coder | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS) | +| Mobile app | Yes (native, full parity with desktop) | Yes (native) | +| CLI | Yes (everything the app does) | Yes (launch and control sessions) | +| Remote machines | Yes (install the daemon anywhere) | - | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (end-to-end encrypted) | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex | +| Parallel agents | Yes (isolated worktrees, across machines) | Existing worktree paths, no worktree management | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (wraps the agent CLI) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | - | +| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Yes | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/openchamber.md b/packages/website/src/content/alternatives/openchamber.md index f71c02ed46..bec7765588 100644 --- a/packages/website/src/content/alternatives/openchamber.md +++ b/packages/website/src/content/alternatives/openchamber.md @@ -51,26 +51,35 @@ OpenChamber's CLI runs and manages its server, remote access, and updates. Its A ## Mobile and voice -Both tools provide iOS and Android clients and support dictation and spoken replies. Paseo uses a React Native mobile interface. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView. +Both tools provide iOS and Android clients and support dictation and spoken replies. The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView. ## Comparison -| | Paseo | OpenChamber | -| ---------------------------- | --------------------------------------------------------------------------------------- | -------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile implementation | React Native | Capacitor WebView | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode | -| Application plugins | Server code and native client components | No | -| Split panes and tabs | Yes | Workspace views | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Issue, PR, checks, review, merge | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Preview and port detection | -| Automation | CLI, SDK, MCP | Server CLI, Agent Control Tool | -| Schedules | Yes | Yes | -| Voice | Local dictation and realtime voice | Dictation and spoken replies | -| Self-hosted daemon | Yes | Yes | +| | Paseo | OpenChamber | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Yes (Capacitor WebView) | +| CLI | Yes (everything the app does) | Yes (runs and manages the server) | +| Remote machines | Yes (install the daemon anywhere) | Yes (server on another machine) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees, multi-run comparisons) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (Agent Control Tool, OpenCode agents only) | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub (issue, PR, checks, review, merge) | +| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Preview and port detection | +| Schedules and heartbeats | Yes | Yes (schedules) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Dictation and spoken replies | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/content/alternatives/opencode-desktop.md b/packages/website/src/content/alternatives/opencode-desktop.md index 5321dd3ae7..d80d969aa5 100644 --- a/packages/website/src/content/alternatives/opencode-desktop.md +++ b/packages/website/src/content/alternatives/opencode-desktop.md @@ -9,7 +9,7 @@ order: 56 OpenCode Desktop is the desktop app for OpenCode. It is available in beta for macOS, Windows, and Linux. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -43,7 +43,7 @@ Paseo ships on macOS, Linux, and Windows. OpenCode provides beta desktop builds ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. OpenCode Desktop is a desktop app. OpenCode also has web and share-link workflows, but not a native mobile app. @@ -78,7 +78,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase" ## Worktrees and services -Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for ports. +Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for ports. OpenCode supports multi-session work on the same project. If you want worktree isolation around OpenCode sessions, Paseo can provide that by launching OpenCode inside Paseo workspaces. @@ -94,23 +94,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe ## Comparison -| | Paseo | OpenCode Desktop | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Native mobile | iOS, Android | No | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode | -| Multi-model support | Through supported agent harnesses | Through OpenCode providers | -| Git worktrees | Yes | No built-in worktree manager | -| Per-worktree dev server URLs | Yes | No | -| Split panes and tabs | Yes | Desktop sessions | -| In-app terminal | Yes | OpenCode terminal workflow | -| In-app browser | Yes | No | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | GitHub integration | -| CLI | Run, `--host`, ls, send, schedule, loop | OpenCode CLI | -| MCP server for orchestration | Yes | MCP support inside OpenCode | -| Application plugins | Server code and native client components | No | -| Local voice | Yes | No | -| Self-hosted daemon | Yes | OpenCode server / local runtime | +| | Paseo | OpenCode Desktop | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (beta, macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | No | +| CLI | Yes (everything the app does) | Yes (OpenCode CLI) | +| Remote machines | Yes (install the daemon anywhere) | OpenCode server | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode (many model providers) | +| Parallel agents | Yes (isolated worktrees, across machines) | Multiple sessions, no built-in worktree manager | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | MCP support inside OpenCode | +| Editor | Yes | - | +| Terminals | Yes | OpenCode terminal workflow | +| Diff review | Yes (comments go to the agent) | - | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab integrations | +| In-app browser | Yes (element picker, agent browser tools) | No | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | No | +| Telemetry | None | - | See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/orca.md b/packages/website/src/content/alternatives/orca.md index d47ce3c218..c939d49645 100644 --- a/packages/website/src/content/alternatives/orca.md +++ b/packages/website/src/content/alternatives/orca.md @@ -1,6 +1,6 @@ --- -title: Orca ADE Alternative With Agent Chat, a No-Account Relay, and Plugins -description: Paseo is an Orca alternative for developers who want a chat-first interface, a daemon they can reach from any client, and plugins that run on the server and every client. +title: Orca Alternative for Agentic Development +description: Paseo is an open source agentic development environment with parallel worktrees, an editor, terminals, diffs, pull requests, and a built-in browser. nav: Orca order: 57 --- @@ -9,7 +9,7 @@ order: 57 Orca is an MIT-licensed desktop app for running terminal coding agents in parallel Git worktrees. It includes a mobile companion app, SSH worktrees, remote Orca servers, an embedded browser, and a CLI. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -17,13 +17,13 @@ Paseo is an app for orchestrating coding agents, with native clients on desktop, Orca is built around terminals. Each agent runs in a terminal pane, and an experimental Chat UI can show supported agents as a chat transcript. Orca ships a broad set of built-in tools around those terminals. -Paseo is built around agent chat. It keeps a small core for starting agents, following their work, giving direction, and reviewing results, and adds everything else through plugins that run on the daemon and every client. +Paseo is built around agent chat. The app ships what every developer uses: editor, terminals, diffs, pull requests, and a browser. Features only some teams need, like issue boards or cloud VMs, are plugins you install, so nothing you do not use is in the way. Orca is made by Stably AI, a venture-funded company (Y Combinator). Paseo is independent and open source. ## Architecture and remote machines -The Paseo daemon runs as its own process. Desktop, web, mobile, and CLI clients connect to it directly, over SSH, or through the optional end-to-end encrypted relay. Run the daemon on your laptop, a VM, a home server, or in Docker, and connect to any of them from any client. See [connectivity](/docs/connectivity). +The Paseo desktop app starts and manages a local daemon automatically. That daemon owns the agents and workspaces, so other clients can connect to the same development environment. Desktop, web, mobile, and CLI clients connect to it directly, over SSH, or through the optional end-to-end encrypted relay. Run the daemon on your laptop, a VM, a home server, or in Docker, and connect to any of them from any client. See [connectivity](/docs/connectivity). Orca runs inside its desktop app by default and reaches other machines in two ways. SSH worktrees keep the Orca runtime on your laptop and run selected worktrees and terminals on a remote host. Remote Orca Servers, in beta, run Orca desktop or `orca serve` on another machine and let clients pair to it. Orca's documentation recommends Tailscale or another private network for Remote Orca Servers. @@ -31,7 +31,7 @@ Orca runs inside its desktop app by default and reaches other machines in two wa Both tools have iOS and Android apps. -Paseo ships native apps on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. They are on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale. Orca's mobile companion is in beta, on the App Store for iOS and as an APK download for Android. It can reply to agents, open sessions as a terminal or Chat UI, commit changes, and create workspaces on the paired desktop. Orca Relay, also in beta, connects the phone when it is not on the same network as the desktop and requires signing in to an Orca account. Without Orca Relay, the phone needs a LAN or Tailscale path to the desktop. @@ -51,7 +51,7 @@ Paseo runs Claude Code, Codex, OpenCode, Pi, Antigravity, and Muse Code through [Paseo plugins](/docs/plugins) extend Paseo itself. A plugin can add workspace panels, sidebar items, Command Center items, slash commands, composer pills, settings screens, themes, attachment sources, and agent providers. Its server code runs beside the daemon, and its client code runs in every connected client: desktop, browser, iOS, and Android. -Community plugins are listed at [paseo.cafe](https://paseo.cafe). +Community plugins are listed at [paseo.sh/plugins](/plugins). Orca has an experimental plugin system that is off by default. Orca plugins can add desktop panels rendered in a sandboxed frame, commands, keybindings, language packs, VM recipes, and agent profiles, and can be installed from git marketplaces. Plugin workers run on the desktop computer, and Orca does not document plugin surfaces in its mobile app. @@ -59,13 +59,13 @@ Orca has an experimental plugin system that is off by default. Orca plugins can Orca includes many tools in the core app: a Monaco code editor, Design Mode for sending page elements from its browser to an agent, computer use, Linear and Jira boards, agent account switching with usage tracking, and per-workspace cloud VM recipes. If you want these in one app without extensions, Orca provides them. -Paseo keeps a smaller core. A capability that serves a specialized workflow is meant to be a plugin, so the default app stays short to learn and each plugin can be maintained on its own schedule. +Paseo includes an editor, terminals, diff review with comments that go to the agent, pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg, a browser with an element picker and agent browser tools, plan usage, schedules, and heartbeats. Boards and cloud VMs are what a plugin adds. ## Workspaces and review Both tools provide Git worktrees, split panes, terminals, diff review, an in-app browser, pull-request workflows, and scheduled agent runs. -Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. Orca adds diff annotations that you send back to the agent, port forwarding for SSH worktrees, and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub. +Paseo has diff review with comments that go to the agent and opens pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg. It also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports. Orca has port forwarding for SSH worktrees and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub. ## Automation @@ -89,24 +89,31 @@ Orca does not require an account for local use. Orca Relay and shared artifacts ## Comparison -| | Paseo | Orca | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Funding | Independent | Venture-funded (Y Combinator) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Native mobile | iOS, Android | iOS, Android APK (beta) | -| Main interface | Agent chat | Terminal, with experimental Chat UI | -| Remote machines | Daemon on any machine, direct, SSH, or relay | SSH worktrees, Remote Orca Server | -| Relay | Optional, end-to-end encrypted, no account | Beta, Orca account required | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Any CLI agent in a terminal | -| Application plugins | Server code and native client components on every client | Experimental, desktop only | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes, with Design Mode | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes, plus GitLab, Bitbucket, and more | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Port forwarding for SSH worktrees | -| Automation | CLI, SDK, MCP | CLI, automations | -| Voice | Local dictation and realtime voice | Dictation | -| Telemetry | None | Anonymous usage data, opt-out | +| | Paseo | Orca | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | No for local use, Orca account for relay | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Yes (companion app, beta) | +| CLI | Yes (everything the app does) | Yes (worktrees, browser, automations) | +| Remote machines | Yes (install the daemon anywhere) | Yes (SSH worktrees, Remote Orca Server in beta) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (beta, Orca account required) | +| Direct network access | Yes (LAN, Tailscale, VPN) | Yes (LAN, Tailscale) | +| SSH access | Yes | Yes (SSH worktrees) | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Any CLI agent in a terminal, 30+ listed | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (main interface, experimental Chat UI) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (agents call the Orca CLI through installed skills) | +| Editor | Yes | Yes (Monaco) | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab, Bitbucket, Azure DevOps, Gitea | +| In-app browser | Yes (element picker, agent browser tools) | Yes (Design Mode) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port forwarding for SSH worktrees | +| Schedules and heartbeats | Yes | Yes (scheduled agent runs) | +| Plan usage | Yes | Yes (usage tracking, account switching) | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | Experimental, desktop only | +| Voice | Yes (local dictation, realtime voice) | Dictation | +| Telemetry | None | Anonymous usage data, opt-out | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/superset.md b/packages/website/src/content/alternatives/superset.md index 3dc33113ce..30877bc79e 100644 --- a/packages/website/src/content/alternatives/superset.md +++ b/packages/website/src/content/alternatives/superset.md @@ -51,7 +51,7 @@ Both tools provide Git worktrees, split panes, terminals, diff review, an in-app Paseo gives supported providers a structured chat interface with modes, slash commands, tool calls, and file attachments. Superset can run any CLI agent in terminal panes and adds lifecycle status for supported agents. -Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. +Paseo also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports. ## Automation @@ -65,23 +65,31 @@ Superset has a free individual tier. Team features, remote access, and integrati ## Comparison -| | Paseo | Superset | -| ---------------------------- | ---------------------------------------- | -------------------------------------- | -| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) | -| Desktop platforms | macOS, Linux, Windows | macOS, experimental Linux | -| Native mobile | iOS, Android | Coming soon | -| Account required | No | Yes, with GitHub sign-in | -| Agent harnesses | Native, ACP, and custom CLI | CLI agents | -| Application plugins | Server code and native client components | No | -| Split panes and tabs | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Port detection | -| Automation | CLI, SDK, MCP | CLI, SDK, MCP | -| Schedules | Yes | Yes | -| Local voice | Dictation and realtime voice | No documented voice support | -| Self-hosted daemon | Yes | Host server | +| | Paseo | Superset | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) | +| Account required | No | Yes, with GitHub sign-in | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, experimental Linux) | +| Mobile app | Yes (native, full parity with desktop) | Coming soon | +| CLI | Yes (everything the app does) | Yes (plus SDK and MCP server) | +| Remote machines | Yes (install the daemon anywhere) | Yes (host server, remote access through Superset cloud on paid plans) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Superset cloud services (paid plans) | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | CLI agents, custom terminal agents | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (lifecycle status for supported agents) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (CLI, SDK, and MCP server create workspaces and launch agents) | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub | +| In-app browser | Yes (element picker, agent browser tools) | Yes | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port detection | +| Schedules and heartbeats | Yes | Yes (schedules) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | - | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/data/sponsors.test.ts b/packages/website/src/data/sponsors.test.ts new file mode 100644 index 0000000000..f586cb9557 --- /dev/null +++ b/packages/website/src/data/sponsors.test.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { SPONSORS, sponsorHref } from "./sponsors"; + +const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); +const WEBSITE_PUBLIC = fileURLToPath(new URL("../../public", import.meta.url)); +const READMES = ["README.md", "README.zh-CN.md", "README.ja.md", "README.ko.md"]; +const README_SPONSOR_CELLS = 3; +const SPONSOR_PAGE_URL = "https://paseo.sh/sponsor"; + +describe("sponsorHref", () => { + it("tags the link with the placement it is shown in", () => { + const sponsor = { + name: "Acme", + url: "https://acme.dev", + logo: { onDark: "", onLight: "" }, + description: "", + }; + + expect(sponsorHref(sponsor, "website")).toBe( + "https://acme.dev/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor", + ); + expect(sponsorHref(sponsor, "x")).toBe( + "https://acme.dev/?utm_source=paseo&utm_medium=x&utm_campaign=sponsor", + ); + }); +}); + +function readmeSponsorCells(readme: string) { + const text = readFileSync(`${REPO_ROOT}${readme}`, "utf8"); + const table = text.slice(text.indexOf("<!-- Sponsors:")).split("</table>")[0]; + return [...table.matchAll(/<td[^>]*>(.*?)<\/td>/g)].map(([, cell]) => { + const logo = cell.match( + /^<a href="([^"]+)"><picture><source media="\(prefers-color-scheme: dark\)" srcset="([^"]+)"><img src="([^"]+)"[^>]*><\/picture><\/a><br><sub>([^<]+)<\/sub>$/, + ); + if (logo) { + const [, href, onDark, onLight, description] = logo; + return { href, onDark, onLight, description }; + } + return { invite: cell.match(/^<a href="([^"]+)">/)?.[1] }; + }); +} + +describe("SPONSORS", () => { + it.each(SPONSORS)("ships both logo inks for $name", (sponsor) => { + expect(existsSync(`${WEBSITE_PUBLIC}${sponsor.logo.onDark}`)).toBe(true); + expect(existsSync(`${WEBSITE_PUBLIC}${sponsor.logo.onLight}`)).toBe(true); + }); + + it.each(READMES)("fill the sponsors table in %s, followed by invitations", (readme) => { + const openCells = Math.max(0, README_SPONSOR_CELLS - SPONSORS.length); + + expect(readmeSponsorCells(readme)).toEqual([ + ...SPONSORS.map((sponsor) => ({ + href: sponsorHref(sponsor, "readme"), + onDark: `packages/website/public${sponsor.logo.onDark}`, + onLight: `packages/website/public${sponsor.logo.onLight}`, + description: sponsor.description, + })), + ...Array.from({ length: openCells }, () => ({ invite: SPONSOR_PAGE_URL })), + ]); + }); +}); diff --git a/packages/website/src/data/sponsors.ts b/packages/website/src/data/sponsors.ts index 22466c09d1..94d1e4b51a 100644 --- a/packages/website/src/data/sponsors.ts +++ b/packages/website/src/data/sponsors.ts @@ -1,31 +1,59 @@ /** - * Homepage sponsor spots. This file is the inventory: the site shows one open - * placeholder for every spot not listed in `HOMEPAGE_SPONSORS`. + * Company sponsors. This file is the inventory: every page of the site shows + * these logos above the footer, and the open spots invite the next sponsor. * * Approving a sponsor means three edits, or the site promises something it does * not deliver: * - * 1. Add the entry below and drop its logo in `public/sponsors/`. - * 2. Add the logo under the `## Sponsors` marker in `README.md`, - * `README.zh-CN.md`, `README.ja.md` and `README.ko.md`, in this same order. + * 1. Add the entry below and drop both logo inks in `public/sponsors/`. + * 2. In the sponsors table after the intro of `README.md`, `README.zh-CN.md`, + * `README.ja.md` and `README.ko.md`, replace a "become a sponsor" cell with + * the logo and its description, in this same order, linked to `sponsorHref(sponsor, "readme")` + * with both inks referenced from `packages/website/public`, so the README + * renders from the repository and never waits on a site deploy. + * `sponsors.test.ts` fails until they match. * 3. Remove all of it when the subscription ends. * - * The Stripe payment link caps itself at `HOMEPAGE_SPOT_COUNT` completed + * The Stripe payment link caps itself at `SPONSOR_SPOT_COUNT` completed * checkouts, and a checkout you decline and refund still counts against that * cap. When you refund one, raise the link's completed-session limit by one, or * the page will offer a spot the link refuses to sell. */ -export interface HomepageSponsor { +export interface Sponsor { name: string; - href: string; - /** Path under `public/`, e.g. `/sponsors/acme.svg`. Keep logos monochrome white. */ - logo: string; + /** The sponsor's own URL, without tracking. Links go through `sponsorHref`. */ + url: string; + /** Paths under `public/`, one per ground. Use the sponsor's official lockup. */ + logo: { onDark: string; onLight: string }; + /** What the product is, in a few words from the sponsor's own site. Shown under the logo. */ + description: string; } -export const HOMEPAGE_SPONSORS: ReadonlyArray<HomepageSponsor> = []; +export const SPONSORS: ReadonlyArray<Sponsor> = [ + { + name: "Hoplite", + url: "https://hoplite.sh", + logo: { + onDark: "/sponsors/hoplite-on-dark.svg", + onLight: "/sponsors/hoplite-on-light.svg", + }, + description: "Cloud coding agents", + }, +]; -export const HOMEPAGE_SPOT_COUNT = 4; -export const HOMEPAGE_SPOT_PRICE = "$500"; +/** Where a sponsor link is shown. Becomes the link's `utm_medium`. */ +export type SponsorPlacement = "website" | "readme" | "discord" | "x"; + +export function sponsorHref(sponsor: Sponsor, placement: SponsorPlacement): string { + const url = new URL(sponsor.url); + url.searchParams.set("utm_source", "paseo"); + url.searchParams.set("utm_medium", placement); + url.searchParams.set("utm_campaign", "sponsor"); + return url.toString(); +} + +export const SPONSOR_SPOT_COUNT = 4; +export const SPONSOR_SPOT_PRICE = "$500"; /** Stripe Payment Link for the monthly spot subscription. */ export const SPONSOR_SPOT_CHECKOUT_URL = "https://buy.stripe.com/8x24gBczR7LNaokcve2sM00"; @@ -38,5 +66,5 @@ export const OPEN_COLLECTIVE_URL = export const BUY_ME_A_COFFEE_URL = "https://buymeacoffee.com/paseo"; export function openSpotCount(): number { - return Math.max(0, HOMEPAGE_SPOT_COUNT - HOMEPAGE_SPONSORS.length); + return Math.max(0, SPONSOR_SPOT_COUNT - SPONSORS.length); } diff --git a/packages/website/src/llms.ts b/packages/website/src/llms.ts index 20e414f9dd..32a1f6dcac 100644 --- a/packages/website/src/llms.ts +++ b/packages/website/src/llms.ts @@ -6,13 +6,15 @@ const SITE_URL = "https://paseo.sh"; const PRODUCT_PREAMBLE = `# Paseo -> Mobile and desktop app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. +> Open source agentic development environment for desktop, mobile, web, and CLI. -Paseo is an open source application that lets you run AI coding agents on your own machine and drive them from your phone, desktop, browser, or terminal. Your code stays local — Paseo connects directly to your real development environment instead of running agents in someone else's cloud. +Paseo is a full agentic development environment. For agent-driven development, it replaces your IDE: run coding agents, edit files, review diffs and pull requests, run tests in terminals, and check your app in the built-in desktop browser. Arrange agents, files, terminals, and browser tabs in split panes within a workspace. -A self-hosted daemon manages agent lifecycle, exposes a WebSocket API, and ships with an MCP server so other agents can talk to it. Native apps for iOS, Android, macOS, Windows, Linux, and the web let you launch sessions, watch them work, review diffs, and ship from anywhere. A Docker-style CLI ("paseo run", "paseo ls", "paseo logs", "paseo wait") gives you scripting access. An end-to-end encrypted relay lets the mobile app reach your daemon over the public internet without exposing it. +Run many agents in parallel, each in its own git worktree and branch. Choose a provider and model for each task, inspect the results, and decide what to merge. Agents can also create worktrees, launch other agents, and communicate with them through Paseo's orchestration tools. -Paseo supports every major coding agent: Claude Code, Codex, GitHub Copilot, OpenCode, Antigravity, Muse Code, Cursor, Gemini, Cline, Goose, Amp, Aider, and 30+ others. Each agent runs as its own process; Paseo handles I/O, persistence, git worktree isolation, schedules, and skills. +Agents run on your own machines with your existing tools, configuration, and credentials. Work from desktop, the full native iOS and Android app, a web browser, or the CLI. The desktop app starts its local daemon automatically. For remote work, run the daemon on another machine and connect directly or through the optional end-to-end encrypted relay. + +Paseo supports Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Muse Code, and additional ACP-compatible agents. The CLI, TypeScript SDK, and MCP tools expose agent and workspace operations for automation. Plugins extend providers, workflows, and the app's interface. Distribution: native apps for Mac, Windows, Linux, iOS, and Android; web app; Homebrew; npm. Source: Apache-2.0 at https://github.com/getpaseo/paseo. Marketing site: https://paseo.sh. `; diff --git a/packages/website/src/plugins/author-link.tsx b/packages/website/src/plugins/author-link.tsx index 769fc9cbf5..3a2be803f3 100644 --- a/packages/website/src/plugins/author-link.tsx +++ b/packages/website/src/plugins/author-link.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useMemo } from "react"; import { type Author, authorAvatarUrl } from "./registry"; const SIZE_CLASS = { + xs: "h-4 w-4 text-[8px]", sm: "h-5 w-5 text-[10px]", lg: "h-14 w-14 text-xl", } as const; diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 0cfbd805db..38b6393662 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -3,8 +3,10 @@ import { SiteShell } from "~/components/site-shell"; import { ContributeLinks } from "./contribute-links"; import type { InstallCounts } from "./installs"; import { type BrowseQuery, browseHref } from "./links"; +import { InstallCount } from "./install-count"; import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card"; import { PluginSearch } from "./plugin-search"; +import { PluginSection } from "./section"; import { addedAgo, CATEGORIES, @@ -14,7 +16,9 @@ import { mostInstalled, newestFirst, type Plugin, + recentlyUpdated, searchPlugins, + updatedAgo, } from "./registry"; import { WindowSwitch } from "./window-switch"; @@ -32,7 +36,7 @@ const TAB_OFF = `${TAB_BASE} border-transparent text-muted-foreground hover:text const CLEAR_CLASS = "text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground"; -/** All plugins or one category: sidebar, Most installed / Newest tabs, and the card grid. */ +/** All plugins or one category: sidebar, Most installed / Newest / Recently updated tabs, and the card grid. */ export function BrowsePage({ plugins, installs, @@ -45,17 +49,17 @@ export function BrowsePage({ query: BrowseQuery; }) { const category = query.category ? getCategory(query.category) : null; - const matches = query.q ? searchPlugins(plugins, query.q) : plugins; - const scoped = category ? getPluginsInCategory(matches, category.slug) : matches; - const results = - query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window); + const ordered = sortPlugins(plugins, installs, query); + // Search ranks by relevance; the tab's order breaks ties. + const matches = query.q ? searchPlugins(ordered, query.q) : ordered; + const results = category ? getPluginsInCategory(matches, category.slug) : matches; const title = category?.label ?? "All plugins"; const clearHref = browseHref({ ...query, q: undefined }); return ( <SiteShell width="wide"> <div className="lg:flex lg:gap-12"> <aside className="mb-8 lg:mb-0 lg:w-48 lg:flex-shrink-0"> - <PluginSearch scope={query} className="mb-4 lg:mb-6" /> + <PluginSearch scope={query} live className="mb-4 lg:mb-6" /> <CategoryNav plugins={matches} query={query} /> </aside> <div className="min-w-0 flex-1"> @@ -63,7 +67,7 @@ export function BrowsePage({ <h1 className="text-3xl font-medium tracking-tight"> {query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title} <span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground"> - {scoped.length} + {results.length} </span> </h1> {query.q && ( @@ -72,42 +76,69 @@ export function BrowsePage({ </a> )} </div> - <SortTabs query={query} /> - {results.length === 0 && query.q && ( - <div className="mt-8 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center"> - <p className="text-sm text-muted-foreground">No plugins match.</p> - <a - href={clearHref} - className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground" - > - Clear filters - </a> - </div> - )} - {results.length === 0 && !query.q && ( - <p className="mt-8 text-sm text-muted-foreground">No plugins in this category yet.</p> - )} - {results.length > 0 && ( - <div className={`mt-8 ${PLUGIN_GRID_CLASS}`}> - {results.map((plugin) => - query.sort === "new" ? ( - <PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} /> - ) : ( - <PluginCard - key={plugin.id} - plugin={plugin} - installs={installs[plugin.id]?.[query.window] ?? 0} - /> - ), - )} - </div> - )} + <PluginSection> + <SortTabs query={query} /> + {results.length === 0 && query.q && ( + <div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center"> + <p className="text-sm text-muted-foreground">No plugins match.</p> + <a + href={clearHref} + className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground" + > + Clear filters + </a> + </div> + )} + {results.length === 0 && !query.q && ( + <p className="text-sm text-muted-foreground"> + {query.sort === "updated" + ? "No plugins have been updated since they were listed." + : "No plugins in this category yet."} + </p> + )} + {results.length > 0 && ( + <div className={PLUGIN_GRID_CLASS}> + {results.map((plugin) => ( + <PluginCard key={plugin.id} plugin={plugin}> + <CardDetail plugin={plugin} installs={installs} now={now} query={query} /> + </PluginCard> + ))} + </div> + )} + </PluginSection> </div> </div> </SiteShell> ); } +function sortPlugins( + plugins: Plugin[], + installs: Record<string, InstallCounts>, + query: BrowseQuery, +): Plugin[] { + if (query.sort === "new") return newestFirst(plugins); + if (query.sort === "updated") return recentlyUpdated(plugins); + return mostInstalled(plugins, installs, query.window); +} + +/** What the card shows beside the author: the date the tab sorts by, or the install count. */ +function CardDetail({ + plugin, + installs, + now, + query, +}: { + plugin: Plugin; + installs: Record<string, InstallCounts>; + now: string; + query: BrowseQuery; +}) { + if (query.sort === "new") return addedAgo(plugin, now); + if (query.sort === "updated") return updatedAgo(plugin, now); + return <InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />; +} + interface NavEntry { href: string; label: string; @@ -186,7 +217,7 @@ function SortTabs({ query }: { query: BrowseQuery }) { <WindowSwitch current={query.window} hrefs={windowHrefs} /> ); return ( - <> + <div> <div className="flex items-baseline justify-between gap-6 border-b border-white/10"> <div className="flex gap-6"> <a @@ -203,10 +234,17 @@ function SortTabs({ query }: { query: BrowseQuery }) { > Newest </a> + <a + href={browseHref({ ...query, sort: "updated" })} + aria-current={query.sort === "updated" ? "page" : undefined} + className={query.sort === "updated" ? TAB_ON : TAB_OFF} + > + Recently updated + </a> </div> {windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>} </div> {windowSwitch && <div className="mt-4 sm:hidden">{windowSwitch}</div>} - </> + </div> ); } diff --git a/packages/website/src/plugins/contribute-links.tsx b/packages/website/src/plugins/contribute-links.tsx index 476146dfca..705615770d 100644 --- a/packages/website/src/plugins/contribute-links.tsx +++ b/packages/website/src/plugins/contribute-links.tsx @@ -21,27 +21,36 @@ export function ContributeLinks() { ); } -/** Closing section of the directory: what a plugin can do, with Read the docs and Submit buttons. */ +/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */ +export function ContributeButtons() { + return ( + <> + <a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}> + Submit a plugin + </a> + <a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}> + Read the docs + </a> + </> + ); +} + +/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */ export function ContributeSection() { return ( <section aria-labelledby="build-your-own" - className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 sm:px-8" + className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 text-center sm:px-8" > <h2 id="build-your-own" className="text-lg font-medium"> Build your own plugin </h2> - <p className="mt-2 max-w-lg text-sm text-muted-foreground"> + <p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground"> Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one with the plugin docs, then submit it to get it listed here. </p> - <div className="mt-6 flex flex-wrap gap-3"> - <a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}> - Read the docs - </a> - <a href={SUBMIT_URL} className={SECONDARY_BUTTON_CLASS}> - Submit a plugin - </a> + <div className="mt-6 flex flex-wrap justify-center gap-3"> + <ContributeButtons /> </div> </section> ); diff --git a/packages/website/src/plugins/hero.tsx b/packages/website/src/plugins/hero.tsx new file mode 100644 index 0000000000..0683a0d6e0 --- /dev/null +++ b/packages/website/src/plugins/hero.tsx @@ -0,0 +1,37 @@ +import { ContributeButtons } from "./contribute-links"; +import type { BrowseQuery } from "./links"; +import { PluginSearch } from "./plugin-search"; + +interface PluginsHeroProps { + pluginCount: number; + authorCount: number; + /** Where the search box sends its term. */ + searchScope: BrowseQuery; +} + +function plural(count: number, word: string): string { + return `${count} ${word}${count === 1 ? "" : "s"}`; +} + +/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */ +export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) { + return ( + <header> + <h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> + <span className="text-3xl font-medium tracking-tight">Plugins</span> + <span className="text-sm font-normal tabular-nums text-extra-muted-foreground"> + {plural(pluginCount, "plugin")} by {plural(authorCount, "author")} + </span> + </h1> + <p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground"> + Browse community plugins that extend Paseo with new functionality. + </p> + <div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex flex-wrap gap-3"> + <ContributeButtons /> + </div> + <PluginSearch scope={searchScope} className="w-full sm:w-64" /> + </div> + </header> + ); +} diff --git a/packages/website/src/plugins/install-count.tsx b/packages/website/src/plugins/install-count.tsx new file mode 100644 index 0000000000..198905ddea --- /dev/null +++ b/packages/website/src/plugins/install-count.tsx @@ -0,0 +1,13 @@ +import { Download } from "lucide-react"; +import { formatInstalls } from "./registry"; + +/** An install count as a download icon and the number; inherits the surrounding text style. */ +export function InstallCount({ count }: { count: number }) { + return ( + <span className="relative inline-flex items-center gap-1 tabular-nums"> + <Download aria-hidden className="h-3 w-3" /> + {formatInstalls(count)} + <span className="sr-only">installs</span> + </span> + ); +} diff --git a/packages/website/src/plugins/installs.ts b/packages/website/src/plugins/installs.ts index 01846e6797..78b4294639 100644 --- a/packages/website/src/plugins/installs.ts +++ b/packages/website/src/plugins/installs.ts @@ -12,6 +12,26 @@ const WINDOW_DAYS = { week: 7, month: 30 } as const; const totalKey = (id: string) => `plugin-installs:${id}`; const dailyKey = (id: string) => `plugin-installs-daily:${id}`; +export async function recordClientInstall({ + cache, + id, + ip, +}: { + cache: KVNamespace; + id: string; + ip: string | null; +}): Promise<void> { + if (!ip) return; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip)); + const hash = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + const key = `plugin-installs-client:${id}:${hash}`; + if ((await cache.get(key)) !== null) return; + await cache.put(key, "1", { expirationTtl: 3600 }); + await recordInstall(cache, id, new Date()); +} + export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise<void> { const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]); await Promise.all([ diff --git a/packages/website/src/plugins/launch.ts b/packages/website/src/plugins/launch.ts index bc784696de..bfe0d00385 100644 --- a/packages/website/src/plugins/launch.ts +++ b/packages/website/src/plugins/launch.ts @@ -1,2 +1 @@ -// Keep navigation on paseo.cafe until the coordinated directory announcement. -export const PLUGINS_LINKED = false; +export const PLUGINS_LINKED = true; diff --git a/packages/website/src/plugins/links.ts b/packages/website/src/plugins/links.ts index 6e785b4df0..18eab1535f 100644 --- a/packages/website/src/plugins/links.ts +++ b/packages/website/src/plugins/links.ts @@ -5,7 +5,7 @@ export const DOCS_URL = "/docs/plugins"; export const SUBMIT_URL = "https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml"; -export type BrowseSort = "installs" | "new"; +export type BrowseSort = "installs" | "new" | "updated"; export interface BrowseQuery { /** Absent for all plugins. */ category?: CategorySlug; @@ -24,7 +24,7 @@ export function parseSearchTerm(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } export function parseSort(value: unknown): BrowseSort | undefined { - return value === "new" ? value : undefined; + return value === "new" || value === "updated" ? value : undefined; } export function pluginHref(id: string): string { @@ -37,7 +37,7 @@ export function browseHref({ category, sort, window, q }: BrowseQuery): string { const path = category ? categoryHref(category) : "/plugins/all"; const params = new URLSearchParams(); if (q) params.set("q", q); - if (sort === "new") params.set("sort", sort); + if (sort !== "installs") params.set("sort", sort); else if (window !== DEFAULT_WINDOW) params.set("window", window); const search = params.toString(); return search ? `${path}?${search}` : path; diff --git a/packages/website/src/plugins/loader.ts b/packages/website/src/plugins/loader.ts index 27416a4125..ebc9bf8272 100644 --- a/packages/website/src/plugins/loader.ts +++ b/packages/website/src/plugins/loader.ts @@ -11,14 +11,19 @@ import { function registryBase(): string { return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; } -/** Listed plugins in index order, their install counts per window, and the server's clock. */ +/** Listed plugins in index order, featured plugin IDs, install counts per window, and the server's clock. */ export const getRegistry = createServerFn({ method: "GET" }).handler(async () => { const context = getWebsiteCacheContext(); const [index, installs] = await Promise.all([ loadRegistryIndex(registryBase(), context), loadInstallCounts(registryBase(), context), ]); - return { plugins: index.plugins, installs, now: new Date().toISOString() }; + return { + plugins: index.plugins, + featured: index.featured, + installs, + now: new Date().toISOString(), + }; }); export const getRegistryPlugin = createServerFn({ method: "GET" }) .validator((id: string) => id) diff --git a/packages/website/src/plugins/media-gallery.tsx b/packages/website/src/plugins/media-gallery.tsx new file mode 100644 index 0000000000..ac25a6f53e --- /dev/null +++ b/packages/website/src/plugins/media-gallery.tsx @@ -0,0 +1,231 @@ +import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; +import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry"; +import { ChevronLeft, ChevronRight, Play, X } from "lucide-react"; +import { + type CSSProperties, + type KeyboardEvent, + type MouseEvent, + type SyntheticEvent, + useCallback, + useMemo, + useRef, + useState, +} from "react"; + +const TILE_CLASS = + "relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%]"; +// Two tiles fill the row; with more, a third peeks at the right edge to show the strip scrolls. +const FILLING_TILE_CLASS = `${TILE_CLASS} md:w-[calc(50%-0.375rem)]`; +const PEEKING_TILE_CLASS = `${TILE_CLASS} md:w-[42%]`; +// The viewer's room: media never exceeds this box, which leaves a margin around it. +const VIEW_WIDTH = "90vw"; +const VIEW_HEIGHT = "85vh"; +const VIEW_CLASS = "max-h-[85vh] max-w-[90vw] rounded-lg object-contain"; +const VIEWER_BUTTON_CLASS = + "fixed rounded-full bg-black/60 p-2 text-white transition-colors hover:bg-black/80"; +const ARROW_STEPS: Partial<Record<string, number>> = { ArrowLeft: -1, ArrowRight: 1 }; + +interface MediaItem { + url: string; + kind: "image" | "video"; + /** Image alt text, or the video's accessible name. */ + label: string; +} + +/** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */ +export function MediaGallery({ name, media }: { name: string; media: string[] }) { + const dialog = useRef<HTMLDialogElement>(null); + const entries = useMemo( + () => media.map((url, index) => mediaEntry(name, url, index)), + [name, media], + ); + const items = useMemo(() => entries.filter((entry) => typeof entry !== "string"), [entries]); + const [viewing, setViewing] = useState<number | null>(null); + const item = viewing === null ? undefined : items[viewing]; + const open = useCallback( + (opened: MediaItem) => { + setViewing(items.indexOf(opened)); + dialog.current?.showModal(); + }, + [items], + ); + const close = useCallback(() => dialog.current?.close(), []); + // Unmounting the viewed media stops a playing video. + const handleClose = useCallback(() => setViewing(null), []); + const closeOnBackdrop = useCallback((event: MouseEvent<HTMLDialogElement>) => { + if (event.target === event.currentTarget) event.currentTarget.close(); + }, []); + const step = useCallback( + (by: number) => + setViewing((index) => (index === null ? null : (index + by + items.length) % items.length)), + [items.length], + ); + const showPrevious = useCallback(() => step(-1), [step]); + const showNext = useCallback(() => step(1), [step]); + // Captured so arrow keys move between items even while a focused video would seek. + const stepWithArrowKeys = useCallback( + (event: KeyboardEvent<HTMLDialogElement>) => { + const by = ARROW_STEPS[event.key]; + if (by === undefined || items.length < 2) return; + event.preventDefault(); + step(by); + }, + [items.length, step], + ); + + if (media.length === 0) return null; + const tileClass = items.length > 2 ? PEEKING_TILE_CLASS : FILLING_TILE_CLASS; + return ( + <> + <div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0"> + {entries.map((entry) => + typeof entry === "string" ? ( + <p key={entry}>{entry}</p> + ) : ( + <MediaTile key={entry.url} item={entry} className={tileClass} onOpen={open} /> + ), + )} + </div> + {/* Focusable so arrow keys still reach it after a click on non-focusable media. */} + <dialog + ref={dialog} + tabIndex={-1} + aria-label={item?.label} + onClose={handleClose} + onClick={closeOnBackdrop} + onKeyDownCapture={stepWithArrowKeys} + className="m-auto overflow-visible bg-transparent p-0 outline-none backdrop:bg-black/80" + > + {item?.kind === "image" && ( + <img key={viewing} src={item.url} alt={item.label} className={VIEW_CLASS} /> + )} + {item?.kind === "video" && <ViewerVideo key={viewing} url={item.url} label={item.label} />} + {items.length > 1 && ( + <> + <button + type="button" + aria-label="Previous" + onClick={showPrevious} + className={`${VIEWER_BUTTON_CLASS} left-4 top-1/2 -translate-y-1/2`} + > + <ChevronLeft className="h-5 w-5" /> + </button> + <button + type="button" + aria-label="Next" + onClick={showNext} + className={`${VIEWER_BUTTON_CLASS} right-4 top-1/2 -translate-y-1/2`} + > + <ChevronRight className="h-5 w-5" /> + </button> + </> + )} + <button + type="button" + aria-label="Close" + onClick={close} + className={`${VIEWER_BUTTON_CLASS} right-4 top-4`} + > + <X className="h-5 w-5" /> + </button> + </dialog> + </> + ); +} + +/** A registry media entry as a gallery item, or the raw entry when it is not a safe URL. */ +function mediaEntry(name: string, url: string, index: number): MediaItem | string { + const source = pluginOverviewUrl(url); + if (!source) return url; + const kind = pluginMediaKind(source); + return { + url: source, + kind, + label: `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`, + }; +} + +/** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */ +function ViewerVideo({ url, label }: { url: string; label: string }) { + const [aspectRatio, setAspectRatio] = useState<number | null>(null); + const readAspectRatio = useCallback((event: SyntheticEvent<HTMLVideoElement>) => { + const { videoWidth, videoHeight } = event.currentTarget; + setAspectRatio(videoWidth / videoHeight); + }, []); + const style = useMemo<CSSProperties | undefined>( + () => + aspectRatio === null + ? undefined + : { aspectRatio, width: `min(${VIEW_WIDTH}, ${VIEW_HEIGHT} * ${aspectRatio})` }, + [aspectRatio], + ); + return ( + <video + src={url} + aria-label={label} + controls + autoPlay + playsInline + onLoadedMetadata={readAspectRatio} + style={style} + className={VIEW_CLASS} + /> + ); +} + +function MediaTile({ + item, + className, + onOpen, +}: { + item: MediaItem; + className: string; + onOpen: (item: MediaItem) => void; +}) { + const { url, kind, label } = item; + const handleClick = useCallback( + (event: MouseEvent<HTMLAnchorElement>) => { + // Modified clicks keep the link's own behavior, such as opening the file in a new tab. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + onOpen(item); + }, + [onOpen, item], + ); + return ( + <a + href={url} + {...pluginOverviewPolicy.link} + aria-label={kind === "video" ? label : undefined} + onClick={handleClick} + className={className} + > + {kind === "image" ? ( + <img + src={url} + alt={label} + loading="lazy" + className="h-full w-full object-cover object-top" + /> + ) : ( + <> + <video + src={url} + preload="metadata" + muted + playsInline + className="h-full w-full object-cover object-top" + /> + <span + aria-hidden + className="absolute inset-0 flex items-center justify-center bg-black/10" + > + <span className="rounded-full bg-black/60 p-3 text-white"> + <Play className="h-5 w-5 fill-current" /> + </span> + </span> + </> + )} + </a> + ); +} diff --git a/packages/website/src/plugins/overview.test.tsx b/packages/website/src/plugins/overview.test.tsx new file mode 100644 index 0000000000..45bccecb37 --- /dev/null +++ b/packages/website/src/plugins/overview.test.tsx @@ -0,0 +1,147 @@ +import registry from "../../e2e/registry.fixture.json"; +import { PluginTile } from "./plugin-tile"; +import { PluginCard } from "./plugin-card"; +import { authorAvatarUrl, getAuthor, type Plugin } from "./registry"; +import React, { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseFragment, type DefaultTreeAdapterMap } from "parse5"; +import { describe, expect, it } from "vitest"; +import corpus from "../../../protocol/tests/fixtures/plugin-overview.json"; +import { PluginOverview } from "./overview"; + +type Element = DefaultTreeAdapterMap["element"]; +function elements(html: string): Element[] { + const result: Element[] = []; + function visit(node: DefaultTreeAdapterMap["node"]) { + if ("tagName" in node) result.push(node); + if ("childNodes" in node) node.childNodes.forEach(visit); + } + visit(parseFragment(html)); + return result; +} +function render(markdown: string) { + return renderToStaticMarkup(<PluginOverview>{markdown}</PluginOverview>); +} +function attribute(element: Element, name: string) { + return element.attrs.find((attr) => attr.name === name)?.value; +} +function imageSources(html: string) { + return elements(html) + .filter((node) => node.tagName === "img") + .map((node) => attribute(node, "src")); +} +/** Cards show the author's GitHub avatar beside the plugin's own images. */ +function avatarUrl(plugin: Plugin) { + return authorAvatarUrl(getAuthor(plugin)); +} +const allowedTags = new Set([ + "div", + "p", + "br", + "hr", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "ul", + "ol", + "li", + "strong", + "em", + "del", + "pre", + "code", + "a", + "img", + "table", + "thead", + "tbody", + "tr", + "th", + "td", +]); + +describe("plugin overview security corpus", () => { + it.each(corpus)("$name", ({ markdown, links, images }) => { + const nodes = elements(render(markdown)); + expect( + nodes.filter((node) => !allowedTags.has(node.tagName)).map((node) => node.tagName), + ).toEqual([]); + expect( + nodes.flatMap((node) => node.attrs).filter((attr) => /^on|^style$|^srcdoc$/i.test(attr.name)), + ).toEqual([]); + const anchors = nodes.filter((node) => node.tagName === "a"); + expect(anchors.map((node) => attribute(node, "href"))).toEqual(links); + for (const anchor of anchors) { + expect(attribute(anchor, "rel")).toBe("noopener noreferrer nofollow"); + expect(attribute(anchor, "target")).toBe("_blank"); + } + expect( + nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")), + ).toEqual(images); + }); + + it.each(corpus)("preserves $name inside code spans and fences", ({ markdown }) => { + for (const code of [ + `\`${markdown.replaceAll("\n", " ")}\``, + `\`\`\`html\n${markdown}\n\`\`\``, + ]) { + const nodes = elements(render(code)); + expect( + nodes.filter((node) => ["a", "img", "script", "iframe", "svg"].includes(node.tagName)), + ).toEqual([]); + const text = nodes + .filter((node) => node.tagName === "code") + .flatMap((node) => node.childNodes) + .map((child) => ("value" in child ? child.value : "")) + .join(""); + expect(text.trim()).toBe( + code.startsWith("```html") ? markdown : markdown.replaceAll("\n", " "), + ); + } + }); + + it("keeps long untrusted input inert", () => { + const nodes = elements( + render("a".repeat(100_000) + "\n\n" + "[click](javascript:alert(1)) ".repeat(1_000)), + ); + expect(nodes.filter((node) => ["a", "img", "script"].includes(node.tagName))).toEqual([]); + }); +}); + +describe("registry images", () => { + it.each([ + "http://example.com/x", + "data:image/png;base64,AAAA", + "javascript:alert(1)", + "vbscript:msgbox(1)", + "./icon.png", + "//example.com/x", + ])("does not load %s in cards or icons", (url) => { + const plugin = { ...registry.plugins[0], icon: url, media: [url] } as Plugin; + for (const component of [ + createElement(PluginTile, { plugin, size: "sm" }), + createElement(PluginCard, { plugin }), + ]) { + expect( + imageSources(renderToStaticMarkup(component)).filter((src) => src !== avatarUrl(plugin)), + ).toEqual([]); + } + }); +}); + +it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () => { + const source = "https://example.com/screenshot.png"; + const plugin = { + ...registry.plugins[0], + icon: undefined, + media: ["data:image/png;base64,AAAA", source], + } as Plugin; + expect(imageSources(renderToStaticMarkup(createElement(PluginCard, { plugin })))).toEqual([ + source, + avatarUrl(plugin), + ]); +}); diff --git a/packages/website/src/plugins/overview.tsx b/packages/website/src/plugins/overview.tsx new file mode 100644 index 0000000000..8fc680f9a9 --- /dev/null +++ b/packages/website/src/plugins/overview.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import ReactMarkdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; + +/** A rejected destination leaves its label as text, without even an empty anchor. */ +export function PluginContentLink({ + href, + children, + className, +}: { + href?: string; + children: ReactNode; + className?: string; +}) { + const destination = pluginOverviewUrl(href); + return destination ? ( + <a href={destination} {...pluginOverviewPolicy.link} className={className}> + {children} + </a> + ) : ( + children + ); +} + +const allowedElements = [...pluginOverviewPolicy.elements]; +const remarkPlugins = [remarkGfm]; + +const components: Components = { + a: ({ href, children }) => <PluginContentLink href={href}>{children}</PluginContentLink>, + img: ({ src, alt }) => { + const source = pluginOverviewUrl(typeof src === "string" ? src : undefined); + return source ? <img src={source} alt={alt ?? ""} loading="lazy" /> : alt; + }, + // GFM alignment otherwise becomes an inline style in react-markdown. + th: ({ children }) => <th>{children}</th>, + td: ({ children }) => <td>{children}</td>, +}; + +/** Untrusted overviews do not inherit documentation directives, anchors, or HTML highlighters. */ +export function PluginOverview({ children }: { children: string }) { + return ( + <div className="docs-prose"> + <ReactMarkdown + skipHtml={!pluginOverviewPolicy.rawHtml} + allowedElements={allowedElements} + unwrapDisallowed + remarkPlugins={remarkPlugins} + urlTransform={pluginOverviewUrl} + components={components} + > + {children} + </ReactMarkdown> + </div> + ); +} diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index d8fdb01fc4..c46b9f553a 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -1,14 +1,17 @@ -import { Download } from "lucide-react"; +import type { ReactNode } from "react"; +import { AuthorAvatar } from "./author-link"; +import { InstallCount } from "./install-count"; +import { pluginCardScreenshot } from "./thumbnails"; import { pluginHref } from "./links"; import { PluginTile } from "./plugin-tile"; -import { formatInstalls, getCategory, type Plugin } from "./registry"; +import { firstMediaImage, getAuthor, getCategory, type Plugin } from "./registry"; export const PLUGIN_GRID_CLASS = "grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"; -/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */ +/** First allowed image, or the plugin tile on a quiet backdrop when there is none. */ function PluginShot({ plugin }: { plugin: Plugin }) { - const url = plugin.screenshots[0]; + const url = firstMediaImage(plugin); return ( <div aria-hidden @@ -16,9 +19,10 @@ function PluginShot({ plugin }: { plugin: Plugin }) { > {url ? ( <img - src={url} + {...pluginCardScreenshot(plugin.id, url)} alt="" loading="lazy" + decoding="async" className="h-full w-full object-cover object-left-top" /> ) : ( @@ -30,57 +34,33 @@ function PluginShot({ plugin }: { plugin: Plugin }) { ); } -/** What's new card: screenshot, name, and when it was added. */ -export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) { +/** + * Gallery card, in three rows under the screenshot: tile and name, description, then author and + * `children`, such as when the plugin was added or its install count. + */ +export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) { + const author = getAuthor(plugin); return ( - <a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto"> + // Grid items size to their content by default; min-w-0 keeps the card in its column. + <a href={pluginHref(plugin.id)} className="group block min-w-0"> <PluginShot plugin={plugin} /> - <div className="mt-3 flex items-center gap-2.5"> - <PluginTile plugin={plugin} size="sm" /> - <div className="min-w-0"> - <p className="truncate text-sm font-medium text-white">{plugin.name}</p> - <p className="truncate text-xs text-extra-muted-foreground">Added {added}</p> - </div> - </div> - </a> - ); -} - -/** Grid card: screenshot, name, one muted stat, and a two-line description. */ -export function PluginCard({ - plugin, - installs, - added, -}: { - plugin: Plugin; - /** Shown with a download icon; otherwise `added` is shown. */ - installs?: number; - added?: string; -}) { - return ( - <a href={pluginHref(plugin.id)} className="group block"> - <PluginShot plugin={plugin} /> - <div className="mt-3 flex items-start gap-2.5"> - <PluginTile plugin={plugin} size="sm" /> - <div className="min-w-0 flex-1"> - <div className="flex items-baseline justify-between gap-2"> - <p className="truncate text-sm font-medium text-white">{plugin.name}</p> - <span className="inline-flex flex-shrink-0 items-center gap-1 text-xs tabular-nums text-extra-muted-foreground"> - {installs === undefined ? ( - added - ) : ( - <> - <Download className="h-3 w-3" /> - {formatInstalls(installs)} - </> - )} - </span> - </div> - <p className="mt-0.5 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground"> - {plugin.description} - </p> - </div> + <div className="mt-3 flex items-center gap-2"> + <PluginTile plugin={plugin} size="xs" /> + <p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p> </div> + <p className="mt-2 line-clamp-2 min-h-10 break-words text-xs leading-5 text-muted-foreground"> + {plugin.description} + </p> + <p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground"> + <AuthorAvatar author={author} size="xs" /> + <span className="truncate">{author.name}</span> + {children !== undefined && ( + <> + <span aria-hidden>·</span> + <span className="flex-shrink-0">{children}</span> + </> + )} + </p> </a> ); } @@ -98,11 +78,9 @@ export function PluginRankRow({ return ( <a href={pluginHref(plugin.id)} - className="flex items-center gap-3 rounded-lg px-2 py-2.5 transition-colors hover:bg-white/[0.04]" + className="flex items-center gap-3 rounded-lg px-4 py-2.5 transition-colors hover:bg-white/[0.04]" > - <span className="w-4 text-right text-sm tabular-nums text-extra-muted-foreground"> - {rank} - </span> + <span className="text-sm tabular-nums text-extra-muted-foreground">{rank}</span> <PluginTile plugin={plugin} size="sm" /> <div className="min-w-0 flex-1"> <p className="truncate text-sm text-white">{plugin.name}</p> @@ -110,7 +88,9 @@ export function PluginRankRow({ {getCategory(plugin.categories[0])?.label} </p> </div> - <span className="text-xs tabular-nums text-muted-foreground">{formatInstalls(installs)}</span> + <span className="text-xs text-muted-foreground"> + <InstallCount count={installs} /> + </span> </a> ); } diff --git a/packages/website/src/plugins/plugin-search.tsx b/packages/website/src/plugins/plugin-search.tsx index 042e5e81e5..400341d5a4 100644 --- a/packages/website/src/plugins/plugin-search.tsx +++ b/packages/website/src/plugins/plugin-search.tsx @@ -1,37 +1,73 @@ -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouter } from "@tanstack/react-router"; import { Search, X } from "lucide-react"; -import { type ChangeEvent, type FormEvent, useCallback, useRef, useState } from "react"; -import { type BrowseQuery, browseHref, DEFAULT_WINDOW } from "./links"; +import { type ChangeEvent, type FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import { type BrowseQuery, browseHref, DEFAULT_WINDOW, parseSearchTerm } from "./links"; const ICON_CLASS = "h-3.5 w-3.5 text-extra-muted-foreground"; /** - * Search box for the plugin pages. Typing replaces the URL with the browse page for the term, - * keeping `scope`'s category and sort. Without JavaScript the form submits the same URL. + * Search box for the plugin pages. Submitting opens the browse page for the term, keeping + * `scope`'s category and sort. With `live`, typing filters in place instead: the first character + * of a query pushes one history entry and later edits replace it, so Back leaves the search. + * Without JavaScript the form submits the same URL. */ -export function PluginSearch({ scope, className }: { scope: BrowseQuery; className?: string }) { +export function PluginSearch({ + scope, + live = false, + className, +}: { + scope: BrowseQuery; + live?: boolean; + className?: string; +}) { const navigate = useNavigate(); + const router = useRouter(); const input = useRef<HTMLInputElement>(null); const [term, setTerm] = useState(scope.q ?? ""); - const search = useCallback( - (next: string) => { - setTerm(next); - void navigate({ - href: browseHref({ ...scope, q: next.trim() ? next : undefined }), - replace: true, - }); + // Back and Forward between results of the same page keep this box mounted; show their term. + useEffect( + () => + router.history.subscribe(({ location, action }) => { + if (action.type === "PUSH" || action.type === "REPLACE") return; + setTerm(parseSearchTerm(new URLSearchParams(location.search).get("q")) ?? ""); + }), + [router], + ); + const show = useCallback( + (next: string, { replace }: { replace: boolean }) => { + void navigate({ href: browseHref({ ...scope, q: next.trim() ? next : undefined }), replace }); }, [navigate, scope], ); + const edit = useCallback( + (next: string) => { + setTerm(next); + if (live) show(next, { replace: Boolean(term.trim()) }); + }, + [live, show, term], + ); + // A term typed before the page hydrated is in the box but not in state; adopt it. Once hydrated, + // the box always matches state, so this does nothing. + useEffect(() => { + const typed = input.current?.value ?? ""; + if (typed !== term) edit(typed); + }, [edit, term]); const handleChange = useCallback( - (event: ChangeEvent<HTMLInputElement>) => search(event.target.value), - [search], + (event: ChangeEvent<HTMLInputElement>) => edit(event.target.value), + [edit], ); const handleClear = useCallback(() => { - search(""); + edit(""); input.current?.focus(); - }, [search]); - const handleSubmit = useCallback((event: FormEvent) => event.preventDefault(), []); + }, [edit]); + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + // Live results already show the term. + if (!live) show(term, { replace: false }); + }, + [live, show, term], + ); return ( <form role="search" @@ -40,7 +76,7 @@ export function PluginSearch({ scope, className }: { scope: BrowseQuery; classNa onSubmit={handleSubmit} className={className} > - {scope.sort === "new" && <input type="hidden" name="sort" value="new" />} + {scope.sort !== "installs" && <input type="hidden" name="sort" value={scope.sort} />} {scope.sort === "installs" && scope.window !== DEFAULT_WINDOW && ( <input type="hidden" name="window" value={scope.window} /> )} diff --git a/packages/website/src/plugins/plugin-tile.tsx b/packages/website/src/plugins/plugin-tile.tsx index 01234ac8b8..5e2dc9c31b 100644 --- a/packages/website/src/plugins/plugin-tile.tsx +++ b/packages/website/src/plugins/plugin-tile.tsx @@ -1,6 +1,8 @@ +import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import type { Plugin } from "./registry"; const SIZE_CLASS = { + xs: "h-6 w-6 rounded-md text-xs", sm: "h-9 w-9 rounded-lg text-sm", lg: "h-14 w-14 rounded-xl text-xl", } as const; @@ -12,7 +14,7 @@ export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeo aria-hidden className={`flex flex-shrink-0 items-center justify-center border border-white/10 bg-white/[0.06] font-medium text-white/80 ${SIZE_CLASS[size]}`} > - {plugin.icon ? ( + {pluginOverviewUrl(plugin.icon) ? ( <img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" /> ) : ( plugin.name.charAt(0).toUpperCase() diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts index 4d6826f11e..b57d16ec2a 100644 --- a/packages/website/src/plugins/published.ts +++ b/packages/website/src/plugins/published.ts @@ -4,8 +4,9 @@ import { PublishedPluginDetailSchema, } from "@getpaseo/protocol/plugin-registry"; import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache"; +import { handlePluginThumbnailRequest } from "./thumbnails"; import { CATEGORIES } from "./categories"; -import { type InstallCounts, readInstallCounts, recordInstall } from "./installs"; +import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs"; export interface RegistryEnvironment { PLUGINS_REGISTRY_URL?: string; WEBSITE_CACHE?: KVNamespace; @@ -63,6 +64,10 @@ export async function handlePluginRegistryRequest( const url = new URL(request.url); if (request.method !== "GET") return null; const base = env.PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; + if (url.pathname.startsWith("/plugins/thumb/")) { + const index = await loadRegistryIndex(base, context); + return handlePluginThumbnailRequest(request, index.plugins); + } if (url.hostname === "plugins.paseo.sh" && url.pathname === "/index.json") return Response.json(await loadRegistryIndex(base, context)); if (url.pathname === "/sitemap-plugins.xml") { @@ -111,7 +116,12 @@ export async function handlePluginRegistryRequest( url.searchParams.get("intent") === "install" ) { const cache = context.cache; - if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined)); + if (cache) + context.waitUntil( + recordClientInstall({ cache, id, ip: request.headers.get("CF-Connecting-IP") }).catch( + () => undefined, + ), + ); } return Response.json(plugin); } diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts index d5244fcb3f..d8f343b266 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -1,16 +1,22 @@ +import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { describe, expect, it } from "vitest"; import { + addedAgo, + featuredPlugins, formatInstalls, installCommand, mostInstalled, newestFirst, type Plugin, readmeBody, + recentlyUpdated, + reportBugUrl, searchPlugins, + updatedAgo, } from "./registry"; import { readInstallCounts, recordInstall } from "./installs"; -import { loadRegistryIndex, loadRegistryPlugin } from "./published"; +import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published"; const plugin: Plugin = { id: "acme/example", name: "Example", @@ -25,7 +31,7 @@ const plugin: Plugin = { resolved: "https://registry.npmjs.org/example.tgz", integrity: "sha512-YWJj", }, - screenshots: [], + media: [], submittedAt: "2026-10-03", reviewedAt: "2026-10-03", updatedAt: "2026-10-03", @@ -39,6 +45,7 @@ describe("plugin registry", () => { registry: { name: "Internal", url: "https://example.test" }, categories: [], plugins: [plugin], + featured: [plugin.id], generatedAt: "2026-10-03", }; const detail = { ...plugin, readme: "# Example" }; @@ -59,8 +66,70 @@ describe("plugin registry", () => { await new Promise<void>((resolve) => server.close(() => resolve())); } }); - it("orders by submission date and by installs in a window, keeping index order for ties", () => { - const older = { ...plugin, id: "acme/older", submittedAt: "2026-09-01" }; + it("features listed plugins once each, in the registry's order, dropping IDs that are not listed", () => { + const listed = [ + { ...plugin, id: "acme/first" }, + { ...plugin, id: "acme/second" }, + { ...plugin, id: "acme/third" }, + ]; + const featured = ["acme/third", "acme/not-published", "acme/first", "acme/third"]; + expect(featuredPlugins(listed, featured).map((p) => p.id)).toEqual([ + "acme/third", + "acme/first", + ]); + expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]); + }); + it("lists what's new by first listing time, so neither a slow review nor an update moves a plugin", () => { + const listedFirst = { + ...plugin, + id: "acme/listed-first", + submittedAt: "2026-09-30T00:00:00.000Z", + publishedAt: "2026-09-30T06:00:00.000Z", + reviewedAt: "2026-10-02T00:00:00.000Z", + updatedAt: "2026-10-02T00:00:00.000Z", + }; + const listedLater = { + ...plugin, + id: "acme/listed-later", + submittedAt: "2026-09-02T00:00:00.000Z", + publishedAt: "2026-09-30T08:00:00.000Z", + reviewedAt: "2026-09-30T08:00:00.000Z", + updatedAt: "2026-09-30T08:00:00.000Z", + }; + expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([ + "acme/listed-later", + "acme/listed-first", + ]); + const now = "2026-10-03T12:00:00.000Z"; + expect(addedAgo(listedLater, now)).toBe("3d ago"); + expect(updatedAgo(listedFirst, now)).toBe("yesterday"); + }); + it("lists only plugins updated after their first listing as recently updated, latest update first", () => { + const bumped = { + ...plugin, + id: "acme/bumped", + publishedAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-10-01T09:00:00.000Z", + }; + const bumpedLater = { + ...plugin, + id: "acme/bumped-later", + publishedAt: "2026-09-20T00:00:00.000Z", + updatedAt: "2026-10-01T17:00:00.000Z", + }; + const neverUpdated = { + ...plugin, + id: "acme/never-updated", + publishedAt: "2026-10-02T00:00:00.000Z", + updatedAt: "2026-10-02T00:00:00.000Z", + }; + expect(recentlyUpdated([bumped, neverUpdated, bumpedLater]).map((p) => p.id)).toEqual([ + "acme/bumped-later", + "acme/bumped", + ]); + }); + it("orders by first listing time and by installs in a window, keeping index order for ties", () => { + const older = { ...plugin, id: "acme/older", publishedAt: "2026-09-01" }; const twin = { ...plugin, id: "acme/twin" }; expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([ "acme/example", @@ -94,6 +163,82 @@ describe("plugin registry", () => { Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")), ).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]); }); + it("counts repeated install reports from the same IP once per hour", async () => { + let now = Date.now(); + const cache = memoryKv(() => now); + const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, headers); + now += 3_599_000; + await resolvePlugin(cache, headers); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 1, month: 1, all: 1 }, + }); + now += 1_000; + await resolvePlugin(cache, headers); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 2, month: 2, all: 2 }, + }); + }); + it("counts different IPs and plugins independently, including query install intent", async () => { + const cache = memoryKv(); + const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, first); + await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" }); + await resolvePlugin(cache, first, { id: "acme/other" }); + await resolvePlugin(cache, first, { query: "?intent=install" }); + await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true }); + expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({ + [plugin.id]: { week: 2, month: 2, all: 2 }, + "acme/other": { week: 1, month: 1, all: 1 }, + }); + const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex"); + expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1"); + expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1"); + expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1"); + }); + it("does not count without a Cloudflare IP or install intent", async () => { + const cache = memoryKv(); + await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" }); + await resolvePlugin(cache, {}, { query: "?intent=install" }); + await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" }); + await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" }); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 0, month: 0, all: 0 }, + }); + expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]); + }); + it("preserves historical totals and the installs endpoint response", async () => { + const cache = memoryKv(); + await cache.put(`plugin-installs:${plugin.id}`, "40"); + const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, headers); + await resolvePlugin(cache, headers); + const base = "https://registry.example.test"; + await cache.put( + `plugins:index:v1:${base}`, + JSON.stringify({ + fetchedAt: Date.now(), + value: { + schemaVersion: 1, + registry: { name: "Internal", url: base }, + categories: [], + plugins: [plugin], + featured: [], + generatedAt: "2026-10-03", + }, + }), + ); + const response = await handlePluginRegistryRequest( + new Request("https://paseo.sh/api/plugins/installs"), + { PLUGINS_REGISTRY_URL: base }, + { cache, waitUntil: () => undefined }, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ [plugin.id]: 41 }); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 1, month: 1, all: 41 }, + }); + }); it("searches name, description, ID, and author, ignoring case", () => { const other = { ...plugin, @@ -110,6 +255,32 @@ describe("plugin registry", () => { expect(searchPlugins(plugins, " ")).toEqual(plugins); expect(searchPlugins(plugins, "nothing")).toEqual([]); }); + it("ranks name, then author, then description matches, keeping the given order on ties", () => { + const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({ + ...plugin, + id, + name, + description, + author: { github }, + }); + // Given in install order: description-only matches first, the exact name last. + const plugins = [ + named("acme/editor", "Remote Editor", "Composer pill to open a workspace"), + named("acme/kit", "PromptKit", "Rewrite prompts"), + named("omp/tools", "Tools", "Helpers", "omp"), + named("acme/history", "History", "Inspect raw composer records"), + named("acme/omp", "OMP", "Paseo integration for OMP"), + ]; + expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([ + "acme/omp", + "acme/kit", + "omp/tools", + "acme/editor", + "acme/history", + ]); + expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]); + expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]); + }); it("strips the README title and quoted description that the page already shows", () => { expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n"); @@ -119,22 +290,63 @@ describe("plugin registry", () => { ); }); it("offers a registry install command", () => { - expect(installCommand(plugin)).toBe("paseo plugin install acme/example"); + expect(installCommand(plugin)).toBe("paseo plugin add acme/example"); expect(formatInstalls(1250)).toBe("1.3k"); }); + it("reports bugs on the source repository's GitHub issue tracker", () => { + const at = (url: string) => reportBugUrl({ ...plugin, repository: { url } }); + expect(at("https://github.com/acme/example")).toBe( + "https://github.com/acme/example/issues/new", + ); + expect(at("https://github.com/acme/plugins.git")).toBe( + "https://github.com/acme/plugins/issues/new", + ); + expect(at("https://github.com/acme/plugins/tree/HEAD/packages/example")).toBe( + "https://github.com/acme/plugins/issues/new", + ); + expect(at("https://gitlab.com/acme/example")).toBeUndefined(); + expect(at("javascript:alert(1)")).toBeUndefined(); + }); }); /** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */ -function memoryKv(): KVNamespace { +function memoryKv(now = Date.now): KVNamespace & { values: Map<string, string> } { const values = new Map<string, string>(); + const expiresAt = new Map<string, number>(); const kv = { + values, get: async (key: string, options?: { type?: string }) => { + if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key); const value = values.get(key) ?? null; return options?.type === "json" && value !== null ? JSON.parse(value) : value; }, - put: async (key: string, value: string) => { + put: async (key: string, value: string, options?: { expirationTtl?: number }) => { values.set(key, value); + expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity); }, }; - return kv as unknown as KVNamespace; + return kv as unknown as KVNamespace & { values: Map<string, string> }; +} + +async function resolvePlugin( + cache: KVNamespace, + headers: HeadersInit, + { id = plugin.id, query = "", staticDetail = false } = {}, +): Promise<void> { + const base = "https://registry.example.test"; + const detail = { ...plugin, id, readme: "# Example" }; + await cache.put( + `plugins:detail:v1:${base}:${id}`, + JSON.stringify({ fetchedAt: Date.now(), value: detail }), + ); + const pending: Promise<unknown>[] = []; + const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`; + const response = await handlePluginRegistryRequest( + new Request(`https://paseo.sh${path}${query}`, { headers }), + { PLUGINS_REGISTRY_URL: base }, + { cache, waitUntil: (promise) => pending.push(promise) }, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual(detail); + await Promise.all(pending); } diff --git a/packages/website/src/plugins/registry.ts b/packages/website/src/plugins/registry.ts index 933399b3a2..7d99cb6789 100644 --- a/packages/website/src/plugins/registry.ts +++ b/packages/website/src/plugins/registry.ts @@ -1,5 +1,12 @@ import type { z } from "zod"; -import type { PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; +import { + isGitHubHost, + parseGitHubRemoteIdentity, + parseGitRemoteLocation, +} from "@getpaseo/protocol/git-remote"; +import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; +import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; +import { scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { CATEGORIES, type Category, type CategorySlug } from "./categories"; import type { InstallCounts, InstallWindow } from "./installs"; export { CATEGORIES, type Category, type CategorySlug }; @@ -15,6 +22,10 @@ const DAY_MS = 24 * 60 * 60 * 1000; export function getCategory(slug: string): Category | null { return CATEGORIES.find((category) => category.slug === slug) ?? null; } +/** The plugin's first HTTPS image, for cards and link previews; videos never stand in for one. */ +export function firstMediaImage(plugin: Plugin): string | undefined { + return plugin.media.find((url) => pluginOverviewUrl(url) && pluginMediaKind(url) === "image"); +} export function getPluginsInCategory(plugins: Plugin[], slug: string): Plugin[] { return plugins.filter((plugin) => plugin.categories.includes(slug)); } @@ -35,7 +46,7 @@ export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] { return plugins.filter((plugin) => pluginOwner(plugin) === owner); } export function installCommand(plugin: Plugin): string { - return `paseo plugin install ${plugin.id}`; + return `paseo plugin add ${plugin.id}`; } export function pluginVersion(plugin: Plugin): string { return plugin.artifact.kind === "npm" @@ -47,6 +58,14 @@ export function npmUrl(plugin: Plugin): string | undefined { ? `https://www.npmjs.com/package/${plugin.artifact.package}` : undefined; } +/** A new issue on the plugin's GitHub repository; absent when the source is hosted elsewhere. */ +export function reportBugUrl(plugin: Plugin): string | undefined { + const location = parseGitRemoteLocation(plugin.repository.url); + if (!location || !isGitHubHost(location.host)) return undefined; + // A monorepo source points at the plugin's folder; issues belong to the repository. + const repository = parseGitHubRemoteIdentity(location.path.split("/").slice(0, 2).join("/")); + return repository ? `https://github.com/${repository.repo}/issues/new` : undefined; +} export function authorNpmUrl(author: Author): string | undefined { return author.npm ? `https://www.npmjs.com/~${author.npm}` : undefined; } @@ -61,20 +80,57 @@ export function formatInstalls(count: number): string { const k = count / 1000; return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; } -/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */ -export function searchPlugins(plugins: Plugin[], term: string): Plugin[] { - const needle = term.trim().toLowerCase(); - if (!needle) return plugins; - return plugins.filter((plugin) => - [plugin.name, plugin.description, plugin.id, getAuthor(plugin).name] - .join(" ") - .toLowerCase() - .includes(needle), - ); +/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */ +function searchFieldGroups(plugin: Plugin): string[][] { + const author = getAuthor(plugin); + const name = [plugin.name, plugin.id.split("/")[1]]; + const owner = [author.name, author.github, plugin.id]; + return [name, owner, [plugin.description], [...name, ...owner, plugin.description]]; } -/** Newest submissions first; equal dates keep index order. */ +interface SearchRank { + field: number; + tier: number; +} +function searchRank(plugin: Plugin, term: string): SearchRank | null { + const groups = searchFieldGroups(plugin); + for (let field = 0; field < groups.length; field += 1) { + const score = scoreTextFields(term, groups[field], { subsequence: false }); + if (score) return { field, tier: score.tier }; + } + return null; +} +/** + * Plugins whose name, author, ID, or description contains every word of the term, ignoring case. + * Name matches come first, then author, then description; within each, exact words beat partial + * ones, and equal matches keep the given order. + */ +export function searchPlugins(plugins: Plugin[], term: string): Plugin[] { + if (!term.trim()) return plugins; + return plugins + .flatMap((plugin) => { + const rank = searchRank(plugin, term); + return rank ? [{ plugin, rank }] : []; + }) + .sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier) + .map(({ plugin }) => plugin); +} +/** The featured IDs that name a listed plugin, in the registry's order, each once. */ +export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] { + const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); + return [...new Set(featured)].flatMap((id) => byId.get(id) ?? []); +} +/** + * Most recently listed first. A slow review does not push a plugin down, and an update does not + * bring it back up. Equal times keep index order. + */ export function newestFirst(plugins: Plugin[]): Plugin[] { - return [...plugins].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt)); + return [...plugins].sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt)); +} +/** Plugins with a version approved after their first listing, most recent update first. */ +export function recentlyUpdated(plugins: Plugin[]): Plugin[] { + return plugins + .filter((plugin) => Date.parse(plugin.updatedAt) > Date.parse(plugin.publishedAt)) + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); } /** Most installs in the window first; equal counts keep index order. */ export function mostInstalled( @@ -85,9 +141,16 @@ export function mostInstalled( const count = (plugin: Plugin) => installs[plugin.id]?.[window] ?? 0; return [...plugins].sort((a, b) => count(b) - count(a)); } -/** How long ago the registry accepted the plugin, relative to the loader's clock. */ +/** How long ago the plugin was first listed, relative to the loader's clock. */ export function addedAgo(plugin: Plugin, now: string): string { - const days = Math.floor((Date.parse(now) - Date.parse(plugin.submittedAt)) / DAY_MS); + return ago(plugin.publishedAt, now); +} +/** How long ago the plugin's current version was approved, relative to the loader's clock. */ +export function updatedAgo(plugin: Plugin, now: string): string { + return ago(plugin.updatedAt, now); +} +function ago(date: string, now: string): string { + const days = Math.floor((Date.parse(now) - Date.parse(date)) / DAY_MS); if (days <= 0) return "today"; if (days === 1) return "yesterday"; if (days < 14) return `${days}d ago`; diff --git a/packages/website/src/plugins/section.tsx b/packages/website/src/plugins/section.tsx new file mode 100644 index 0000000000..690a758fde --- /dev/null +++ b/packages/website/src/plugins/section.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; + +/** A block of a plugins page: its header, then its content one fixed gap below. */ +export function PluginSection({ + id, + labelledBy, + className = "", + children, +}: { + id?: string; + labelledBy?: string; + className?: string; + children: ReactNode; +}) { + return ( + <section id={id} aria-labelledby={labelledBy} className={`flex flex-col gap-8 ${className}`}> + {children} + </section> + ); +} + +/** A section's title with its trailing controls on one baseline. */ +export function PluginSectionHeader({ children }: { children: ReactNode }) { + return ( + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">{children}</div> + ); +} + +export function PluginSectionTitle({ id, children }: { id: string; children: ReactNode }) { + return ( + <h2 id={id} className="text-lg font-medium"> + {children} + </h2> + ); +} diff --git a/packages/website/src/plugins/thumbnails.test.ts b/packages/website/src/plugins/thumbnails.test.ts new file mode 100644 index 0000000000..aaaaf47418 --- /dev/null +++ b/packages/website/src/plugins/thumbnails.test.ts @@ -0,0 +1,258 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails"; +import { PluginCard } from "./plugin-card"; +import type { Plugin } from "./registry"; + +const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png"; +const video = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/demo.mp4"; +const plugin: Plugin = { + id: "acme/example", + name: "Example", + description: "An example", + categories: ["themes"], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { + kind: "npm", + package: "paseo-example", + version: "1.2.3", + resolved: "https://registry.npmjs.org/example.tgz", + integrity: "sha512-YWJj", + }, + media: [source], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + installs: 42, +}; + +const avatar = 'src="https://github.com/acme.png?size=80"'; + +describe("plugin card thumbnails", () => { + it("uses the first image in the plugin's media, skipping a leading video", () => { + const html = renderToStaticMarkup( + createElement(PluginCard, { plugin: { ...plugin, media: [video, source] } }), + ); + expect(html).toContain(`/plugins/thumb/592/${encodeURIComponent(source)}?`); + expect(html).not.toContain(encodeURIComponent(video)); + }); + + it("shows the plugin tile when the media has no image", () => { + const html = renderToStaticMarkup( + createElement(PluginCard, { plugin: { ...plugin, media: [video] } }), + ); + expect(html.match(/<img [^>]*>/g)).toEqual([expect.stringContaining(avatar)]); + expect(html).not.toContain("<video"); + expect(html).toContain(">E</div>"); + }); + + it("keeps a screenshot on an unsupported host visible at its original URL", () => { + const screenshot = "https://example.com/preview.png"; + const html = renderToStaticMarkup( + createElement(PluginCard, { plugin: { ...plugin, media: [screenshot] } }), + ); + expect(html).toContain(`src="${screenshot}"`); + expect(html).not.toContain("/plugins/thumb/"); + }); + + it("shows the author's avatar and name beside the card detail", () => { + const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today")); + expect(html).toContain(avatar); + expect(html).toMatch(/>acme<\/span><span aria-hidden="true">·<\/span><span[^>]*>today<\/span>/); + }); + + it("renders a thumbnail instead of downloading the original screenshot", () => { + const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today")); + expect(html).toContain( + `src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`, + ); + expect(html).toContain( + `srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`, + ); + expect(html).toContain('loading="lazy"'); + expect(html).toContain('decoding="async"'); + }); +}); + +function thumbnailRequest( + sourceUrl = source, + width = 592, + id = plugin.id, + accept = "image/avif,image/webp", +) { + return new Request( + `https://paseo.sh/plugins/thumb/${width}/${encodeURIComponent(sourceUrl)}?plugin=${encodeURIComponent(id)}`, + { headers: { accept } }, + ); +} + +function imageResponse(body: string, headers: Record<string, string> = {}) { + return new Response(body, { headers: { "content-type": "image/png", ...headers } }); +} + +// A typed fetch dependency, returning real Responses without patching the global fetch. +function imageFetch(...responses: (Response | Error)[]) { + const calls: { input: Parameters<typeof fetch>[0]; init: Parameters<typeof fetch>[1] }[] = []; + const fetchImage: typeof fetch = async (input, init) => { + calls.push({ input, init }); + const response = responses.shift(); + if (!response) throw new Error("Unexpected fetch"); + if (response instanceof Error) throw response; + return response; + }; + return { calls, fetchImage }; +} + +describe("thumbnail route", () => { + it("sandboxes an SVG fallback when opened as a document on the website origin", async () => { + const svg = + '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.domain)</script></svg>'; + const upstream = imageFetch( + new Response("unsupported", { status: 415 }), + imageResponse(svg, { "content-type": "image/svg+xml" }), + ); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(await response.text()).toBe(svg); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + it.each([ + [592, source, "image/avif,image/webp", "avif"], + [592, source, "image/avif;q=0,image/webp", "webp"], + [592, source, "image/avif;q=0,image/webp;q=0", undefined], + [ + 1184, + "https://raw.githubusercontent.com/acme/example/abc123/preview.png", + "image/webp", + "webp", + ], + [592, source, "image/png,image/*;q=0.8", undefined], + ] as const)( + "transforms a registered screenshot at %s from %s for %s", + async (width, screenshot, accept, format) => { + const upstream = imageFetch(imageResponse("thumbnail")); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(screenshot, width, plugin.id, accept), + [{ ...plugin, media: [screenshot] }], + upstream.fetchImage, + ); + expect(upstream.calls).toEqual([ + { + input: screenshot, + init: { + cf: { image: { width, fit: "scale-down", format, quality: 80 } }, + }, + }, + ]); + expect(response.status).toBe(200); + expect(await response.text()).toBe("thumbnail"); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(response.headers.get("vary")).toBe("Accept"); + }, + ); + + it.each([ + ["Cloudflare error", () => new Response("error 9401", { status: 403 })], + [ + "non-image success", + () => new Response("<html>unavailable</html>", { headers: { "content-type": "text/html" } }), + ], + ["Cf-Resized error", () => imageResponse("error", { "cf-resized": "err=9401" })], + ["network exception", () => new Error("Transform unavailable")], + ] as const)("proxies the original with immutable caching on %s", async (_, failure) => { + const upstream = imageFetch(failure(), imageResponse("original")); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(upstream.calls.map((call) => call.input)).toEqual([source, source]); + expect(upstream.calls[1].init).toBeUndefined(); + expect(response.status).toBe(200); + expect(await response.text()).toBe("original"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + }); + + it.each([ + ["arbitrary width", thumbnailRequest(source, 593), [plugin], 400], + ["unlisted source on allowed host", thumbnailRequest(source + "?different"), [plugin], 404], + ["wrong plugin ID", thumbnailRequest(source, 592, "acme/other"), [plugin], 404], + [ + "missing plugin ID", + new Request(`https://paseo.sh/plugins/thumb/592/${encodeURIComponent(source)}`), + [plugin], + 404, + ], + ["removed plugin", thumbnailRequest(), [], 404], + ["listed video", thumbnailRequest(video), [{ ...plugin, media: [video, source] }], 404], + ["malformed encoding", new Request("https://paseo.sh/plugins/thumb/592/%ZZ"), [plugin], 400], + ["malformed URL", thumbnailRequest("not a URL"), [plugin], 400], + ...[ + "https://example.com/preview.png", + "https://cdn.jsdelivr.net.evil.test/preview.png", + "http://cdn.jsdelivr.net/preview.png", + "https://user:pass@cdn.jsdelivr.net/preview.png", + "https://cdn.jsdelivr.net:8080/preview.png", + ].map( + (url) => + [ + "disallowed registered source", + thumbnailRequest(url), + [{ ...plugin, media: [url] }], + 400, + ] satisfies [string, Request, Plugin[], number], + ), + ] satisfies [string, Request, Plugin[], number][])( + "rejects %s without fetching an image", + async (_, request, plugins, status) => { + const upstream = imageFetch(); + const response = await handlePluginThumbnailRequest( + request, + [...plugins], + upstream.fetchImage, + ); + expect(response.status).toBe(status); + expect(upstream.calls).toEqual([]); + expect(response.headers.get("cache-control")).toBeNull(); + }, + ); + + it.each([ + () => new Response("missing", { status: 404 }), + () => new Response("<html>error</html>", { headers: { "content-type": "text/html" } }), + () => new Error("Origin unavailable"), + ])("does not cache a failed original for a year", async (failure) => { + const upstream = imageFetch(new Response("transform error", { status: 502 }), failure()); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(response.status).toBe(502); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("round-trips screenshot URL punctuation through the card URL", async () => { + const screenshot = source + "?name=a&other=b#preview"; + const upstream = imageFetch(imageResponse("thumbnail")); + const request = new Request( + "https://paseo.sh" + pluginCardScreenshot(plugin.id, screenshot).src, + ); + const response = await handlePluginThumbnailRequest( + request, + [{ ...plugin, media: [screenshot] }], + upstream.fetchImage, + ); + expect(response.status).toBe(200); + expect(upstream.calls[0].input).toBe(screenshot); + }); +}); diff --git a/packages/website/src/plugins/thumbnails.ts b/packages/website/src/plugins/thumbnails.ts new file mode 100644 index 0000000000..1d5b0304ce --- /dev/null +++ b/packages/website/src/plugins/thumbnails.ts @@ -0,0 +1,119 @@ +import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry"; +import type { Plugin } from "./registry"; + +// The single-column grid reaches 591px just below sm (640px minus 48px padding). +const CARD_WIDTH = 592; +const WIDTHS = new Set([CARD_WIDTH, CARD_WIDTH * 2]); +const SOURCE_HOSTS = new Set(["cdn.jsdelivr.net", "raw.githubusercontent.com"]); + +export function pluginCardScreenshot(id: string, source: string): { src: string; srcSet?: string } { + if (!parseSource(source)) return { src: source }; + return { + src: pluginThumbnailUrl(id, source, 1), + srcSet: `${pluginThumbnailUrl(id, source, 1)} 1x, ${pluginThumbnailUrl(id, source, 2)} 2x`, + }; +} + +function pluginThumbnailUrl(id: string, source: string, scale: 1 | 2): string { + return `/plugins/thumb/${CARD_WIDTH * scale}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`; +} + +/** Only images listed in the plugin's media are eligible for transformation or original fallback. */ +export async function handlePluginThumbnailRequest( + request: Request, + plugins: Plugin[], + fetchImage: typeof fetch = fetch, +): Promise<Response> { + const url = new URL(request.url); + const match = /^\/plugins\/thumb\/(\d+)\/([^/]+)$/.exec(url.pathname); + if (!match || !WIDTHS.has(Number(match[1]))) + return new Response("Invalid thumbnail width or source", { status: 400 }); + + let decoded: string; + try { + decoded = decodeURIComponent(match[2]); + } catch { + return new Response("Invalid thumbnail source", { status: 400 }); + } + const source = parseSource(decoded); + if (!source) return new Response("Invalid thumbnail source", { status: 400 }); + + const plugin = plugins.find((entry) => entry.id === url.searchParams.get("plugin")); + if (!plugin?.media.includes(source) || pluginMediaKind(source) !== "image") + return new Response("Screenshot not found", { status: 404 }); + + const format = negotiatedFormat(request.headers.get("accept") ?? ""); + let response: Response | undefined; + try { + response = await fetchImage(source, { + cf: { image: { width: Number(match[1]), fit: "scale-down", format, quality: 80 } }, + }); + } catch { + // An unavailable transformation must not prevent fetching the original. + } + if (!response || !isImage(response) || /err=/i.test(response.headers.get("cf-resized") ?? "")) { + await response?.body?.cancel(); + try { + response = await fetchImage(source); + } catch { + return unavailableImage(); + } + } + if (!isImage(response)) { + await response.body?.cancel(); + return unavailableImage(); + } + return new Response(response.body, { + headers: { + "content-type": response.headers.get("content-type")!, + "cache-control": "public, max-age=31536000, immutable", + vary: "Accept", + // Originals can be SVGs. Keep direct navigation from executing them on our origin. + "content-security-policy": "sandbox", + "x-content-type-options": "nosniff", + }, + }); +} + +function isImage(response: Response): boolean { + return response.ok && /^image\//i.test(response.headers.get("content-type") ?? ""); +} + +function unavailableImage(): Response { + return new Response("Screenshot unavailable", { + status: 502, + headers: { "cache-control": "no-store" }, + }); +} + +function parseSource(source: string): string | null { + try { + const url = new URL(source); + if ( + url.protocol !== "https:" || + !SOURCE_HOSTS.has(url.hostname) || + url.username || + url.password || + url.port + ) + return null; + return source; + } catch { + return null; + } +} + +function negotiatedFormat(accept: string): "avif" | "webp" | undefined { + // The Worker API requires explicit negotiation; only the URL API accepts format=auto. + const accepted = new Map( + accept + .toLowerCase() + .split(",") + .map((range) => { + const [type, ...parameters] = range.split(";").map((part) => part.trim()); + const quality = parameters.find((parameter) => parameter.startsWith("q=")); + return [type, quality === undefined ? 1 : Number(quality.slice(2))] as const; + }), + ); + return (["avif", "webp"] as const).find((format) => (accepted.get(`image/${format}`) ?? 0) > 0); +} diff --git a/packages/website/src/routes/docs.tsx b/packages/website/src/routes/docs.tsx index b58934cb75..f1c5632cf1 100644 --- a/packages/website/src/routes/docs.tsx +++ b/packages/website/src/routes/docs.tsx @@ -4,6 +4,7 @@ import { useCallback, useMemo, useState } from "react"; import { DocsBreadcrumbs } from "~/components/docs-breadcrumbs"; import { DocsNav } from "~/components/docs-nav"; import { DocsOutline } from "~/components/docs-outline"; +import { SponsorsSection } from "~/components/sponsorship"; import { buildDocsNavTree, getDoc, getDocs } from "~/docs"; import "~/styles.css"; @@ -63,6 +64,7 @@ function DocsLayout() { <div className="max-w-prose mx-auto"> {doc && <DocsBreadcrumbs doc={doc} tree={tree} />} <Outlet /> + <SponsorsSection placement="footer" /> </div> </main> diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 38806673f9..5f0bc664ae 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -6,7 +6,7 @@ export const Route = createFileRoute("/")({ head: () => pageMeta( "Paseo – Run Claude Code, Codex, Copilot, OpenCode from anywhere", - "Self-hosted daemon for Claude Code, Codex, Copilot, OpenCode, and Pi. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.", + "Open source app for Claude Code, Codex, OpenCode, Pi, and 30+ more coding agents. Run many agents in parallel on your machines. Editor, terminals, diffs, pull requests, and a browser in one window. One download on desktop. The full app on iOS and Android.", "/", ), component: Home, @@ -17,16 +17,19 @@ function Home() { <LandingPage title={ <> - The control plane + The agentic development <br /> - for coding agents + environment </> } subtitle={ <> - Run any coding agent from anywhere. + Run many coding agents at once, + <br className="sm:hidden" /> + <span className="hidden sm:inline"> </span> + on any machine. <br /> - Self-hosted, multi-provider, open source + From your desk and from your phone. </> } /> diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx index e4db39a205..89c4f185ef 100644 --- a/packages/website/src/routes/plugins/$owner.tsx +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -1,3 +1,4 @@ +import { PluginContentLink } from "~/plugins/overview"; import { createFileRoute, notFound } from "@tanstack/react-router"; import { ExternalLink } from "lucide-react"; import { useMemo } from "react"; @@ -15,6 +16,7 @@ import { } from "~/plugins"; import { AuthorAvatar } from "~/plugins/author-link"; import { PluginsNotFound } from "~/plugins/not-found"; +import { InstallCount } from "~/plugins/install-count"; import { PluginCard } from "~/plugins/plugin-card"; import "~/styles.css"; @@ -64,21 +66,16 @@ function AuthorPage() { </p> <div className="flex items-center gap-4 pt-1"> {authorNpmUrl(author) && ( - <a - href={authorNpmUrl(author)} - target="_blank" - rel="noopener noreferrer" - className={LINK_CLASS} - > + <PluginContentLink href={authorNpmUrl(author)} className={LINK_CLASS}> npm <ExternalLink className="h-3 w-3" /> - </a> + </PluginContentLink> )} {github && ( - <a href={github} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}> + <PluginContentLink href={github} className={LINK_CLASS}> GitHub <ExternalLink className="h-3 w-3" /> - </a> + </PluginContentLink> )} </div> </div> @@ -86,7 +83,9 @@ function AuthorPage() { <div className="mt-12 grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3"> {plugins.map((plugin) => ( - <PluginCard key={plugin.id} plugin={plugin} installs={installs[plugin.id]?.all ?? 0} /> + <PluginCard key={plugin.id} plugin={plugin}> + <InstallCount count={installs[plugin.id]?.all ?? 0} /> + </PluginCard> ))} </div> </SiteShell> diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index b70a13d9da..b3d02177aa 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -1,11 +1,12 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import { ExternalLink } from "lucide-react"; +import { GitHubIcon } from "~/components/brand-icons"; import { CodeBlock } from "~/components/code-block"; -import { DocsMarkdown } from "~/components/docs-markdown"; +import { PluginOverview, PluginContentLink } from "~/plugins/overview"; import { SiteShell } from "~/components/site-shell"; import { pageMeta } from "~/meta"; import { - formatInstalls, + firstMediaImage, getAuthor, getCategory, getRegistry, @@ -14,9 +15,12 @@ import { npmUrl, pluginVersion, readmeBody, + reportBugUrl, } from "~/plugins"; -import { AuthorLink } from "~/plugins/author-link"; +import { AuthorAvatar, AuthorLink } from "~/plugins/author-link"; +import { InstallCount } from "~/plugins/install-count"; import { categoryHref } from "~/plugins/links"; +import { MediaGallery } from "~/plugins/media-gallery"; import { PluginsNotFound } from "~/plugins/not-found"; import "~/styles.css"; @@ -34,7 +38,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({ loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo", loaderData?.plugin?.description ?? "Plugin not found.", `/plugins/${params.owner}/${params.slug}`, - loaderData?.plugin.screenshots[0], + loaderData?.plugin && firstMediaImage(loaderData.plugin), ), component: PluginPage, notFoundComponent: () => ( @@ -46,76 +50,64 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({ const META_LINK_CLASS = "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; +const REPORT_BUG_CLASS = + "inline-flex items-center gap-2 rounded-lg border border-white/12 px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"; +const AUTHOR_LINK_CLASS = + "inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; function PluginPage() { const { plugin, installs } = Route.useLoaderData(); const category = getCategory(plugin.categories[0]); const author = getAuthor(plugin); const npm = npmUrl(plugin); + const reportBug = reportBugUrl(plugin); return ( <SiteShell width="default"> - <div className="max-w-3xl"> - <a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground"> - ← Plugins - </a> - <h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1> - <p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p> - <div className="mt-6"> + <a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground"> + ← Plugins + </a> + <h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1> + <p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p> + <div className="mt-6 flex flex-wrap items-center gap-3"> + <div className="w-fit max-w-full"> <CodeBlock size="sm">{installCommand(plugin)}</CodeBlock> </div> - <div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']"> - <AuthorLink author={author} className={META_LINK_CLASS}> - {author.name} - </AuthorLink> - {category && ( - <a href={categoryHref(category.slug)} className={META_LINK_CLASS}> - {category.label} - </a> - )} - <span className="tabular-nums">{formatInstalls(installs)} installs</span> - <span className="font-mono">{pluginVersion(plugin)}</span> - <a - href={plugin.repository.url} - target="_blank" - rel="noopener noreferrer" - className={META_LINK_CLASS} - > - Source - <ExternalLink className="h-3 w-3" /> - </a> - {npm && ( - <a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}> - npm - <ExternalLink className="h-3 w-3" /> - </a> - )} - </div> - - {plugin.screenshots.length > 0 && ( - <div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0"> - {plugin.screenshots.map((url, index) => ( - <a - key={url} - href={url} - target="_blank" - rel="noopener noreferrer" - className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]" - > - <img - src={url} - alt={`${plugin.name} screenshot ${index + 1}`} - loading="lazy" - className="h-full w-full object-cover object-top" - /> - </a> - ))} - </div> + {reportBug && ( + <PluginContentLink href={reportBug} className={REPORT_BUG_CLASS}> + <GitHubIcon className="h-4 w-4" /> + Report a bug + </PluginContentLink> )} + </div> + <div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']"> + <AuthorLink author={author} className={AUTHOR_LINK_CLASS}> + <AuthorAvatar author={author} size="xs" /> + {author.name} + </AuthorLink> + {category && ( + <a href={categoryHref(category.slug)} className={META_LINK_CLASS}> + {category.label} + </a> + )} + <InstallCount count={installs} /> + <span className="font-mono">{pluginVersion(plugin)}</span> + <PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}> + Source + <ExternalLink className="h-3 w-3" /> + </PluginContentLink> + {npm && ( + <PluginContentLink href={npm} className={META_LINK_CLASS}> + npm + <ExternalLink className="h-3 w-3" /> + </PluginContentLink> + )} + </div> - <div className="mt-10 border-t border-white/10 pt-10"> - <DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown> - </div> + <MediaGallery name={plugin.name} media={plugin.media} /> + + <div className="mt-10 border-t border-white/10 pt-10"> + <PluginOverview>{readmeBody(plugin.readme)}</PluginOverview> </div> </SiteShell> ); diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index bd6cbb5690..613afc375c 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -19,14 +19,19 @@ import { addedAgo, CATEGORIES, type CategorySlug, + featuredPlugins, getCategory, getPluginsInCategory, getRegistry, type InstallWindow, mostInstalled, newestFirst, + pluginOwner, + recentlyUpdated, + updatedAgo, } from "~/plugins"; import { ContributeSection } from "~/plugins/contribute-links"; +import { PluginsHero } from "~/plugins/hero"; import { type BrowseQuery, browseHref, @@ -36,10 +41,10 @@ import { parseSearchTerm, parseSort, parseWindow, - SUBMIT_URL, } from "~/plugins/links"; -import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card"; -import { PluginSearch } from "~/plugins/plugin-search"; +import { InstallCount } from "~/plugins/install-count"; +import { PluginCard, PluginRankRow } from "~/plugins/plugin-card"; +import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section"; import { WindowSwitch } from "~/plugins/window-switch"; import "~/styles.css"; @@ -58,7 +63,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = { utils: Wrench, }; -const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground"; +// Card sections: a swipeable row on phones, a grid from `sm` up. +const CARD_ROW_CLASS = + "-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 *:w-[70%] *:flex-shrink-0 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 sm:*:w-auto lg:grid-cols-4"; const SEE_ALL_CLASS = "inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground"; @@ -74,7 +81,7 @@ export const Route = createFileRoute("/plugins/")({ const sort = parseSort(params.get("sort")) ?? "installs"; const window = parseWindow(params.get("window")) ?? DEFAULT_WINDOW; const q = parseSearchTerm(params.get("q")); - if (category || sort === "new" || q) + if (category || sort !== "installs" || q) throw redirect({ href: browseHref({ category: category?.slug, sort, window, q }), statusCode: 301, @@ -91,9 +98,11 @@ export const Route = createFileRoute("/plugins/")({ }); function PluginsPage() { - const { plugins, installs, now } = Route.useLoaderData(); + const { plugins, featured: featuredIds, installs, now } = Route.useLoaderData(); const window = Route.useSearch().window ?? DEFAULT_WINDOW; + const featured = featuredPlugins(plugins, featuredIds); const newest = newestFirst(plugins).slice(0, NEW_COUNT); + const updated = recentlyUpdated(plugins).slice(0, NEW_COUNT); const top = mostInstalled(plugins, installs, window).slice(0, TOP_COUNT); const windowHrefs = useMemo( () => ({ @@ -104,95 +113,134 @@ function PluginsPage() { [], ); const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]); + const authorCount = new Set(plugins.map(pluginOwner)).size; return ( <SiteShell width="default"> - <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> - <h1 className="text-3xl font-medium tracking-tight"> - Plugins - <span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground"> - {plugins.length} - </span> - </h1> - <div className="flex flex-wrap items-center gap-x-5 gap-y-3"> - <a href={SUBMIT_URL} className={SUBMIT_CLASS}> - Submit a plugin - </a> - <PluginSearch scope={searchScope} className="w-full sm:w-56" /> - </div> - </div> + <PluginsHero + pluginCount={plugins.length} + authorCount={authorCount} + searchScope={searchScope} + /> - <section aria-labelledby="whats-new" className="mt-10"> - <div className="mb-4 flex items-baseline justify-between gap-4"> - <h2 id="whats-new" className="text-lg font-medium"> - What’s new - </h2> - <a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}> - See all - <ChevronRight className="h-3.5 w-3.5" /> - </a> - </div> - <div className="-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4"> - {newest.map((plugin) => ( - <NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} /> - ))} - </div> - </section> - - <section aria-labelledby="categories" className="mt-14"> - <h2 id="categories" className="mb-4 text-lg font-medium"> - Categories - </h2> - <div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> - {CATEGORIES.map((category) => { - const Icon = CATEGORY_ICONS[category.slug]; - return ( + <div className="mt-16 flex flex-col gap-14"> + {featured.length > 0 && ( + <PluginSection labelledBy="featured"> + <PluginSectionHeader> + <div> + <PluginSectionTitle id="featured">Featured</PluginSectionTitle> + <p className="text-sm text-muted-foreground">A selection of hand picked plugins</p> + </div> <a - key={category.slug} - href={categoryHref(category.slug)} - className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4" + href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })} + className={SEE_ALL_CLASS} > - <Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> - <span className="min-w-0 flex-1 text-sm leading-tight text-white"> - {category.label} - </span> - <span className="text-xs tabular-nums text-extra-muted-foreground"> - {getPluginsInCategory(plugins, category.slug).length} - </span> + See all + <ChevronRight className="h-3.5 w-3.5" /> </a> - ); - })} - </div> - </section> + </PluginSectionHeader> + <div className={CARD_ROW_CLASS}> + {featured.map((plugin) => ( + <PluginCard key={plugin.id} plugin={plugin}> + <InstallCount count={installs[plugin.id]?.all ?? 0} /> + </PluginCard> + ))} + </div> + </PluginSection> + )} - <section - id="most-installed" - aria-labelledby="most-installed-title" - className="mt-14 scroll-mt-8" - > - <div className="mb-4 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2"> - <h2 id="most-installed-title" className="text-lg font-medium"> - <a - href={browseHref({ sort: "installs", window })} - className="group inline-flex items-center gap-1" - > - Most installed - <ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" /> + <PluginSection labelledBy="whats-new"> + <PluginSectionHeader> + <PluginSectionTitle id="whats-new">What’s new</PluginSectionTitle> + <a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}> + See all + <ChevronRight className="h-3.5 w-3.5" /> </a> - </h2> - <WindowSwitch current={window} hrefs={windowHrefs} /> - </div> - <div className="-mx-2 grid gap-x-8 md:grid-cols-2"> - {top.map((plugin, index) => ( - <PluginRankRow - key={plugin.id} - plugin={plugin} - rank={index + 1} - installs={installs[plugin.id]?.[window] ?? 0} - /> - ))} - </div> - </section> + </PluginSectionHeader> + <div className={CARD_ROW_CLASS}> + {newest.map((plugin) => ( + <PluginCard key={plugin.id} plugin={plugin}> + {addedAgo(plugin, now)} + </PluginCard> + ))} + </div> + </PluginSection> + + {updated.length > 0 && ( + <PluginSection labelledBy="recently-updated"> + <PluginSectionHeader> + <PluginSectionTitle id="recently-updated">Recently updated</PluginSectionTitle> + <a + href={browseHref({ sort: "updated", window: DEFAULT_WINDOW })} + className={SEE_ALL_CLASS} + > + See all + <ChevronRight className="h-3.5 w-3.5" /> + </a> + </PluginSectionHeader> + <div className={CARD_ROW_CLASS}> + {updated.map((plugin) => ( + <PluginCard key={plugin.id} plugin={plugin}> + {updatedAgo(plugin, now)} + </PluginCard> + ))} + </div> + </PluginSection> + )} + + <PluginSection labelledBy="categories"> + <PluginSectionTitle id="categories">Categories</PluginSectionTitle> + <div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> + {CATEGORIES.map((category) => { + const Icon = CATEGORY_ICONS[category.slug]; + return ( + <a + key={category.slug} + href={categoryHref(category.slug)} + className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4" + > + <Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> + <span className="min-w-0 flex-1 text-sm leading-tight text-white"> + {category.label} + </span> + <span className="text-xs tabular-nums text-extra-muted-foreground"> + {getPluginsInCategory(plugins, category.slug).length} + </span> + </a> + ); + })} + </div> + </PluginSection> + + <PluginSection + id="most-installed" + labelledBy="most-installed-title" + className="scroll-mt-8" + > + <PluginSectionHeader> + <PluginSectionTitle id="most-installed-title"> + <a + href={browseHref({ sort: "installs", window })} + className="group inline-flex items-center gap-1" + > + Most installed + <ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" /> + </a> + </PluginSectionTitle> + <WindowSwitch current={window} hrefs={windowHrefs} /> + </PluginSectionHeader> + <div className="-mx-4 grid gap-x-8 md:grid-cols-2"> + {top.map((plugin, index) => ( + <PluginRankRow + key={plugin.id} + plugin={plugin} + rank={index + 1} + installs={installs[plugin.id]?.[window] ?? 0} + /> + ))} + </div> + </PluginSection> + </div> <ContributeSection /> </SiteShell> diff --git a/packages/website/src/routes/sponsor.tsx b/packages/website/src/routes/sponsor.tsx index 6c5b1b4f46..651f61dd21 100644 --- a/packages/website/src/routes/sponsor.tsx +++ b/packages/website/src/routes/sponsor.tsx @@ -7,7 +7,7 @@ export const Route = createFileRoute("/sponsor")({ head: () => pageMeta( "Sponsor Paseo", - "Paseo is built by one person with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or sponsor it as a company.", + "Paseo is an independent project used by tens of thousands of developers daily, maintained by one person, shaped by the community, with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or sponsor it as a company.", "/sponsor", ), component: Sponsor, diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index 946176fb43..47da4f3c47 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -504,6 +504,24 @@ background-color: rgba(255, 255, 255, 0.02); } +/* Comparison tables: the browser would size each column by its longest cell, + so the two product columns get equal width regardless of content. */ +.comparison-prose table { + display: table; + table-layout: fixed; +} + +.comparison-prose th, +.comparison-prose td { + overflow-wrap: anywhere; + hyphens: auto; +} + +.comparison-prose th:first-child, +.comparison-prose td:first-child { + width: max(22%, 7.5rem); +} + .docs-prose pre { background-color: var(--color-card) !important; border: 1px solid var(--color-border); @@ -543,23 +561,6 @@ margin-bottom: 2rem; } -.docs-source-footer { - border-top: 1px solid var(--color-border); - margin-top: 3rem; - padding-top: 1.25rem; - font-size: 0.875rem; -} - -.docs-source-footer a { - color: rgba(255, 255, 255, 0.5); - font-weight: 500; - text-decoration: none; -} - -.docs-source-footer a:hover { - color: var(--color-foreground); -} - /* Blog post prose styles — adapted from moboudra.com */ .blog-prose { color: rgba(250, 250, 250, 0.82); diff --git a/patches/@gorhom+bottom-sheet+5.2.14.patch b/patches/@gorhom+bottom-sheet+5.2.14.patch new file mode 100644 index 0000000000..f84d46f8fd --- /dev/null +++ b/patches/@gorhom+bottom-sheet+5.2.14.patch @@ -0,0 +1,116 @@ +diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js +index ee51e76..dc1bc17 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js +@@ -315,7 +315,9 @@ function BottomSheetModalComponent(props, ref) { + } + currentIndexRef.current = _index; + nextIndexRef.current = null; +- statusRef.current = _index === -1 ? _constants.MODAL_STATUS.MINIMIZED : _constants.MODAL_STATUS.PRESENTED; ++ if (!(_index === -1 && statusRef.current === _constants.MODAL_STATUS.DISMISSING)) { ++ statusRef.current = _index === -1 ? _constants.MODAL_STATUS.MINIMIZED : _constants.MODAL_STATUS.PRESENTED; ++ } + if (_providedOnChange) { + _providedOnChange(_index, _position, _type); + } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js +index e44ad86..96555ef 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js +@@ -15,6 +15,7 @@ var _exportNames = { + useBottomSheetTimingConfigs: true, + useBottomSheetInternal: true, + useBottomSheetModalInternal: true, ++ BottomSheetModalInternalProvider: true, + useScrollEventsHandlersDefault: true, + useGestureEventsHandlersDefault: true, + useBottomSheetGestureHandlers: true, +@@ -220,6 +221,13 @@ var _useBottomSheetSpringConfigs = require("./hooks/useBottomSheetSpringConfigs" + var _useBottomSheetTimingConfigs = require("./hooks/useBottomSheetTimingConfigs"); + var _useBottomSheetInternal = require("./hooks/useBottomSheetInternal"); + var _useBottomSheetModalInternal = require("./hooks/useBottomSheetModalInternal"); ++var _modalInternal = require("./contexts/modal/internal"); ++Object.defineProperty(exports, "BottomSheetModalInternalProvider", { ++ enumerable: true, ++ get: function () { ++ return _modalInternal.BottomSheetModalInternalProvider; ++ } ++}); + var _useScrollEventsHandlersDefault = require("./hooks/useScrollEventsHandlersDefault"); + var _useGestureEventsHandlersDefault = require("./hooks/useGestureEventsHandlersDefault"); + var _useBottomSheetGestureHandlers = require("./hooks/useBottomSheetGestureHandlers"); +diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js +index 94fabdb..60ca66a 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js +@@ -308,7 +308,9 @@ function BottomSheetModalComponent(props, ref) { + } + currentIndexRef.current = _index; + nextIndexRef.current = null; +- statusRef.current = _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ if (!(_index === -1 && statusRef.current === MODAL_STATUS.DISMISSING)) { ++ statusRef.current = _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ } + if (_providedOnChange) { + _providedOnChange(_index, _position, _type); + } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/index.js b/node_modules/@gorhom/bottom-sheet/lib/module/index.js +index 8259637..eb057fe 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/module/index.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/module/index.js +@@ -14,6 +14,7 @@ export { useBottomSheetSpringConfigs } from './hooks/useBottomSheetSpringConfigs + export { useBottomSheetTimingConfigs } from './hooks/useBottomSheetTimingConfigs'; + export { useBottomSheetInternal } from './hooks/useBottomSheetInternal'; + export { useBottomSheetModalInternal } from './hooks/useBottomSheetModalInternal'; ++export { BottomSheetModalInternalProvider } from './contexts/modal/internal'; + export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault'; + export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault'; + export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers'; +diff --git a/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts b/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts +index ffc6caa..550f1b3 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts ++++ b/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts +@@ -7,6 +7,8 @@ export { useBottomSheetSpringConfigs } from './hooks/useBottomSheetSpringConfigs + export { useBottomSheetTimingConfigs } from './hooks/useBottomSheetTimingConfigs'; + export { useBottomSheetInternal } from './hooks/useBottomSheetInternal'; + export { useBottomSheetModalInternal } from './hooks/useBottomSheetModalInternal'; ++export { BottomSheetModalInternalProvider } from './contexts/modal/internal'; ++export type { BottomSheetModalInternalContextType } from './contexts/modal/internal'; + export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault'; + export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault'; + export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers'; +diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx +index 2954b3f..7260656 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx ++++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx +@@ -432,8 +432,15 @@ function BottomSheetModalComponent<T = never>( + currentIndexRef.current = _index; + nextIndexRef.current = null; + +- statusRef.current = +- _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ // A sheet that is dismissing (dismiss(), or unmounting while presented) keeps that status, ++ // so `onClose` lands on DISMISSED and unmounts it; MINIMIZED would read as a user close, ++ // which with `enableDismissOnClose=false` leaves the portal mounted forever. ++ if (_index === -1 && statusRef.current === MODAL_STATUS.DISMISSING) { ++ // keep dismissing ++ } else { ++ statusRef.current = ++ _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ } + + if (_providedOnChange) { + _providedOnChange(_index, _position, _type); +diff --git a/node_modules/@gorhom/bottom-sheet/src/index.ts b/node_modules/@gorhom/bottom-sheet/src/index.ts +index 7aaea57..6a5f5a4 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/index.ts ++++ b/node_modules/@gorhom/bottom-sheet/src/index.ts +@@ -12,6 +12,8 @@ export { useBottomSheetSpringConfigs } from './hooks/useBottomSheetSpringConfigs + export { useBottomSheetTimingConfigs } from './hooks/useBottomSheetTimingConfigs'; + export { useBottomSheetInternal } from './hooks/useBottomSheetInternal'; + export { useBottomSheetModalInternal } from './hooks/useBottomSheetModalInternal'; ++export { BottomSheetModalInternalProvider } from './contexts/modal/internal'; ++export type { BottomSheetModalInternalContextType } from './contexts/modal/internal'; + export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault'; + export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault'; + export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers'; diff --git a/patches/react-native-uitextview+2.2.0.patch b/patches/react-native-uitextview+2.2.0.patch deleted file mode 100644 index f64f09ebc7..0000000000 --- a/patches/react-native-uitextview+2.2.0.patch +++ /dev/null @@ -1,72 +0,0 @@ -diff --git a/node_modules/react-native-uitextview/ios/RNUITextView.mm b/node_modules/react-native-uitextview/ios/RNUITextView.mm -index d0c31ed..df8a233 100644 ---- a/node_modules/react-native-uitextview/ios/RNUITextView.mm -+++ b/node_modules/react-native-uitextview/ios/RNUITextView.mm -@@ -22,6 +22,7 @@ using namespace facebook::react; - RNUITextViewShadowNode::ConcreteState::Shared _state; - UITapGestureRecognizer * _outsideTapRecognizer; - BOOL _suppressSelectionChange; -+ CGRect _lastTextLayoutFrame; - } - - + (ComponentDescriptorProvider)componentDescriptorProvider -@@ -47,7 +48,8 @@ using namespace facebook::react; - _textView.delegate = self; - // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. - _textView.layoutManager.usesFontLeading = NO; -- [self addSubview:_textView]; -+ _textView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; -+ [_view addSubview:_textView]; - - const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self - action:@selector(handleLongPressIfNecessary:)]; -@@ -94,16 +96,15 @@ using namespace facebook::react; - // Reset the frame to zero so that when it properly lays out on the next use - _textView.frame = CGRectZero; - _textView.attributedText = nil; -+ _lastTextLayoutFrame = CGRectZero; - } - - - (void)layoutSubviews - { - [super layoutSubviews]; -- // _textView's frame is assigned inside drawRect, which only fires when -- // state changes. Trigger a redraw whenever the host frame moves out from -- // under it (rotation, parent relayout) so the text view resizes and -- // onTextLayout re-fires with the new line wrapping. -- if (!CGRectEqualToRect(_textView.frame, _view.frame)) { -+ // Autoresizing updates UIKit immediately; report the new line layout when -+ // the content container's bounds change. -+ if (!CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds)) { - [self setNeedsDisplay]; - } - } -@@ -124,8 +125,9 @@ using namespace facebook::react; - // entirely when nothing actually changed so a JS-side state update made in - // response to onSelectionChange doesn't deselect what the user is selecting. - const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; -- const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); -- if (!textChanged && !frameChanged) { -+ const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.bounds); -+ const BOOL layoutChanged = !CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds); -+ if (!textChanged && !frameChanged && !layoutChanged) { - return; - } - if (textChanged) { -@@ -142,7 +144,7 @@ using namespace facebook::react; - _suppressSelectionChange = NO; - } - if (frameChanged) { -- _textView.frame = _view.frame; -+ _textView.frame = _view.bounds; - } - - __block std::vector<std::string> lines; -@@ -162,6 +164,7 @@ using namespace facebook::react; - } - }]; - -+ _lastTextLayoutFrame = _view.bounds; - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast<const facebook::react::RNUITextViewEventEmitter>(_eventEmitter) - ->onTextLayout(facebook::react::RNUITextViewEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines}); diff --git a/plugins/claude-usage-source/server/stores.test.ts b/plugins/claude-usage-source/server/stores.test.ts index c830fca9cc..093fbf114f 100644 --- a/plugins/claude-usage-source/server/stores.test.ts +++ b/plugins/claude-usage-source/server/stores.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, expect, test } from "vitest"; import { discover, fetchUsage } from "./usage.js"; @@ -43,8 +44,9 @@ async function logins(options: Parameters<typeof discover>[1]) { } async function database(path: string) { await mkdir((await import("node:path")).dirname(path), { recursive: true }); - const { DatabaseSync } = await import("node:sqlite"); const db = new DatabaseSync(path); + // Create the fixture with one durable commit instead of one per row. + db.exec("BEGIN"); db.exec( "CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)", ); @@ -78,6 +80,7 @@ async function database(path: string) { null, ); insert.run(5, "anthropic", "oauth", "invalid", null); + db.exec("COMMIT"); db.close(); } @@ -248,7 +251,6 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login const path = join(home, ".omp", "agent", "agent.db"); await database(path); const [input] = await logins(lookup()); - const { DatabaseSync } = await import("node:sqlite"); const db = new DatabaseSync(path); db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run( JSON.stringify({ access: "fixture-rotated-omp", accountId: "omp-account", expires: 2000 }), diff --git a/plugins/codex-usage-source/server/stores.test.ts b/plugins/codex-usage-source/server/stores.test.ts index 708c289f0f..335985bacd 100644 --- a/plugins/codex-usage-source/server/stores.test.ts +++ b/plugins/codex-usage-source/server/stores.test.ts @@ -39,6 +39,8 @@ async function database(path: string) { await mkdir((await import("node:path")).dirname(path), { recursive: true }); const { DatabaseSync } = await import("node:sqlite"); const db = new DatabaseSync(path); + // Create the fixture with one durable commit instead of one per row. + db.exec("BEGIN"); db.exec( "CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)", ); @@ -72,6 +74,7 @@ async function database(path: string) { null, ); insert.run(5, "openai-codex", "oauth", "invalid", null); + db.exec("COMMIT"); db.close(); } diff --git a/plugins/hoplite-provider/icon.svg b/plugins/hoplite-provider/icon.svg new file mode 100644 index 0000000000..ed585a9083 --- /dev/null +++ b/plugins/hoplite-provider/icon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="1.394 1.679 8.494 7.828" fill="currentColor"><path d="M8.237 7.813C8.276 7.789 8.327 7.796 8.358 7.83L9.741 9.346C9.797 9.407 9.754 9.506 9.671 9.507H8.977C8.924 9.507 8.881 9.463 8.881 9.41V9.125C8.881 9.089 8.861 9.057 8.83 9.04L7.589 8.379C7.524 8.344 7.521 8.251 7.584 8.212L8.237 7.813ZM3.944 1.679C4.502 1.679 5.052 1.82 5.54 2.09L5.962 2.323C6.024 2.358 6.029 2.446 5.97 2.486L5.091 3.09C5.065 3.108 5.05 3.137 5.05 3.169V3.495C5.05 3.529 5.032 3.561 5.003 3.578L4.68 3.769C4.594 3.82 4.498 3.722 4.549 3.637L4.592 3.566C4.63 3.502 4.584 3.421 4.51 3.421H4.028C3.925 3.421 3.894 3.562 3.989 3.604L4.377 3.775C4.448 3.806 4.455 3.904 4.389 3.944L3.77 4.323C3.755 4.332 3.737 4.337 3.72 4.337H3.361C3.309 4.337 3.268 4.379 3.268 4.431C3.268 4.484 3.309 4.526 3.361 4.526H3.779C3.797 4.526 3.814 4.521 3.829 4.513L7.599 2.272C8.135 1.954 8.815 2.008 9.294 2.407C9.307 2.418 9.314 2.434 9.314 2.451V2.729C9.315 2.812 9.413 2.856 9.475 2.8L9.728 2.57C9.789 2.514 9.888 2.558 9.888 2.641V5.812C9.888 5.865 9.845 5.908 9.792 5.908H9.359C9.331 5.908 9.303 5.895 9.285 5.873L8.374 4.773C8.337 4.728 8.268 4.728 8.229 4.771L7.468 5.625C7.429 5.669 7.438 5.737 7.487 5.769L9.038 6.776C9.098 6.815 9.096 6.903 9.035 6.939L6.618 8.363C6.556 8.4 6.478 8.358 6.474 8.286L6.439 7.633C6.437 7.578 6.48 7.532 6.535 7.532H6.925C6.948 7.532 6.971 7.523 6.988 7.508L7.211 7.31C7.276 7.252 7.236 7.144 7.148 7.142L5.991 7.118H1.49C1.437 7.118 1.394 7.075 1.394 7.022V3.308C1.394 3.255 1.437 3.212 1.49 3.212H2.544C2.597 3.212 2.64 3.169 2.64 3.116V2.969C2.64 2.916 2.597 2.873 2.544 2.873H1.461C1.366 2.873 1.329 2.75 1.408 2.697L2.134 2.221C2.672 1.867 3.301 1.679 3.944 1.679Z"/></svg> diff --git a/plugins/hoplite-provider/index.server.ts b/plugins/hoplite-provider/index.server.ts new file mode 100644 index 0000000000..4c101df470 --- /dev/null +++ b/plugins/hoplite-provider/index.server.ts @@ -0,0 +1,7 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { createHopliteProvider } from "./server/provider.js"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createHopliteProvider()); + return () => {}; +} diff --git a/plugins/hoplite-provider/paseo-plugin.json b/plugins/hoplite-provider/paseo-plugin.json new file mode 100644 index 0000000000..138cf83ad6 --- /dev/null +++ b/plugins/hoplite-provider/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "hoplite-provider", "requirements": { "paseo": ">=0.11.0" } } diff --git a/plugins/hoplite-provider/server/provider.ts b/plugins/hoplite-provider/server/provider.ts new file mode 100644 index 0000000000..cb362052ea --- /dev/null +++ b/plugins/hoplite-provider/server/provider.ts @@ -0,0 +1,18 @@ +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; +import { runPhaseTransformer } from "./run-phase.js"; + +export function createHopliteProvider(): ProviderRegistration { + return { + requiresConfiguration: true, + ...runAcpProvider({ + id: "hoplite", + label: "Hoplite", + description: "Hoplite's coding agent through the local `hoplite acp` bridge", + icon: "icon.svg", + command: ["hoplite", "acp"], + acpOptions: { workspaceOnlyCatalog: true }, + transformers: [runPhaseTransformer], + }), + }; +} diff --git a/plugins/hoplite-provider/server/run-phase.test.ts b/plugins/hoplite-provider/server/run-phase.test.ts new file mode 100644 index 0000000000..5526f386c6 --- /dev/null +++ b/plugins/hoplite-provider/server/run-phase.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { runPhaseTransformer } from "./run-phase.js"; + +const context = { sessionId: "session-1" }; + +// Frames captured from `hoplite acp` 3.0.0. +function runPhase(phase: string | null, label: string | null) { + return { + method: "_hoplite/run_phase", + params: { + sessionId: "thr_c75d39f7d7624256af918eeee2f4941b", + runId: "run_eb213b97bd3147afb2927fbc27f87180", + phase, + label, + startedAt: phase === null ? null : "2026-10-04T17:51:46.311Z", + }, + }; +} + +describe("runPhaseTransformer", () => { + it("shows a provisioning phase as one running workspace item per run", () => { + const update = runPhaseTransformer.notification?.( + runPhase("fetching_repository", "Cloning repository"), + context, + ); + + expect(update).toEqual({ + type: "timeline", + item: { + type: "tool_call", + id: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", + callId: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", + name: "Hoplite workspace", + detail: { type: "plain_text", label: "Cloning repository", icon: "wrench" }, + status: "running", + error: null, + }, + }); + }); + + it("completes the same item when the run leaves provisioning", () => { + const update = runPhaseTransformer.notification?.(runPhase(null, null), context); + + expect(update).toMatchObject({ + type: "timeline", + item: { + id: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", + status: "completed", + detail: { label: "Workspace ready" }, + }, + }); + }); + + it("falls back to the phase name when Hoplite sends no label", () => { + const update = runPhaseTransformer.notification?.( + runPhase("configuring_workspace", null), + context, + ); + + expect(update).toMatchObject({ item: { detail: { label: "configuring_workspace" } } }); + }); + + it("ignores other vendor notifications and malformed phases", () => { + expect( + runPhaseTransformer.notification?.({ method: "_hoplite/other", params: {} }, context), + ).toBeNull(); + expect( + runPhaseTransformer.notification?.( + { method: "_hoplite/run_phase", params: { phase: "x" } }, + context, + ), + ).toBeNull(); + }); +}); diff --git a/plugins/hoplite-provider/server/run-phase.ts b/plugins/hoplite-provider/server/run-phase.ts new file mode 100644 index 0000000000..767ee0447d --- /dev/null +++ b/plugins/hoplite-provider/server/run-phase.ts @@ -0,0 +1,39 @@ +import type { AcpTransformer } from "@getpaseo/plugin/server/acp"; +import { z } from "zod"; + +// Hoplite provisions a cloud workspace before the first agent output of a run and reports each +// step as `_hoplite/run_phase`. A null phase ends provisioning for that run. +const runPhaseSchema = z.object({ + runId: z.string().min(1), + phase: z.string().nullable(), + label: z.string().nullable(), +}); + +const RUN_PHASE_METHOD = "_hoplite/run_phase"; + +export const runPhaseTransformer: AcpTransformer = { + notification(notification) { + if (notification.method !== RUN_PHASE_METHOD) return null; + const params = runPhaseSchema.safeParse(notification.params); + if (!params.success) return null; + const { runId, phase, label } = params.data; + const id = `hoplite-run-phase:${runId}`; + const running = phase !== null; + return { + type: "timeline", + item: { + type: "tool_call", + id, + callId: id, + name: "Hoplite workspace", + detail: { + type: "plain_text", + label: running ? (label ?? phase) : "Workspace ready", + icon: "wrench", + }, + status: running ? "running" : "completed", + error: null, + }, + }; + }, +}; diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index 598579d04e..4868634d70 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -73,7 +73,8 @@ async function harness( predicate: (event: ProviderEvent) => boolean, from = 0, ): Promise<ProviderEvent> { - for (let count = 0; count < 200; count++) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { const event = events.slice(from).find(predicate); if (event) return event; await delay(10); @@ -1219,6 +1220,18 @@ test("silent active turns page after two minutes and stop polling on recovered t await vi.advanceTimersByTimeAsync(240000); expect((await h.recorded()).filter((f) => f.method === "view/page")).toHaveLength(1); }); +test("session readiness accepts initialization beyond the old event polling window", async () => { + const h = await harness("text-reasoning", { MUSE_TEST_INITIALIZE_DELAY_MS: "3500" }); + expect(await h.open()).toMatchObject({ type: "session.ready" }); + expect((await h.recorded()).find((frame) => frame.event === "hostStarted").args).toEqual([ + "serve", + "--sandbox-network", + "proxy-only", + "--disable-sandbox", + "--trust-workspace", + ]); +}, 10000); + test("default launch disables sandbox and trusts workspace", async () => { const h = await harness(); await h.open(); diff --git a/plugins/opencode-go-usage-source/icon.svg b/plugins/opencode-go-usage-source/icon.svg index 79991973d6..7e76235008 100644 --- a/plugins/opencode-go-usage-source/icon.svg +++ b/plugins/opencode-go-usage-source/icon.svg @@ -1,6 +1,6 @@ <svg width="54" height="30" viewBox="0 0 54 30" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="#211E1E"/> -<path d="M12 18H18V24H6V12H12V18Z" fill="#CFCECD"/> -<path d="M48 12V24H36V12H48Z" fill="#CFCECD"/> -<path d="M54 30H30V0H54V30ZM36 24H48V6H36V24Z" fill="#211E1E"/> +<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="currentColor"/> +<path d="M12 18H18V24H6V12H12V18Z" fill="currentColor" fill-opacity="0.4"/> +<path d="M48 12V24H36V12H48Z" fill="currentColor" fill-opacity="0.4"/> +<path d="M54 30H30V0H54V30ZM36 24H48V6H36V24Z" fill="currentColor"/> </svg> diff --git a/plugins/package.json b/plugins/package.json index a932782991..4bd6ca429b 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.11.0-beta.5", + "version": "0.11.2", "private": true, "type": "module", "scripts": { diff --git a/public-docs/cli.md b/public-docs/cli.md index 71e54474e3..48f37e89de 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -21,8 +21,49 @@ paseo attach <id> # Stream agent output paseo send <id> "also fix linting" # Send follow-up task paseo logs <id> # View agent timeline paseo stop <id> # Stop an agent +paseo usage # Show account quotas and balances ``` +## Account usage + +Show account quotas and balances from the host's usage sources: + +```bash +paseo usage +paseo usage ls --refresh +paseo usage ls --agent <agent-id> +paseo usage inspect <report-id> +paseo usage inspect <report-id> --refresh +``` + +`paseo usage` is equivalent to `paseo usage ls`. The summary groups windows and balances by account, with report IDs, source and account labels, status, and fetch timestamps. Missing values appear as `-`; unavailable and failed reports include their reasons. + +Copy the full report ID from the list into `inspect` to see reset times, balances and units, plan details, supplied forecasts, and login failures with recovery instructions. Report IDs require exact matches. + +`--agent` selects the account quota associated with an agent. It does not measure that agent's token consumption or cost. Use the full agent ID, and do not combine `--agent` with `inspect`. + +Queries use the host's five-minute cache when available. Add `--refresh` to bypass it. Use `--host` or `--home` to [select a daemon](#select-one-daemon). Hosts without usage-source support return an update message. + +### Usage in scripts + +```bash +paseo usage ls --json +paseo usage inspect <report-id> --json +paseo usage ls --quiet +``` + +List JSON is always an array, including `[]` when no reports are returned. Inspect JSON is one report entry. Each entry includes `id`, `sourceId`, `sourceLabel`, `account`, `fetchedAt`, and a nested `report`. Read `report.status` before accessing its data: + +| Status | Fields | +| ------------- | ---------------------------------------------------------------------- | +| `available` | `windows`, with optional `planLabel`, `balances`, and `details` | +| `unavailable` | `problem`, describing expired or rejected credentials or missing quota | +| `error` | `error`, the failure message | + +Entries may also include `loginErrors`. Structured output preserves numeric values and optional fields. Standard `--format yaml`, `--no-headers`, and `--no-color` options are supported; `--quiet` prints full report IDs. + +A completed query exits with status `0`, including when individual reports are unavailable or failed. Connection or request failures, unsupported hosts, unknown report IDs, and conflicting selectors exit with status `1` and write the error to stderr. Human output says **No usage reports returned** for an empty result; this does not establish whether account discovery succeeded for every source. + ## Provider diagnostics Ask the daemon to inspect the provider environment it actually uses: @@ -42,7 +83,8 @@ Use `paseo run` to start a new agent with a task: ```bash paseo run "implement user authentication" paseo run --provider codex "refactor the API layer" -paseo run --background "run the focused test suite" +paseo run --no-wait "run the focused test suite" +paseo run --output-schema schema.json "name this branch" paseo run --new-workspace worktree --worktree-mode branch-off --new-branch feature/x --base origin/main "implement feature X" paseo run --workspace <workspace-id> "review the current diff" paseo run --output-schema schema.json "extract release notes" @@ -55,9 +97,14 @@ Worktree creation accepts `--worktree-mode branch-off|checkout-branch|checkout-p When an existing Paseo agent runs the same command, Paseo recognizes it through `PASEO_AGENT_ID`. Without explicit placement, the new agent becomes its subagent in the same workspace. `--workspace` can place that subagent elsewhere without changing its parent. -Use `--output-schema` to return only matching JSON output. You can pass a schema file path or an inline JSON schema object. This mode cannot be used with `--background`. +Use `--output-schema` to return only matching JSON output. You can pass a schema file path or an inline JSON schema object. This mode cannot be used with `--no-wait`. -By default, `paseo run` waits for completion. Use `--background` to return immediately while the agent keeps running. +By default, `paseo run` waits for completion. Use `--no-wait` to return immediately while the agent keeps running, the same flag `paseo send` takes. + +The old `run --background` and `-d` aliases still mean `--no-wait`. They are deprecated, hidden +from help, and print a warning to stderr. Use `--no-wait` in scripts. Workspace visibility is +selected separately with `workspace create --background`; this release does not repurpose +`run --background`. ## Projects @@ -116,10 +163,23 @@ paseo workspace create \ --pr-number 2186 ``` +Add `--background` to hide a workspace from default discovery. Its agents and terminals keep +ordinary history, persistence, hooks, and restart recovery. `--no-background` explicitly overrides +caller-workspace inheritance; omission inherits when run by a Paseo agent and defaults to false +from a human shell. Visibility is creation-only. + +```bash +paseo workspace create --isolation local --background --path /path/to/repo +paseo run --workspace <workspace-id> --no-wait "review the checkout flow" +``` + +Exact workspace and agent IDs remain accessible regardless of listing visibility. + Then list, use, rename, or archive it: ```bash paseo workspace ls +paseo workspace ls --background # Also include background workspaces paseo run --workspace <workspace-id> "implement authentication" paseo workspace rename <workspace-id> "Auth rework" paseo workspace rename <workspace-id> --reset # back to the branch or directory name @@ -168,11 +228,12 @@ The output includes each script's lifecycle and supervised terminal ID. Services Create and manage trusted plugins on a daemon: ```bash -paseo plugin init /absolute/path/to/plugin -paseo plugin install /absolute/path/to/plugin -paseo plugin add owner/repository +paseo plugin init ./plugin +paseo plugin install ./plugin +paseo plugin add owner/slug +paseo plugin add git:owner/repository paseo plugin add https://gitlab.com/group/repository.git --ref main -paseo plugin add owner/monorepo:plugins/review +paseo plugin add git:owner/monorepo:plugins/review paseo plugin ls [id] paseo plugin update my-plugin paseo plugin update --all @@ -183,8 +244,10 @@ paseo plugin enable my-plugin paseo plugin remove my-plugin ``` -GitHub shorthand checks an existing host directory first. Append `:<directory>` for a plugin in a -monorepo. `paseo plugin ls [id]` does not contact the remote. `paseo plugin logs <id>` returns the +Bare `owner/slug` installs a reviewed registry listing. Use `git:owner/repository` for GitHub +source, or `./plugin` for a local directory. Append `:<directory>` for a plugin in a monorepo. + +`paseo plugin ls [id]` does not contact the remote. `paseo plugin logs <id>` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for structured entries, or run `paseo --host <target> plugin logs <id>` for another daemon. See the [Plugin reference](/docs/plugins/reference) for installation, trust, lifecycle, and log-retention @@ -197,6 +260,7 @@ paseo ls # Non-archived agents in active workspaces paseo ls -a # Also include archived agents paseo ls -g # Non-archived agents across all workspaces paseo ls -a -g --json # All agents, including archived, as JSON +paseo ls --background # Also include agents in background workspaces ``` ## Streaming output @@ -391,7 +455,7 @@ The CLI is designed to be used by agents themselves. You can instruct an agent t ```bash # Agent A spawns Agent B and waits for it -agent_id=$(paseo run --background --quiet --title api-agent "implement the API") +agent_id=$(paseo run --no-wait --quiet --title api-agent "implement the API") paseo wait "$agent_id" paseo logs "$agent_id" --tail 5 ``` diff --git a/public-docs/community.md b/public-docs/community.md index 72f949bc83..f18815990d 100644 --- a/public-docs/community.md +++ b/public-docs/community.md @@ -10,12 +10,11 @@ category: Getting started These projects are related to Paseo and built by the community. -## Plugin directory +## Plugins -[paseo.cafe](https://paseo.cafe) is a community-run directory of Paseo plugins. Listings are generated -from each plugin's public repository, so it is the fastest way to see what people are building and to -publish your own. It is independent of the Paseo project and nothing on it is reviewed or endorsed by -us, so read a plugin's source before installing it. +Browse the [plugin registry](/plugins) for reviewed community plugins. To share your own, follow +[Publish a plugin](/docs/plugins/publishing) for package preparation and submission. +Plugins run unsandboxed; read their listing and source before installing. ## Tools and integrations @@ -30,7 +29,8 @@ us, so read a plugin's source before installing it. ## Hosting and infrastructure -| Project | What it does | -| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | -| [Paseo Self-hosted](https://github.com/blockfeed/paseo-selfhosted) | Packages the Paseo web UI and a local daemon as a Docker deployment. | -| [Devbox Fleet](https://github.com/omrihaviv/devbox-fleet) | Provisions and maintains per-developer GCP devboxes with Paseo, coding agents, and Tailscale access. | +| Project | What it does | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Paseo Self-hosted](https://github.com/blockfeed/paseo-selfhosted) | Packages the Paseo web UI and a local daemon as a Docker deployment. | +| [Devbox Fleet](https://github.com/omrihaviv/devbox-fleet) | Provisions and maintains per-developer GCP devboxes with Paseo, coding agents, and Tailscale access. | +| [Paseo Vercel Sandbox](https://github.com/vercel-labs/paseo-vercel-sandbox) | Creates persistent Vercel Sandboxes running a Paseo daemon with Codex, Claude Code, OpenCode, or Pi through Vercel AI Gateway. Pair each sandbox as a remote Paseo host. | diff --git a/public-docs/configuration.md b/public-docs/configuration.md index ca578a96ac..a11c4c69ba 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -141,7 +141,7 @@ Daemon logging uses separate console and file sinks by default: - Console: `info` and above - File (`$PASEO_HOME/daemon.log`): `trace` and above -- File rotation: `10m` max file size, `2` retained files total (active + 1 rotated) +- File rotation: `10m` max file size, `3` rotated files kept alongside the active file ```json { @@ -155,7 +155,7 @@ Daemon logging uses separate console and file sinks by default: "path": "daemon.log", "rotate": { "maxSize": "10m", - "maxFiles": 2 + "maxFiles": 3 } } } diff --git a/public-docs/index.md b/public-docs/index.md index f93216883d..54bd56b7b4 100644 --- a/public-docs/index.md +++ b/public-docs/index.md @@ -8,7 +8,7 @@ category: Getting started # Getting started -Paseo runs your coding agents on your machine and gives you a mobile, desktop, web, and CLI client to drive them from anywhere. Three common ways to install. +Paseo is an agentic development environment for running coding agents, editing files, reviewing changes, and testing your work. Agents run on your machines; desktop, mobile, web, and CLI clients let you work from anywhere. Choose how to install it below. ## Desktop app (recommended) @@ -16,27 +16,6 @@ Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub rele The desktop app bundles its own daemon and starts it automatically, no separate install required. On first launch you'll see a brief startup screen, then connect from your phone using **Settings → your host → Pair Device**. -### Linux - -Use the `.deb` on Debian/Ubuntu or the `.rpm` on Fedora to keep Chromium's sandbox available even when your distribution restricts user namespaces. The installer configures the bundled sandbox helper; you do not need to change system security settings. - -For an AppImage, make the download executable and open it: - -```bash -chmod +x Paseo-x86_64.AppImage -./Paseo-x86_64.AppImage -``` - -If it reports `error loading libfuse.so.2`, run without FUSE: - -```bash -./Paseo-x86_64.AppImage --appimage-extract-and-run -``` - -Alternatively, install `libfuse2t64` on Ubuntu 24.04 or newer (`sudo apt install libfuse2t64`), or your distribution's FUSE 2 compatibility package. This dependency belongs to the AppImage runtime, before Paseo starts. - -Paseo checks sandbox availability each time it launches. AppImage and extracted tar archives retain sandboxing when user namespaces work. On a restricted host without a usable installed helper, they launch with Chromium's sandbox disabled. Prefer the installed package if you require OS process isolation. **Settings → Diagnostics → App Diagnostics** reports the sandbox state and reason; the desktop log records the same decision. An explicit `--no-sandbox` argument overrides the automatic choice. - ## Server / CLI For headless machines, dev boxes, or any setup where you want the daemon running without the desktop UI: @@ -71,6 +50,7 @@ The image runs the daemon and serves the bundled web UI. It does not bundle agen ## Where next +- [Run parallel tasks](/docs/parallel-development), use separate worktrees, review diffs, and test changes in the desktop app. - [Connectivity](/docs/connectivity), connect through the relay or Tailscale. - [Docker](/docs/docker), run the daemon and bundled web UI in a container. - [Workspaces](/docs/workspaces), the project, workspace, and session model Paseo is built around. diff --git a/public-docs/mcp.md b/public-docs/mcp.md index a52327174a..58e8a2db56 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -87,27 +87,29 @@ MCP does not expose an agent-detach tool. Detaching is a manual user action in t ### Agents -| Tool | Function | -| -------------------- | --------------------------------------------------------------------------------------- | -| `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. | -| `send_agent_prompt` | Send a prompt to an existing agent using its `agentId` and a `prompt`. | -| `get_agent_status` | Return the latest snapshot for an agent. | -| `list_agents` | List recent agents as compact metadata. | -| `cancel_agent` | Abort an agent's current run but keep the agent alive. | -| `archive_agent` | Soft-delete an agent and remove it from the active list. | -| `kill_agent` | Terminate an agent session permanently. | -| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | -| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | -| `set_agent_mode` | Switch an agent's session mode. | +| Tool | Function | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. Use `initialPrompt` to start work; returns without waiting for completion. | +| `send_agent_prompt` | Send a prompt using `agentId` and `prompt`; returns without waiting for completion. | +| `get_agent_status` | Return the latest snapshot for an agent. | +| `list_agents` | List recent agents as compact metadata; `includeBackground: true` includes background work. | +| `cancel_agent` | Abort an agent's current run but keep the agent alive. | +| `archive_agent` | Soft-delete an agent and remove it from the active list. | +| `kill_agent` | Terminate an agent session permanently. | +| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | +| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | +| `set_agent_mode` | Switch an agent's session mode. | + +Paseo agents receive finish, error, and permission notifications by default; set `notifyOnFinish: false` to disable them. External MCP clients use `get_agent_status` and `get_agent_activity` to check results. ### Workspaces -| Tool | Function | -| ------------------- | ----------------------------------------------------------------------------------------------------- | -| `create_workspace` | Create a local or worktree-isolated workspace. Worktrees can branch off, check out a branch, or a PR. | -| `list_workspaces` | List active workspaces and their directories and isolation. | -| `rename_workspace` | Change the user-visible name of the current or specified workspace. | -| `archive_workspace` | Archive a workspace and the sessions it owns. | +| Tool | Function | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create_workspace` | Create a local or worktree-isolated workspace. Worktrees can branch off, check out a branch, or a PR. `background: true` hides it from default lists and the sidebar while preserving history and access by ID. Omission inherits the calling agent’s workspace setting, or defaults to false without a caller. | +| `list_workspaces` | List active workspaces and their directories and isolation. Use `includeBackground: true` to include background workspaces. | +| `rename_workspace` | Change the user-visible name of the current or specified workspace. | +| `archive_workspace` | Archive a workspace and the sessions it owns. | For worktree isolation, `create_workspace` accepts the same useful choices as the app: branch off from a base, check out an existing branch, or check out a pull request. The worktree remains an implementation detail of the workspace lifecycle. diff --git a/public-docs/orchestration.md b/public-docs/orchestration.md index 74c15a36aa..cd65e709ce 100644 --- a/public-docs/orchestration.md +++ b/public-docs/orchestration.md @@ -10,6 +10,8 @@ category: Orchestration Paseo lets your coding agents coordinate other agents, split work across providers and machines, and keep tasks moving automatically. +You can also start and supervise each task yourself in the desktop app. [Run parallel tasks in separate worktrees](/docs/parallel-development), inspect their diffs, and test each app in a terminal and browser before choosing what to merge. + ## What your agents can do - **Choose providers and models:** launch other agents using any provider and model configured on the host. @@ -46,12 +48,12 @@ See the [MCP reference](/docs/mcp) for tool configuration and the full catalog. Agents with shell access can also use the Paseo CLI. This route does not require enabling tool injection. With Paseo installed, a running host, and Codex configured: ```bash -paseo run --provider codex --background \ +paseo run --provider codex --no-wait \ "Review this branch without changing files" paseo ls -a ``` -The first command starts a worker and returns immediately; the second lists agents from active workspaces, including archived agents. When a Paseo agent runs the command, the worker becomes its subagent in the same workspace. From your own terminal, it starts in a new local workspace. +The first command starts a worker and returns immediately; the second lists archived and background agents as well. When a Paseo agent runs the command, the worker becomes its subagent in the same workspace. From your own terminal, it starts in a new local workspace. See the [CLI reference](/docs/cli) for follow-ups, output, worktrees, and remote hosts. diff --git a/public-docs/parallel-development.md b/public-docs/parallel-development.md new file mode 100644 index 0000000000..4d9c450d25 --- /dev/null +++ b/public-docs/parallel-development.md @@ -0,0 +1,52 @@ +--- +title: Run parallel tasks +description: Run coding agents in separate worktrees, review their diffs, and test each change with terminals and the built-in browser in Paseo. +nav: Run parallel tasks +order: 12 +category: Workspaces +--- + +# Run parallel tasks + +Use Paseo's desktop app to give several coding agents independent tasks, review their changes, and run the results. Each task gets a workspace with its own git worktree and branch, with agents, files, diffs, terminals, and browser tabs together. + +Start with the [desktop app](/docs#desktop-app-recommended), a local git repository, and at least one [coding agent installed and authenticated](/docs/supported-providers). The examples below use a repository with an `origin/main` branch. + +## Start two tasks + +1. Open **New workspace** and select your project. +2. Set **Isolation** to **New worktree** and choose `origin/main` as the starting ref. +3. Choose an agent and model, then submit a task, for example: “Add filtering to the project list. Run the relevant tests.” +4. Create a second workspace with **New worktree** from the same starting ref. Give its agent another task: “Add keyboard navigation to the settings page. Run the relevant tests.” + +Both agents run concurrently. Select either workspace in the sidebar to read progress or send a follow-up. Choose the same provider for both tasks or a different provider for each. + +Separate worktrees keep their file edits on separate branches. For two alternative implementations of the same feature, give both agents the same task and compare their results before choosing one. + +## Review each change + +Select a workspace and open **Changes** from the Command Center to inspect its diff. Open **Files** to browse and edit the source. Use split panes to keep the agent conversation beside the code you're reviewing. + +Send corrections to that workspace's agent. To get another opinion, use **New agent** in the same workspace and ask it to review the changes without editing files. Agents in the same workspace share its working directory. + +## Run and test the app + +In the selected workspace, use **New terminal** from the Command Center. Install dependencies and run your project's test or development command there. + +Each worktree has its own files, but processes still share the machine's network ports. Assign a different dev-server port to each running copy. For repeatable setup and service ports, configure [worktree setup and scripts](/docs/worktrees#paseojson). + +For a web app, use **New browser** and enter that workspace's dev-server URL. Exercise the changed flow, then switch workspaces to test the other implementation. Browser tabs run inside the desktop app. + +You can also ask the agent to check the app in those tabs. Enable [browser automation](/docs/browser#enabling), then give it the URL and concrete steps to verify, for example: + +> Open http://localhost:3001, filter the project list by name, and verify that clearing the filter restores the full list. Report any failures and take a screenshot of the result. + +## Choose what to merge + +Review the diff and test results for each workspace. Ask for any remaining changes before committing and opening a pull request for that branch, using your usual Git workflow in the workspace terminal or through its agent. + +For independent tasks, review and merge each branch. For competing implementations, choose the version you want to keep. Archive a workspace when you no longer need it; [worktree cleanup](/docs/worktrees#manage-the-workspace) removes the managed directory after its last workspace is archived. + +## Let an agent coordinate the tasks + +When you want an agent to create the workspaces, launch workers, and collect their results, use [orchestration](/docs/orchestration). The workers appear in Paseo so you can inspect their conversations and changes as they work. diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index 033235b7f0..50d9be9826 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -21,6 +21,16 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add [connect a coding agent as a provider](/docs/plugins/providers). Client contributions run on every Paseo client connected to that daemon, including mobile. +Browse [published plugins](https://paseo.sh/plugins), then install one on your daemon: + +```bash +paseo plugin add owner/slug +``` + +Or paste `owner/slug` into **Settings → Plugins → Plugin source** and select **Install plugin**. +Paseo installs the registry's reviewed artifact. Use `git:owner/repository` or a full Git URL +when you want to install directly from a Git repository. + This guide scaffolds a plugin, runs it, and adds a workspace panel to it. ## Create a plugin @@ -246,13 +256,13 @@ reload use the installed files. See [plugin sources](/docs/plugins/reference#plugin-sources) for identifier syntax. To share your own plugin, follow [Publish a plugin](/docs/plugins/publishing). -Plugins published in a Git repository install by shorthand or URL: +Plugins published in a Git repository install by explicit Git shorthand or URL: ```bash -paseo plugin add owner/repository +paseo plugin add git:owner/repository paseo plugin add https://gitlab.com/group/repository.git -paseo plugin add owner/monorepo:plugins/workspace -paseo plugin add owner/repository --ref main +paseo plugin add git:owner/monorepo:plugins/workspace +paseo plugin add git:owner/repository --ref main ``` Append `:relative/path` when the plugin lives below the repository root. `--ref` selects the initial diff --git a/public-docs/plugins/publishing.md b/public-docs/plugins/publishing.md index 0e7f2f89cf..f925d0f1d4 100644 --- a/public-docs/plugins/publishing.md +++ b/public-docs/plugins/publishing.md @@ -1,6 +1,6 @@ --- title: Publish a plugin -description: Share a Paseo plugin on npm, through a private registry, or from a Git repository. +description: Prepare, publish and submit a Paseo plugin to the reviewed registry. nav: Publishing order: 45 category: Plugins @@ -8,95 +8,241 @@ category: Plugins # Publish a plugin -Publish your plugin so other people can install and use it in Paseo. Start with a working -[plugin project](/docs/plugins), then choose where to share it: +Start with a working [plugin project](/docs/plugins). Prepare its listing, publish it on npm +or push it to GitHub, then submit it to the registry. After approval, users install the reviewed +version with `paseo plugin add owner/slug`. -- [npm](#publish-on-npm): publish a package on the public npm registry. -- [GitHub or Git](#share-through-github-or-git): let users install from a repository. +1. Prepare your [listing page](#your-listing-page) and, for a visual plugin, an + [interface image](#icons-and-screenshots). +2. [Publish on npm](#publish-on-npm) or [share through Git](#share-through-github-or-git). +3. [Submit to the plugin registry](#plugin-registry). + +For a private plugin, use [direct Git installation](#share-through-github-or-git) or +[private npm packages](#private-npm-packages). + +## Your listing page + +You can submit without `OVERVIEW.md`; the reviewer creates a listing page from your README and +verified behavior. To maintain the page yourself, put `OVERVIEW.md` beside `paseo-plugin.json` +and include it in the published package. The scaffold already includes it in `files`. +Your shipped overview replaces the reviewer-written page. + +Explain these points in order, using headings only when they help: + +1. What the plugin does. +2. How it works, when that is not obvious. +3. Setup: settings, accounts, tokens, external tools, supported operating systems and Paseo versions. +4. Useful capabilities, settings, data access, permissions and limitations. + +Describe where data goes, including prompts and tool results sent to agent providers. Local storage +does not mean data stays on the host. If the plugin downloads or installs software while running, +explain what it installs and its source, and require explicit user confirmation before the download. +This applies to activation and connection tests too; declining must leave the software uninstalled. + +Use plain factual language and sentence case. Omit marketing, unsupported claims, badges, +changelogs, contributing and license sections, and implementation details that do not help someone +choose the plugin. The listing already provides plugin installation instructions. + +The publication validator rejects `paseo plugin add`, `npm install` and `npm i` anywhere in an +overview, including development or setup examples. Link to your README for those commands. + +Also supply a one-sentence description: use `package.json`'s `description` for npm or the manifest's +`description` for Git. The reviewer can fill in a missing description in the registry. + +## Icons and screenshots + +Themes, panels and other visual plugin interfaces need at least one image showing the current +interface. Use sample data and hide private details. An icon alone does not meet this requirement; +plugins without their own visual interface do not need screenshots. + +Declare local images in `paseo-plugin.json` before publishing: + +```json +{ + "id": "review-tools", + "name": "Review tools", + "description": "Reviews changes before merge", + "icon": "assets/icon.png", + "media": ["assets/screenshot.png"], + "requirements": { "paseo": ">=0.11.0" } +} +``` + +Create the referenced files and include `assets/` in `package.json`'s `files` list for npm. +Paths are relative to the manifest, use forward slashes and stay inside the plugin directory. +These metadata fields require Paseo 0.11.0 or later. + +| Field | Listing behavior | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Optional display name; defaults to a humanized plugin ID. | +| `icon` | Optional package-relative PNG. Provider registration icons have a separate [SVG contract](/docs/plugins/reference#providers). | +| `media` | Package-relative paths or public HTTPS URLs. Images: PNG, JPG, JPEG, WebP or GIF. Videos: MP4 or WebM. The first image is the card thumbnail; videos supplement it. | + +Public media URLs need a supported file extension, matched case-insensitively, and must return +HTTP 200 with the corresponding image or video content type. SVG and extensionless attachment +URLs are not accepted as registry media. + +Instead of declaring `media`, you can supply suitable public image URLs in the submission or +review PR. The reviewer can add them to the listing without a new plugin release. ## Publish on npm -The scaffold prepares the package files and development dependencies. You choose the package name -and release version. +### 1. Set package identity -### 1. Set your package name and version - -From the plugin directory, set your package details and allow publication. Replace `@acme` with your npm scope: +Use an npm scope you can publish to and authenticate with `npm login`. From your plugin directory, +replace `@acme` and the repository below with your own values: ```bash npm pkg set name=@acme/paseo-review version=1.0.0 npm pkg delete private +npm pkg set repository.type=git repository.url=git+https://github.com/acme/paseo-review.git ``` -### 2. Check and publish +The current registry tooling requires a GitHub repository for npm listing ownership, supplied +through `package.json` or npm provenance. Set `repository` to your public source repository so +pinning can determine the listing owner. Provenance itself is optional; if present, its repository +must match the package's repository. A matching source checkout is not required for artifact review. + +### 2. Lock installed dependencies + +Keep Paseo's host libraries and type-only dependencies in `devDependencies`. Add other runtime +libraries to `dependencies`. For registry submission, every installed dependency tree needs a lock +with exact versions, resolved sources and integrity, including transitive dependencies. + +For an npm package with installed runtime dependencies, update its lock without running lifecycle +scripts, then create the publishable lock: + +```bash +npm install --package-lock-only --ignore-scripts +npm shrinkwrap +``` + +`npm shrinkwrap` converts `package-lock.json` into `npm-shrinkwrap.json`. npm publishes the shrinkwrap +at the package root; it does not publish `package-lock.json`. A repository-only lock or exact direct +version does not lock the dependency tree installed by your users. + +Plugins using only host libraries and development dependencies need no runtime lock unless a +preparation command installs those dependencies. You can also bundle a library and remove the +runtime dependency declaration. See [preparation and additional setup](#plugins-with-a-build-step) +if your plugin installs a separate tree. + +### 3. Check the package and publish + +Generate any required files before checking the package; the dry run skips packing scripts. ```bash npm run typecheck -npm pack --dry-run +npm pack --dry-run --ignore-scripts +``` + +Check the file list contains `paseo-plugin.json`, at least one runtime entry, every imported local +module, any overview and assets you declared, and the shrinkwrap when required. Repository-only import aliases +and files outside the package do not reach users. The scaffold includes the entries and `client/`, +`server/` and `shared/`; add other asset directories to `files`. + +Confirm any declared preparation command succeeds with the Node/npm versions you support; +an out-of-sync or incompatible lockfile still blocks installation. + +Publish the checked version: + +```bash npm publish --access public ``` -Check the pack output includes any assets you added to the project. - -### 3. Test the published plugin - -On a daemon host with npm available: +On a test daemon, install that exact published version and exercise its contributions: ```bash paseo plugin install npm:@acme/paseo-review@1.0.0 +paseo plugin ls ``` -Users can also paste `npm:@acme/paseo-review` into **Settings → Plugins → Plugin source**. +Require `running` with no error, then [submit the package](#plugin-registry). -:::example[Package configuration] +## Share through GitHub or Git -The scaffold includes this `files` list in `package.json`: +For the public registry, push your plugin to a public GitHub repository. Include its manifest, +runtime entries, imported files and listing assets. Git tags and commits are optional at submission; +provide one to select a revision, or the reviewer resolves `main`. + +If preparation installs runtime npm dependencies, commit a synchronized `package-lock.json` and +add this command to `paseo-plugin.json`: ```json { - "files": [ - "paseo-plugin.json", - "index.client.ts", - "index.client.tsx", - "index.server.ts", - "index.server.tsx", - "client/", - "server/", - "shared/" - ] + "build": [["npm", "ci", "--omit=dev", "--ignore-scripts"]] } ``` -- Add any assets stored outside these directories to `files`. -- Keep the scaffold's SDK and host libraries in `devDependencies`. -- Add other runtime libraries with `npm install <package>`. Paseo installs their dependencies too. -- The npm package name identifies the source. The manifest's `id` identifies the installed plugin. +`npm ci` consumes the committed lock and fails if it disagrees with `package.json`. `--omit=dev` +excludes development tools and `--ignore-scripts` suppresses dependency lifecycle scripts. +The daemon needs npm on its command path. Plugins using only host libraries need no preparation +command. See [preparation](#plugins-with-a-build-step) for generated files or explicit scripts. -See the [project reference](/docs/plugins/reference#project-files) for entry points and runtime boundaries. +Users can install directly from Git without a registry listing: -::: +```bash +paseo plugin add git:acme/paseo-review +paseo plugin add git:acme/monorepo:plugins/review +``` -### Plugins with a build step +Use `--ref` to select a branch, tag or commit for the initial installation. Direct installation +also supports private repositories and other Git hosts when the daemon has access; see +[plugin sources](/docs/plugins/reference#plugin-sources). Registry submission accepts public +GitHub sources and public npm packages. -Paseo compiles TypeScript. An ordinary plugin needs no separate build before publication. -If your plugin generates files, include the generated output in the package. +## Plugin registry -**Installation scripts do not run automatically.** If a dependency needs host-specific setup, -declare a [preparation command](/docs/plugins/reference#cli-reference). +Open the [submission form](https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml). +Use the form so the issue receives the submission label that starts intake. -:::example[Generated files and dependencies] +1. Use your plugin's name as the title. +2. In **Source**, enter the public npm package name or page, or the GitHub repository/folder URL. + Include the version or Git reference if you want a specific release reviewed. npm defaults to + `latest`. Registry IDs and local directories are not submission sources. +3. Select the categories that fit. Include any interface-image URLs in the issue description. -- Generate code and assets before running `npm publish`. -- Keep generated JavaScript in its runtime directory and import it from the TypeScript entry. -- Include generated assets in `files`. -- Keep host-provided modules external when producing your own bundle. -- Remove Git-only dependency-install commands from the published manifest; npm installation already - installs production dependencies. +The bot opens a review PR. The reviewer inspects the artifact and dependencies without running +plugin code, completes the listing and records the exact approved version or commit. Publication +requires the [review policy](https://github.com/getpaseo/plugins/blob/main/REVIEW.md), including +locked installation inputs and appropriate data access, even when automated validation passes. -Paseo skips npm lifecycle scripts during installation, including dependency scripts. For example, -a native dependency that needs rebuilding requires an explicit preparation command. +If a change is requested, reply on the review PR with the corrected version/revision or requested +image URL. The reviewer updates the pin and listing. Intake service errors retry automatically; +missing images or an overview do not prevent opening the review PR. -::: +After approval and publication, users install your listing with: + +```bash +paseo plugin add owner/slug +``` + +npm releases receive automatic update PRs. Submit Git updates manually with the desired revision. +Existing registry installations use approved pins; direct npm/Git installs follow their own update +rules in the [source reference](/docs/plugins/reference#plugin-sources). + +## Plugins with a build step + +Paseo compiles TypeScript, so an ordinary plugin needs no separate publication build. +Keep generated modules in their runtime directory and host-provided modules external. + +Paseo suppresses npm lifecycle scripts during package acquisition. Commands explicitly declared in +manifest `build` still run, including scripts they invoke. Explain the purpose of those commands +and keep their inputs locked and inspectable. Remove Git-only dependency-install commands from +npm manifests when npm already installs the production dependencies. + +Later setup that installs another dependency tree needs its own complete lock, even if your plugin +has a development lockfile. For npm setup, ship the runtime manifest and lock together and use +`npm ci`. For Python setup, ship a requirements file containing exact versions and SHA-256 hashes +for all transitive packages, then install it with: + +```bash +python -m pip install --require-hashes -r requirements-documents.txt +``` + +This command rejects unpinned or unhashed dependencies. Runtime installation also needs the +[confirmation and disclosure](#your-listing-page) described above. + +## Private npm packages :::example[Publish a private package with GitHub Packages] @@ -135,32 +281,3 @@ Paseo uses the host's npm registry settings and credentials for installation and In the app, enter only the source identifier. ::: - -## Share through GitHub or Git - -Push the plugin project to a repository. Users can install it with: - -```bash -paseo plugin install github:acme/paseo-review -``` - -For another Git host: - -```bash -paseo plugin install git:https://git.example.com/acme/paseo-review.git -``` - -If your plugin has runtime npm dependencies, commit `package-lock.json` and add a preparation -command to `paseo-plugin.json`: - -```json -{ - "build": [["npm", "ci", "--omit=dev"]] -} -``` - -- `npm ci` installs the versions in the committed lockfile. -- `--omit=dev` excludes development tools. -- npm must be available on the daemon host. - -Plugins that only use Paseo's host libraries need no preparation command. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 086f910599..409bb3f5a6 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -52,12 +52,26 @@ The required root manifest is `paseo-plugin.json`: } ``` -| Field | Required | Behavior | -| -------------- | -------- | ---------------------------------------------------------------------- | -| `id` | Yes | Default installation ID. | -| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | -| `requirements` | No | Supported Paseo versions, described below. | -| `build` | No | Preparation commands, described in the CLI reference. | +| Field | Required | Behavior | +| -------------- | -------- | ----------------------------------------------------------------------------------------- | +| `id` | Yes | Default installation ID. | +| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. | +| `icon` | No | Relative path to a PNG inside the plugin package. | +| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | +| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | +| `requirements` | No | Supported Paseo versions, described below. | +| `build` | No | Preparation commands, described in the CLI reference. | + +Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..` +segments. Registry listings for visual plugins require an interface image even though `media` is +optional in the runtime manifest. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) +for accepted formats and listing requirements. +Paseo validates the references without opening local assets or fetching URLs. + +Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside +`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check. +Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject +these fields during installation. ### Requirements @@ -411,7 +425,7 @@ account metadata is unavailable. Labels can name accounts without becoming their Return logins in preference order and set `harness` to the owning harness's display name, such as Codex, OpenCode, Pi, or OMP. The daemon treats inputs as opaque and never derives labels from them. -The host-wide Usage screen groups logins with the same key into one account card and tries them +The host-wide Usage modal groups logins with the same key into one account card and tries them concurrently, preferring the discovery order when selecting a result. If any login returns `available`, it shows usage with no errors. If all fail, it shows every login's error on a separate line with its harness label and its own remedy. @@ -718,10 +732,9 @@ plans, and mode changes; requesting permission does not end the turn. | `workspace.created` | `workspace` | Record created; directory available | | `workspace.archived` | `workspace` | Archive state is saved | -Agent events exclude internal utility agents. Archive events can precede runtime/worktree cleanup; -closing an agent that is already closed does not emit another `agent.closed` event. -During daemon shutdown, pending event hooks have up to five seconds to finish before plugins stop. -`workspace.created` is not a setup barrier before agent startup. +Agent events exclude the daemon's private disposable helpers. Public background workspaces and +their ordinary agents run the same lifecycle hooks as other workspaces. Archive events can precede +runtime/worktree cleanup; observe lifecycle completion separately when cleanup matters. **Shared payload shapes** (`@getpaseo/plugin/server`): @@ -775,7 +788,6 @@ type PluginTurnOutcome = | `providerOptions` | Opaque provider-specific options | | `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | | `cwd` | Cannot change | -| `internal` | Daemon-owned; cannot change through this hook | **`agent.session_open` request example:** @@ -2127,7 +2139,7 @@ export default function contribute(server: PluginServerContext) { Inputs and outputs are validated on both sides. RPC names start with a lowercase letter and contain lowercase letters, numbers, dots, hyphens, or underscores. `useRpc()` returns a typed async function. Use TanStack Query for request state, caching, and mutations. -Backend handlers receive the same `PaseoApi` as `{ paseo }`. Their connection belongs to the subprocess and closes when the plugin stops. It does not subscribe to timelines or catalog events until plugin code subscribes. Follow the [SDK event contract](../../sdk/events.md) for cleanup and timeline replacements. Backend code can use Node APIs and dependencies installed in the plugin directory. +Backend handlers receive the same `PaseoApi` as `{ paseo }`. Their connection belongs to the subprocess and closes when the plugin stops. It does not subscribe to timelines or catalog events until plugin code subscribes. Follow the [SDK event contract](/docs/sdk/events) for cleanup and timeline replacements. Backend code can use Node APIs and dependencies installed in the plugin directory. ## Debug backend output @@ -2258,16 +2270,20 @@ failures stay inside the plugin error boundary. Paste one of these source identifiers into **Settings → Plugins**, or pass it to `paseo plugin install`. `paseo plugin add <source>` and `paseo plugin install <source>` are aliases. -Absolute host paths are recommended because relative paths resolve against the daemon's working -directory. The app does not expand `~`; your shell may expand it before the CLI runs. +The CLI resolves relative directory paths from your current working directory and expands `~` +to your local home directory, preserving any `:plugin/path` suffix. With `--host`, use an absolute +path that exists on the daemon host; plugin files are read there and are never uploaded by the CLI. +Paths entered in Settings resolve relative to the daemon's working directory, and `~` expands to +the daemon's home directory. | Source | Accepted form | Example | | -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | +| Plugin registry | `owner/slug` or `host/owner/slug` | `acme/review` | | Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` | -| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` | +| GitHub repository | `git:owner/repository` or `github:owner/repository` | `github:acme/paseo-review` | | Git repository | `git:<URL or SCP source>`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` | | npm package | `npm:<name>[@<version, tag, or range>]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` | -| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` | +| Plugin below a source root | Append `:relative/plugin/path` to a directory, Git, or npm source | `github:acme/monorepo:plugins/review` | Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use `user@host:path`. `file://` selects Git acquisition, not directory installation. @@ -2283,24 +2299,27 @@ The package registry validates the selected version, tag, or range. Paseo resolves an identifier in this order: -1. An existing directory matching the complete identifier on the daemon host wins, including a - literal directory containing `:`. +1. A bare `owner/slug` is a registry id; local directory sources are `.` or `..`, or start with `./`, `../`, `.\`, `..\`, `/`, `~`, a Windows drive (`C:\` or `C:/`), or a UNC (`\\server\share`) prefix. 2. Otherwise, recognize `npm:`, `github:`, or `git:` before interpreting a subdirectory suffix. `git://` is a Git URL scheme. An explicit prefix selects acquisition of that kind. 3. Recognize a final `:relative/plugin/path` only when its suffix contains no empty, `.` or `..` segments. A lone `.` selects the source root. Both `/` and `\` separate suffix segments; use `/` across hosts. URL ports and the separator in an SCP source stay part of the source. A suffix that does not satisfy these rules stays part of the identifier. -4. Without an explicit prefix, an existing directory matching the remaining source wins. -5. Resolve Git URLs and SCP sources as Git; expand exact `owner/repository` shorthand to GitHub - HTTPS. `github:` requires that shorthand; `git:` accepts it as well as URLs and SCP sources. -6. Resolve a remaining npm package name with its optional selector through the host's registry. +4. Resolve bare `owner/slug` through the default plugin registry and `host/owner/slug` through + that registry host. Resolve Git URLs and SCP sources as Git. `github:` requires + `owner/repository` shorthand; `git:` accepts it as well as URLs and SCP sources. +5. Resolve a remaining npm package name with its optional selector through the host's registry. Reject anything else. -Plugin registry installs are off by default. When the daemon enables them with -`pluginRegistryEnabled: true` or `PASEO_PLUGIN_REGISTRY_ENABLED=1`, bare `owner/slug` and -`host/owner/slug` resolve through the plugin registry instead of GitHub, the registry record owns -the revision and plugin path, and GitHub shorthand requires `github:`. +The default registry is `https://plugins.paseo.sh`. Browse [published plugins](https://paseo.sh/plugins) +and install with `paseo plugin add owner/slug`. Registry records own the revision and plugin path. +Use `git:owner/repository` for explicit GitHub shorthand or a full Git URL. +Set `PASEO_PLUGIN_REGISTRY` to use a self-hosted default URL, including a path prefix. +Private registry credentials use `pluginRegistries: { "host": { "authorization": "Bearer token" } }` +in daemon config. Restart the daemon after changing these startup settings. Credentials go only +to that registry, never to artifact hosts or redirects. Installed plugins retain their recorded +source and registry URL when defaults change. Directory lookup happens on the daemon host. The app uses the `paseo-plugin.json` ID; the CLI accepts `--id <runtime-id>` to override it. An existing installation ID is rejected without changing @@ -2334,8 +2353,9 @@ keeps the complete dependency tree and `package-lock.json` when activating it. T package and lockfile provide its current version and artifact integrity. A version, tag, or range chooses content for this installation only. -For package contents, dependencies, preparation, and private registries, see -[Publish a plugin](/docs/plugins/publishing). +The installed lockfile records what this installation resolved. Authors submitting npm plugins +with runtime dependencies must also ship the [publication lock](/docs/plugins/publishing#2-lock-installed-dependencies). +For package contents, preparation and submission, see [Publish a plugin](/docs/plugins/publishing). A failed download, dependency installation, manifest check, preparation command, compilation, or activation discards the candidate. Other installed plugins keep running. Removing an npm plugin @@ -2347,9 +2367,10 @@ deletes its managed files; removing a directory plugin keeps your source directo paseo plugin init /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin --id another-runtime-id -paseo plugin add owner/repository +paseo plugin add owner/slug +paseo plugin add git:owner/repository paseo plugin add https://git.example.com/owner/repository.git --ref main -paseo plugin add owner/monorepo:plugins/review +paseo plugin add git:owner/monorepo:plugins/review paseo plugin ls [id] paseo plugin update <id> paseo plugin update --all --check diff --git a/public-docs/providers.md b/public-docs/providers.md index 6de304a019..fc23bff784 100644 --- a/public-docs/providers.md +++ b/public-docs/providers.md @@ -8,7 +8,9 @@ category: Providers # Providers -Paseo doesn't ship its own coding agent. It launches and supervises **existing CLIs you've already installed and authenticated**, Claude Code, Codex, OpenCode, Antigravity, Muse Code, Cursor, Gemini, and the rest. Your subscriptions, your config, your skills, your MCP servers all stay intact. Paseo just gives you a UI, a CLI, a relay, and orchestration on top. +Paseo runs **existing coding agents you've installed and authenticated** in workspaces with an editor, terminals, diffs, and, on desktop, a browser. Run several agents in parallel, choose a provider for each task, and review their work in the same app. Your subscriptions, config, skills, and MCP servers stay intact. + +To try the workflow, [run parallel tasks in separate worktrees](/docs/parallel-development). ## Mental model diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md index fa3ed80c00..87d3e993cf 100644 --- a/public-docs/sdk/agents.md +++ b/public-docs/sdk/agents.md @@ -105,6 +105,31 @@ const child = await workspace.agents.create({ `parent` establishes parentage. Archiving a parent cascade-archives its children. Call `detach()` first when a child should continue independently. +## Run work in a background workspace + +Create a background workspace and its first agent in one call. Put `background` on the +workspace options and the agent's configuration under `agent`: + +```ts +const cwd = "/Users/me/dev/storefront"; +const workspace = await client.workspaces.create({ + source: { kind: "directory", path: cwd }, + background: true, + agent: { + cwd, + config: { provider: "codex/gpt-5.5" }, + prompt: "Review the checkout flow.", + }, +}); +``` + +The call returns a workspace handle and starts its first agent with the supplied prompt. + +Background agents keep their history and survive daemon restarts. Default discovery hides them; +use `client.agents.list({ filter: { includeBackground: true } })` to include them. +Exact-ID handles work normally. See [workspace creation](./workspaces.md#create-a-fresh-workspace) +for defaults and caller inheritance. + ## Request structured output ```ts diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 620f398691..76490974fe 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -57,7 +57,10 @@ Create a new client after `close()`. `list({ subscribe: {} })` also returns a `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Capable daemons assign the ID and keep observations independent. Older daemons use local IDs and their established shared delivery behavior. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). -Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`. +Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, `background`, and `labels`. + +`background` is workspace creation intent. It cannot accompany an existing `workspaceId`. +See [background workspaces](./workspaces.md) for caller inheritance and inclusive discovery. `config` accepts: @@ -123,14 +126,19 @@ See [events](./events.md#follow-provider-catalog-changes) for explicit event obs ## `client.workspaces` -| Method | Result | Behavior | -| ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------- | -| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | -| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | -| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | -| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | -| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | -| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | +| Method | Result | Behavior | +| ------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | +| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | +| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. `background: true` hides it from default discovery. | +| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | +| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | +| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | + +`create({ source, agent, background })` creates a workspace and its initial agent together. +`agent` accepts agent creation options except `worktree`, `git`, `onEvent`, `idempotencyKey`, +and `requestId`. Set `background` on the workspace options; `agent.background` is rejected. +See the [combined creation example](./agents.md#run-work-in-a-background-workspace). A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. diff --git a/public-docs/sdk/workspaces.md b/public-docs/sdk/workspaces.md index 6775e90b3d..2bf1dd7ee3 100644 --- a/public-docs/sdk/workspaces.md +++ b/public-docs/sdk/workspaces.md @@ -50,8 +50,25 @@ const workspace = await client.workspaces.create({ }); ``` +Pass `agent` to create the workspace and its first agent in one call. See the +[combined background workspace example](./agents.md#run-work-in-a-background-workspace). +Set visibility on the workspace options; `agent.background` is rejected. + You can pass `projectId` in either source when you already have one. Most integrations should omit it; the daemon finds or creates the project from the directory. +Pass `background: true` to hide a workspace from default discovery. Its agents and terminals +remain durable, with ordinary history, lifecycle hooks, archive, and exact-ID access. The app's +sidebar offers **Show background** without dropping replicated state. + +`background` is creation-only. Omission defaults to false; when `callerAgentId` identifies an agent +creating a new workspace, omission inherits its workspace's value. Explicit false or true overrides +inheritance. Agent creation with `workspaceId` rejects explicit background creation intent; configure +visibility when creating the workspace. SDK/RPC implicit agent creation also accepts `background` +as workspace creation intent, never as an agent property. + +Creation and inclusive discovery require `features.backgroundWorkspaces`; the client rejects an +unsupported host with an update-host message. + ## Start an agent in a workspace Create through the workspace handle: @@ -116,6 +133,11 @@ do { } while (cursor); ``` +Background workspaces are left out unless `filter.includeBackground` is true. Use +`client.workspaces.list({ filter: { includeBackground: true } })` or +`client.agents.list({ filter: { includeBackground: true } })`. Workspace descriptors carry +`background`; agents derive visibility from their workspace and have no separate setting. + ## Refresh and archive a handle ```ts diff --git a/public-docs/security.md b/public-docs/security.md index a935a75878..d1b6ee229a 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -121,6 +121,14 @@ The image runs the daemon and launched agents as the non-root `paseo` user, but See [Docker](/docs/docker) for Compose and reverse proxy examples. +## Linux desktop sandbox + +Paseo checks Chromium sandbox availability each time the desktop app launches on Linux. It keeps sandboxing enabled when user namespaces work or an installed sandbox helper is usable. The `.deb` and `.rpm` installers configure that helper. + +AppImage and extracted tar archives launch with Chromium's sandbox disabled when neither is available. Use the `.deb` or `.rpm` package if you require Chromium process isolation. Passing `--no-sandbox` explicitly disables it. + +Open **Settings → Diagnostics → App Diagnostics** to check the sandbox state and reason. The [desktop log](/docs/troubleshooting#reading-the-logs) records the same decision. + ## Agent authentication Paseo wraps agent CLIs (Claude Code, Codex, OpenCode, Muse Code) but does not manage their authentication. Each agent provider handles its own credentials: diff --git a/public-docs/supported-providers.md b/public-docs/supported-providers.md index f2b1370b68..1aa981ba29 100644 --- a/public-docs/supported-providers.md +++ b/public-docs/supported-providers.md @@ -85,6 +85,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in - [goose](https://block.github.io/goose/), Block's local open-source AI agent. - [Grok](https://docs.x.ai/build/overview), xAI's Grok Build agentic coding CLI. - [Hermes Agent](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp), Nous Research's self-improving agent. +- [Hoplite](#hoplite), cloud coding agent through its local ACP bridge. - [Junie](https://junie.jetbrains.com/docs/junie-cli-acp.html), JetBrains' coding agent. - [Kilo Code](https://kilo.ai/docs/code-with-ai/platforms/cli), open-source coding agent. - [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), Moonshot AI's coding assistant. @@ -100,3 +101,18 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in - [VT Code](https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md), open-source multi-provider coding agent. The in-app catalog is the canonical, version-pinned source. Anything not listed here can still be added manually, see [Custom providers](/docs/custom-providers). + +### Hoplite + +Install the [Hoplite CLI](https://hoplite.sh/docs/cli) on your daemon host and run +`hoplite login`. In **Settings → Hosts → your host → Providers**, find **Hoplite** +in the catalog and choose **Add**. Paseo launches `hoplite acp` through the generic ACP adapter. + +Hoplite runs the agent in a cloud workspace. Open a repository linked to a Hoplite project, +or set `agents.providers.hoplite.env.HOPLITE_PROJECT_ID` to use one project everywhere. +Without a linked repository or pinned project, discovery reports `No Hoplite project`. +Your local files and Paseo's loopback MCP tools are not available in that cloud workspace. + +See [Hoplite's ACP guide](https://hoplite.sh/docs/cli/acp) for authentication and project setup. +Use a [command override](/docs/custom-providers) if `hoplite` is outside your daemon's PATH; +keep `acp` in the arguments. diff --git a/public-docs/troubleshooting.md b/public-docs/troubleshooting.md index 77b2497e03..420c3363aa 100644 --- a/public-docs/troubleshooting.md +++ b/public-docs/troubleshooting.md @@ -1,6 +1,6 @@ --- title: Troubleshooting -description: Why Paseo can't find a provider you've installed, and how to fix the PATH and environment mismatches behind most setup issues. +description: Fix Linux AppImage launch errors, missing providers, and environment mismatches. nav: Common problems order: 90 category: Troubleshooting @@ -8,7 +8,22 @@ category: Troubleshooting # Troubleshooting -Almost every "it works in my terminal but not in Paseo" problem is the same thing: Paseo and your terminal aren't searching the same `PATH`. This page covers how to spot that and fix it. +## AppImage won't open on Linux + +Make the downloaded AppImage executable, then launch it from a terminal to see any error: + +```bash +chmod +x Paseo-x86_64.AppImage +./Paseo-x86_64.AppImage +``` + +If it reports `error loading libfuse.so.2`, run without FUSE: + +```bash +./Paseo-x86_64.AppImage --appimage-extract-and-run +``` + +The Paseo window should open. You can also install the `.deb` on Debian/Ubuntu or the `.rpm` on Fedora from the [download page](https://paseo.sh/download). ## Paseo can't find my provider diff --git a/public-docs/web-ui.md b/public-docs/web-ui.md index 9e80e8355e..6fe574e3cd 100644 --- a/public-docs/web-ui.md +++ b/public-docs/web-ui.md @@ -197,7 +197,7 @@ If you don't want to manage a reverse proxy or open ports, a tunnel gives you an - **Tailscale Serve** keeps it inside your tailnet, no public exposure, TLS handled for you: ```bash - tailscale serve https / http://127.0.0.1:6767 + tailscale serve --bg http://127.0.0.1:6767 ``` Reach it at `https://<your-machine>.<tailnet>.ts.net/`. Only devices on your tailnet can connect. diff --git a/public-docs/why.md b/public-docs/why.md index 44ac4a90c9..8b3d0d8531 100644 --- a/public-docs/why.md +++ b/public-docs/why.md @@ -1,6 +1,6 @@ --- title: Why Paseo? -description: What Paseo is, what it isn't, and how it fits into your workflow. +description: A full agentic development environment for running coding agents, editing files, reviewing changes, and testing your work. nav: Why Paseo? order: 2 category: Getting started @@ -8,38 +8,36 @@ category: Getting started # Why Paseo? -Paseo is a self-hostable platform for running and orchestrating coding agents. It runs the agent CLIs you already use, on the hardware you already have, and gives you a UI, CLI, and API to drive them from anywhere. +Paseo is a full agentic development environment. Run coding agents, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. For agent-driven development, Paseo replaces your IDE: take a task from the first prompt through review and testing in one app. -## Architecture +## Develop in one workspace -- Daemon-client architecture. The daemon manages agents; clients (mobile, desktop, web, CLI) connect locally or over a relay. Remote access isn't an add-on. -- macOS, Windows, and Linux are all primary targets. None of them are a port or an afterthought. -- Mobile, desktop, and web are separate native clients. The mobile app is built in React Native, not a webview. +Open the desktop app, choose a project and an agent, and start working. Keep the conversation, editor, terminal, diffs, and browser together in split panes. Run your app, inspect the result, and ask the agent for changes without leaving the workspace. -## Providers +The desktop app is available on macOS, Windows, and Linux. It starts and manages its local daemon automatically. -- Bring your own. Use your Claude subscription, your OpenAI account, your own API keys, a self-hosted endpoint. Paseo doesn't proxy model calls. -- Local voice stack. Speech-to-text and text-to-speech run on-device by default. OpenAI providers are configurable if you want cloud quality. -- Open source. No telemetry on your code. +## Run tasks in parallel -## Where agents run +Give independent tasks their own worktrees and branches. Run several agents at once, inspect each diff, test each implementation, and choose what to merge. Use the same provider for every task or choose a different provider and model for each. -- Your laptop, a homelab, a company server. Same daemon, same client surface. -- Any directory, git or not. Launch agents, merge locally, review the diff in the app. -- GitHub PRs, checks, and reviews surface in the app when you want them. Not required. +Configure [workspace scripts](/docs/worktrees#scripts-and-services) to start development servers with separate ports for each worktree. [Try the parallel development workflow](/docs/parallel-development). -## Parallel work +## Use your agents and tools -- Splits and panes. Agents, terminals, and browsers side by side in one workspace. -- Per-worktree services. Each worktree gets allocated ports for dev servers and databases, reachable through proxy URLs like `web.fix-auth.my-app.localhost` so they don't collide. -- Multiple agents on the same repo via worktrees. +Paseo runs the coding agents you already use, with your subscriptions, credentials, configuration, skills, and MCP servers. Your agents work with the files and tools on your machine. See [supported providers](/docs/supported-providers). -## Automation +Dictate tasks or use [voice mode](/docs/voice). Speech-to-text and text-to-speech run locally by default, with cloud providers available in settings. -- The CLI exposes the same surface as the app. Anything in the UI is scriptable. -- [Paseo tools](/docs/orchestration). Agents can drive Paseo themselves: create isolated workspaces, spawn subagents, open terminals, and send prompts. -- [Hub](/docs/hub). A service you host that starts agents on your daemon when someone mentions you on GitHub, Slack, or Discord. +## Continue from another device -## What it isn't +The daemon owns the running agents and workspaces. The desktop app connects to it locally; mobile, web, and CLI clients connect to the same environment. That separation lets you continue working from another device or run your environment on a server. -Not a hosted agent, not an IDE, not a model provider. Paseo runs the CLIs you already use and stays out of the way. +Use Paseo entirely on your laptop, or connect to another machine when you need it. The native iOS and Android app gives you agents, files, terminals, and diffs on your phone. The built-in browser runs in the desktop app. [Connect your devices](/docs/connectivity) directly or through the optional end-to-end encrypted relay. + +## Automate and extend your workflow + +Give agents [Paseo tools](/docs/orchestration) to create worktrees, launch other agents, send prompts, and collect results. Use the [CLI](/docs/cli) or [TypeScript SDK](/docs/sdk) to automate agent and workspace operations. + +[Plugins](/docs/plugins) add providers, workspace panels, commands, and workflows. [Hub](/docs/hub) connects external events to agents running on your machines. + +Paseo is open source under Apache-2.0, with no telemetry, tracking, or required Paseo account. diff --git a/public-docs/workspaces.md b/public-docs/workspaces.md index caf59884c4..2e74935d54 100644 --- a/public-docs/workspaces.md +++ b/public-docs/workspaces.md @@ -48,6 +48,8 @@ The workspace is the product concept; a git worktree is one way to isolate its f ## Creating a workspace +For a desktop walkthrough from starting parallel agents to reviewing and testing their changes, see [Run parallel tasks](/docs/parallel-development). + You can create a workspace in the app or from the CLI: ```bash diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 4235127a63..3eeafc6371 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -12,10 +12,6 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-markdown-display", patchPrefix: "react-native-markdown-display+", }, - { - nodeModulesPath: "node_modules/react-native-uitextview", - patchPrefix: "react-native-uitextview+", - }, // Remove after react-native-unistyles ships // https://github.com/jpudysz/react-native-unistyles/pull/1203. { @@ -30,6 +26,14 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-gesture-handler", patchPrefix: "react-native-gesture-handler+", }, + // Exports BottomSheetModalInternalProvider so a subtree can host its own sheets on the shared + // modal stack (see packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx), and + // keeps a dismissing modal's status through its final onChange(-1), so a modal with + // enableDismissOnClose=false that unmounts while presented still leaves the portal. + { + nodeModulesPath: "node_modules/@gorhom/bottom-sheet", + patchPrefix: "@gorhom+bottom-sheet+", + }, { nodeModulesPath: "node_modules/react-native-svg", patchPrefix: "react-native-svg+", diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 6fc8c90f87..e511a7e12f 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -64,6 +64,7 @@ The generated project contains: ```text my-plugin/ paseo-plugin.json + OVERVIEW.md package.json tsconfig.json index.client.tsx @@ -638,6 +639,47 @@ Do not edit a local config when the target is a remote daemon. Perform the edit When the same screen or sidebar item exists on several connected hosts, Paseo shows it once. The screen header has a host picker; a sidebar item uses the host of the screen on display, else the host last picked in one of the plugin's screens. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host. +## Publishing a listing + +Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo. +It helps someone decide whether to install your plugin; the install command is already at +the top of that page. A README assumes a GitHub audience and carries installation +instructions, technical details, and badges. Long, AI-generated READMEs make people read +past that material to understand what a plugin does. + +`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside +`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it +relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm +package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment +with useful facts before publishing. + +The repository overview takes precedence over a registry import stopgap. Online validation +fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records +that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump +requires a repository overview and removes the stopgap in the same PR. A bump without the +repository overview fails validation. README files do not supply the overview. + +Author overviews and registry stopgaps follow the same content contract, in this order. +Choose headings only when they help; length follows complexity. A theme needs one paragraph. + +1. Explain what the plugin is and does in plain terms first. +2. Explain how it works only when that is not obvious. +3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other + plugins. Include applicable daemon version and operating system requirements. Setup + guidance is allowed; installation instructions are not. +4. Explain capabilities and settings worth understanding, what each option does, what the + plugin reads or sends and where, permissions, and known limits. + +Use sentence case and plain factual language, with no em dashes. Omit installation commands, +badges, changelog, contributing or license sections, marketing, and unsupported claims. +Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields, +or lists of absent features. Keep only what helps someone choose the plugin. + +If your plugin is an unchanged imported record, you can propose an author-written overview +to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`. + +See [Your listing page](https://paseo.sh/docs/plugins/publishing.md#your-listing-page). + ## Typecheck and manage When editing a plugin, typecheck its source before install or reload: diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 60ca70057a..11b5f2ff97 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -23,9 +23,11 @@ For a local daemon, `project create` defaults to the current directory and resol **`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`. +`background: true` hides a workspace from default lists and the sidebar while its contents remain durable and accessible; omit `background` to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks. + Choose `baseBranch` explicitly: `origin/main` selects the remote-tracking branch; `refs/heads/main` selects local main. Bare `main` prefers local main when it exists, otherwise origin/main. Paseo retains the resolved ref for workspace comparisons, even after rebasing the branch or changing its PR target. -**`list_workspaces`** — list active workspaces. +**`list_workspaces`** — list active workspaces, excluding background workspaces unless `includeBackground: true`. **`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived. @@ -61,11 +63,11 @@ Detach is an explicit user action in the subagents track, not an agent tool. A c Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents. -**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Agent-scoped prompt calls default to `background: true` and `notifyOnFinish: true`; top-level calls default to blocking with no callback. For a synchronous follow-up, pass `background: false` and use the returned result. +**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback. **`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`. -**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`. +**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`; set `includeBackground: true` to include agents in background workspaces. **`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list. @@ -106,9 +108,11 @@ Schedules have the full list/inspect/update/pause/resume/run-once/log/delete sur Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows. -For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own. +`create_agent` and `send_agent_prompt` return after startup or dispatch acknowledgement in both MCP scopes. Top-level callers have no parent callback; observe completion through status/activity tools or SDK/RPC subscriptions and wait APIs. -Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you. +For agent-scoped `create_agent` and `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own. + +With finish notifications enabled, don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you. ## CLI semantics diff --git a/vitest.config.ts b/vitest.config.ts index 77e75f6d6c..47bd965113 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import fs from "node:fs"; import path from "node:path"; import { configDefaults, defineConfig } from "vitest/config"; +// React's test APIs require its development build, even in a production shell. +process.env.NODE_ENV = "test"; + const appDir = path.resolve(__dirname, "packages/app"); const appNodeModules = path.resolve(appDir, "node_modules"); const rootNodeModules = path.resolve(__dirname, "node_modules");