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 3a4c3ab914..0e2b24ac5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,34 @@ # Changelog -## 0.11.0-beta.5 - 2026-10-06 +## 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)) @@ -16,21 +38,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 -- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps. - - 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)) @@ -46,12 +73,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)) @@ -66,12 +99,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)) @@ -85,12 +120,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)) @@ -98,12 +139,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)) @@ -123,6 +168,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.md b/README.md index a8fe3f5b25..2fc0fc8f3d 100644 --- a/README.md +++ b/README.md @@ -39,17 +39,19 @@ Paseo mobile app

-Paseo is a desktop, mobile, web, and CLI app for coding agents. 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. +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. -- **Full IDE:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want. +- **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. + ## Plugins Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 8662ee3287..f501538270 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 the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it 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/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/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..abd95fc35f 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 | +| Workspace sheet | Compact look-ups | Holds one tab; closes or becomes a tab | ## Panel host contract @@ -22,6 +23,24 @@ 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. +## Workspace sheet + +On compact layouts, files and subagents open in a closable 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. The sheet holds one ordinary +`WorkspaceTab`, which is not persisted. + +The sheet is modal and covers the whole compact column, header included. It renders in the +workspace tree rather than through `IsolatedBottomSheetModal`: its panel needs the navigator and +every workspace provider an ordinary pane has, and a portal drops them +([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)). + +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 +81,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 0d3983c343..73df983a50 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -304,7 +304,9 @@ 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. +sibling dialog over an existing iOS presenter. The one exception is the +[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a +whole workspace panel. 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, diff --git a/docs/glossary.md b/docs/glossary.md index 3ed97d1fc3..ac988e8d2b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -21,6 +21,7 @@ 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). +- **Workspace sheet** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet). - **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). 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/npm-deps.hash b/nix/npm-deps.hash index 32aedbf346..8906abc66b 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4= +sha256-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ= diff --git a/package-lock.json b/package-lock.json index a2856c464a..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", @@ -41754,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/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/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/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..c245d2ae10 --- /dev/null +++ b/packages/app/e2e/browser/compact-workspace-header.spec.ts @@ -0,0 +1,96 @@ +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"; + +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 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 () => ({ + 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.title).not.toBeNull(); + expect(bounds.project).not.toBeNull(); + expect(bounds.badge).not.toBeNull(); + expect(bounds.icon).not.toBeNull(); + expect(bounds.trigger).not.toBeNull(); + // Approved geometry: 12px outside margin, 46px leading island, 8px title gap. + expect(bounds.title!.x).toBe(66); + expect(bounds.project!.x).toBe(bounds.title!.x); + expect(bounds.trigger!.width).toBe(32); + expect(bounds.trigger!.height).toBe(32); + // The action frame starts 7px inside its island; preserve the 8px gap before it. + const contentRight = bounds.trigger!.x - 15; + for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) { + expect(box.x + box.width).toBeLessThanOrEqual(contentRight + 1); + } + expect(bounds.project!.width).toBeGreaterThan(20); + expect(bounds.icon!.width).toBe(12); + 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(); + } + }); + } +} 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..474a498353 --- /dev/null +++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts @@ -0,0 +1,106 @@ +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 { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents"; +import { + closeWorkspaceSheet, + expectOpenTabCount, + openWorkspaceSheetAsTab, + expectDeclinedDiscardPrompt, + expectWorkspaceSheetAtSnap, + replaceWorkspaceSheetFileText, + selectTabFromSwitcher, + tapWorkspaceSheetBackdrop, + workspaceSheet, +} from "../support/helpers/workspace-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 sheet = workspaceSheet(page, "linked.ts"); + await expect(sheet).toBeVisible(); + await expect(sheet.getByText("three")).toBeVisible(); + await expectWorkspaceSheetAtSnap(page, "linked.ts"); + await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0); + await expectOpenTabCount(page, 1); + await closeWorkspaceSheet(page, "linked.ts"); + }); + + await test.step("a subagent opens in a sheet without its composer", async () => { + await openSubagentFromCompactTrack(page, child.id); + + const sheet = workspaceSheet(page, child.title); + await expect(sheet).toBeVisible(); + await expectWorkspaceSheetAtSnap(page, child.title); + await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0); + await expectOpenTabCount(page, 1); + }); + + await test.step("Open as tab promotes the subagent to a full tab", async () => { + await openWorkspaceSheetAsTab(page, child.title); + await expectOpenTabCount(page, 2); + await expectComposerVisible(page); + }); + + await test.step("a sheet with unsaved edits closes only through the guarded close", async () => { + await selectTabFromSwitcher(page, "Sheet parent"); + await page.getByText(FILE_LINK, { exact: true }).click(); + const sheet = workspaceSheet(page, "linked.ts"); + await expect(sheet).toBeVisible(); + // Hold the autosave so the edit stays unsaved. + gate.holdNextClientRequest("fs.file.write.request"); + await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet"); + await gate.waitForHeldClientRequest(); + + await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page)); + await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet"); + await expectDeclinedDiscardPrompt(page, () => + sheet.getByRole("button", { name: "Close" }).click(), + ); + await expect(sheet.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-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-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 57951b8289..e91599f853 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,7 +7,27 @@ 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, + recordVisibleComposerGeometry, + expectNoCollapsedVisibleComposer, + expectAttachmentPill, + expectQueuedMessageButton, + startRunningMockAgent, + composerLocator, + expectComposerVisible, +} from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; async function composerHeight(page: Page): Promise { @@ -132,3 +154,300 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } await agent.cleanup(); } }); + +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 stays open 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 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 selectComposerDraftRange(page, 4, 17); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(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 exitComposerFullscreen(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 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 expectComposerFullscreenControls(page); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); + } 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 expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText( + "Requested mock prompt rejection", + ); + 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 expectComposerFullscreenControls(page); + } 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 expectComposerFullscreenControls(page); + await exitComposerFullscreen(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 selectComposerDraftRange(page, 4, 17); + await activateRetainedComposerTab(page, first.title); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await writeCompactDraft(page, "Other tab draft"); + await shrinkVisibleViewport(page); + await activateRetainedComposerTab(page, second.title); + await expectComposerFullscreenControls(page); + await expect(composerLocator(page)).toHaveValue(draft); + await expectFullscreenInsideVisibleViewport(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("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(); +} + +async function expectFullscreenInsideVisibleViewport(page: Page): Promise { + await expect + .poll(() => + page.getByTestId("composer-fullscreen").evaluate((element) => { + const viewport = window.visualViewport!; + const frame = element.getBoundingClientRect(); + const send = element.querySelector('[aria-label="Send message"]')!.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, + }; + }), + ) + .toEqual({ frameFits: true, sendFits: true }); +} + +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 expectFullscreenInsideVisibleViewport(page); + await shrinkVisibleViewport(page); + await expectFullscreenInsideVisibleViewport(page); + await panVisibleViewport(page); + await expectFullscreenInsideVisibleViewport(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/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/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..a4fbd15edf 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -9,13 +9,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 +41,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 +97,51 @@ 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(); + }); + }); }); 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/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/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/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-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/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/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/composer.ts b/packages/app/e2e/support/helpers/composer.ts index de8ddf0c9b..01b9a39d58 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -316,3 +316,133 @@ 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]); +} +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); +} +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. + */ +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/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..8ad7e0a632 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,21 @@ 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", + }); +} 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/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/workspace-sheet.ts b/packages/app/e2e/support/helpers/workspace-sheet.ts new file mode 100644 index 0000000000..8865073c81 --- /dev/null +++ b/packages/app/e2e/support/helpers/workspace-sheet.ts @@ -0,0 +1,94 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** The compact workspace sheet, named after the tab it hosts. */ +export function workspaceSheet(page: Page, label: string): Locator { + return page.getByRole("dialog", { name: label }); +} + +/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */ +const SHEET_SNAP_FRACTION = 0.92; +/** The dialog starts below the sheet's drag handle. */ +const SHEET_HANDLE_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 expectWorkspaceSheetAtSnap(page: Page, label: string): Promise { + const viewportHeight = page.viewportSize()?.height ?? 0; + const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION)); + const sheet = workspaceSheet(page, label); + await expect + .poll(async () => { + const box = await sheet.boundingBox(); + if (!box) return null; + const top = Math.round(box.y); + return { + dockedToBottom: Math.round(box.y + box.height) === viewportHeight, + topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_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 closeWorkspaceSheet(page: Page, label: string): Promise { + const sheet = workspaceSheet(page, label); + await sheet.getByRole("button", { name: "Close" }).click(); + await expect(sheet).toHaveCount(0); +} + +export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise { + const sheet = workspaceSheet(page, label); + await sheet.getByRole("button", { name: "Open as tab" }).click(); + await expect(sheet).toHaveCount(0); +} + +export async function replaceWorkspaceSheetFileText( + page: Page, + label: string, + content: string, +): Promise { + const editor = workspaceSheet(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(); + 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); +} + +export async function tapWorkspaceSheetBackdrop(page: Page): Promise { + await page + .getByRole("button", { name: "Bottom sheet backdrop" }) + .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/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/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-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/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/view.tsx b/packages/app/src/agent-stream/view.tsx index 12f628da4f..1e60240be9 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -792,6 +792,7 @@ const AgentStreamViewComponent = forwardRef + {!isCompactLayout ? ( {themedSidebarChrome} @@ -625,7 +626,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon surface ); - return {content}; + return ( + + {content} + + ); } function SidebarChrome({ 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/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/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..707c0e8622 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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 { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; +import Animated from "react-native-reanimated"; import { ArrowLeft, Search, X } 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, @@ -263,40 +253,6 @@ 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]); - - return ( - - {children} - - ); -} - export function SheetHeaderView({ header, onClose, @@ -471,8 +427,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,7 +447,6 @@ export function AdaptiveModalSheet({ presentation, contentStyle, bodyStyle, - sizeContentToCurrentSnapPoint = true, contextBridge = null, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); @@ -519,11 +472,15 @@ export function AdaptiveModalSheet({ () => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }), [compactSafeAreaPadding.footerPaddingBottom], ); - const footerView = footer ? ( - - {footer} - - ) : null; + const footerView = useMemo( + () => + footer ? ( + + {footer} + + ) : null, + [footer, footerClearanceStyle, footerContainerStyle], + ); const handleIndicatorStyle = useMemo( () => ({ backgroundColor: theme.colors.palette.zinc[600] }), [theme.colors.palette.zinc], @@ -628,7 +585,6 @@ export function AdaptiveModalSheet({ )} - {footerView} ); @@ -650,11 +606,7 @@ export function AdaptiveModalSheet({ accessible={false} presentation={presentation} > - {sizeContentToCurrentSnapPoint ? ( - {sheetContent} - ) : ( - sheetContent - )} + {sheetContent} ); } 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..b18a8731b0 --- /dev/null +++ b/packages/app/src/components/floating-header/index.tsx @@ -0,0 +1,99 @@ +import { useCallback, useId, useState, type ReactNode } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; + +/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */ +const FADE_OVERHANG = 24; + +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} + + ); +} + +function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) { + return ( + + + + {/* Vary opacity rather than interpolating toward `transparent`, which crosses black in + some engines and leaves a grey fringe. */} + + + + + + + + + ); +} + +const ThemedFloatingHeaderFadeSvg = withUnistyles(FloatingHeaderFadeSvg); +const workspaceSurfaceColorMapping = (theme: Theme) => ({ + color: theme.colors.surfaceWorkspace, +}); + +function FloatingHeaderFade() { + // React-generated ids contain characters that are invalid inside SVG fragment references. + const gradientId = `floating-header-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + minHeight: 0, + }, + content: { + flex: 1, + minHeight: 0, + }, + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + }, + fade: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: -FADE_OVERHANG, + }, +}); 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..aa66580fce --- /dev/null +++ b/packages/app/src/components/headers/compact-header.tsx @@ -0,0 +1,168 @@ +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 ISLAND_PADDING = 7; +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. It has no background of its own, so it reads the same in flow and floating + * over content (see `FloatingHeaderLayout`). 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", + padding: ISLAND_PADDING, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, + ...theme.shadow.sm, + }, + title: { + fontSize: theme.fontSize.base, + 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..087a3ffca8 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,7 @@ 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 { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; @@ -37,6 +45,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 +289,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 +334,10 @@ function FooterIconButton({ } function SidebarHostPicker({ - theme, label, onAddHost, onOpenHostSettings, }: { - theme: SidebarTheme; label: string; onAddHost: () => void; onOpenHostSettings: (serverId: string) => void; @@ -377,7 +381,6 @@ function SidebarHostPicker({ icon={Server} // Server's two boxes fill more of the square than the other glyphs. iconSizeAdjustment={-1} - theme={theme} /> ); @@ -399,7 +402,6 @@ function IconTooltipContent({ } function SidebarFooter({ - theme, handleOpenProject, handleSettings, labels, @@ -407,7 +409,6 @@ function SidebarFooter({ handleOpenHostSettings, onBeforeNavigate, }: { - theme: SidebarTheme; handleOpenProject: () => void; handleSettings: () => void; labels: { @@ -436,11 +437,9 @@ function SidebarFooter({ label={labels.addProject} icon={FolderPlus} shortcutKeys={newAgentKeys} - theme={theme} /> - + @@ -461,7 +459,7 @@ function SidebarFooter({ ); } -function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) { +function SidebarUsageIcon({ label }: { label: string }) { const openUsage = useOpenSidebarUsage(); return ( ); } @@ -612,7 +609,6 @@ function MobileSidebar({ )} ({ 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..d8b45ae415 100644 --- a/packages/app/src/components/markdown/html-ish.test.ts +++ b/packages/app/src/components/markdown/html-ish.test.ts @@ -391,6 +391,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..bd095c60b3 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: ["**", "**"], @@ -373,14 +379,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 +408,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 +614,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 +636,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 +649,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT closing: true, selfClosing: false, attributes: {}, - raw: ``, + raw: readRawTag() ?? ``, }); }, ontext(value) { @@ -660,6 +676,8 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT ); return { write(chunk) { + chunkStart = writtenLength; + writtenLength += chunk.length; parser.write(chunk); }, skip(length) { @@ -698,7 +716,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 583585b782..4587556ebe 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"; @@ -3022,6 +3023,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa } interface ToolCallProps { + agentMessage?: AgentMessage; toolName: string; args?: unknown; result?: unknown; @@ -3041,6 +3043,7 @@ interface ToolCallProps { } export const ToolCall = memo(function ToolCall({ + agentMessage, toolName, args, result, @@ -3081,6 +3084,7 @@ export const ToolCall = memo(function ToolCall({ const presentation = useMemo( () => buildToolCallPresentation({ + agentMessage, toolName, status, error: error ?? null, @@ -3089,7 +3093,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 +3109,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 +3121,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 +3162,7 @@ export const ToolCall = memo(function ToolCall({ return ( ( <> - 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 @@ -2527,7 +2522,7 @@ const styles = StyleSheet.create((theme) => ({ // 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,27 +2542,27 @@ 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, }, newWorkspaceGhostText: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, newWorkspaceGhostTextHovered: { - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, color: theme.colors.foreground, }, projectRow: { position: "relative", - minHeight: 36, + minHeight: density.rowHeight, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -2608,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({ }, projectTitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", minWidth: 0, flexShrink: 1, @@ -2689,7 +2684,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 index 9dc2c9fdba..2169492a08 100644 --- a/packages/app/src/components/sidebar/pinned-section-header.tsx +++ b/packages/app/src/components/sidebar/pinned-section-header.tsx @@ -2,16 +2,11 @@ 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 { StyleSheet } from "react-native-unistyles"; +import { DensityIcon } from "@/components/ui/density-icon"; 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, -}); +import { density, densityFontSize } from "@/styles/density"; export function PinnedSectionHeader({ collapsed, @@ -23,7 +18,6 @@ export function PinnedSectionHeader({ const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); - const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown; return ( {t("sidebar.pinned.title")} {hovered || isNative || isCompact ? ( - + ) : null} )} @@ -47,7 +45,7 @@ export function PinnedSectionHeader({ const styles = StyleSheet.create((theme) => ({ header: { - minHeight: 36, + minHeight: density.rowHeight, flexDirection: "row", alignItems: "center", alignSelf: "flex-start", @@ -58,7 +56,7 @@ const styles = StyleSheet.create((theme) => ({ }, title: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: densityFontSize(theme, "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 e8c1e3a5d6..3f31181220 100644 --- a/packages/app/src/components/sidebar/project-leading-visual.tsx +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; +import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getProjectStatusBadgeContent, @@ -35,9 +36,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); @@ -230,12 +230,12 @@ function ProjectIcon({ projectViewKey: string; }) { return ( - ); } @@ -271,12 +271,12 @@ 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 shares the same `rowLineHeight`. projectLeadingVisualSlot: { - width: theme.iconSize.md, - height: LEADING_SLOT_HEIGHT, + width: densityIconBox("md"), + height: density.rowLineHeight, flexShrink: 0, alignItems: "center", justifyContent: "center", @@ -284,8 +284,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..3d437ae0ca 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, densityFontSize, 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,21 +92,21 @@ 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, }, text: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, textHovered: { color: theme.colors.foreground, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 4519f6d4ff..7001bf66a6 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, densityFontSize, 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,17 +142,17 @@ 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, + fontSize: densityFontSize(theme, "base"), fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, 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 0cd7dc2b72..ec834aee19 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -57,6 +57,7 @@ export function SidebarModelProvider({ }) { 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); @@ -107,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], @@ -124,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, diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index c299d07987..85c0d524f8 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, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { withUnistyles } from "react-native-unistyles"; import { @@ -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); @@ -470,23 +470,23 @@ function StatusGroupLeadingVisual({ return ; } 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 ; } } @@ -1058,7 +1058,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, @@ -1084,8 +1084,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", @@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({ }, statusGroupTitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", minWidth: 0, flexShrink: 1, @@ -1108,7 +1108,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-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 7b48f0dd6c..33f14afcb5 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 { density, densityFontSize, 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} ); @@ -482,8 +480,8 @@ const styles = StyleSheet.create((theme) => ({ }, workspaceStatusDot: { position: "relative", - width: theme.iconSize.md, - height: 20, + width: densityIconBox("md"), + height: density.rowLineHeight, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", @@ -515,9 +513,9 @@ const styles = StyleSheet.create((theme) => ({ // to the meta row, so it takes the full width the trailing slot leaves behind. workspaceBranchText: { color: theme.colors.foreground, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", - lineHeight: 20, + lineHeight: density.rowLineHeight, 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/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/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/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 3f9c9113d0..8dd55e5678 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 @@ -152,3 +152,5 @@ export function useIsolatedBottomSheetVisibility({ handleSheetDismiss, }; } + +export { SheetVisibleFrame } from "./visible-frame"; 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/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/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..243e813f26 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,56 @@ 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); + }, + 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..30ee0c6eba 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,35 @@ 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); + } + }, + 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..a668c00743 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -2,6 +2,7 @@ import { ComposerDockBackground } from "./internal/background"; export { ComposerDockBackground } from "./internal/background"; 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"; @@ -17,6 +18,7 @@ import Animated, { import { useKeyboardShift } from "@/keyboard/shift"; import { resolveCenteredClearance, + resolveInlineComposerCapacity, updateComposerCapacity, type ComposerCapacity, } from "./internal/capacity"; @@ -71,12 +73,13 @@ function ComposerViewport({ /** Bound the complete composer (including its controls), not just the text input. */ function ComposerViewportContent({ style, ...props }: ViewProps) { + const compact = useIsCompactFormFactor(); const capacity = useContext(ViewportCapacity); if (!capacity) 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: resolveInlineComposerCapacity(capacity.value, compact), })); return ( diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx index d39439f325..3f41118696 100644 --- a/packages/app/src/composer/dock/index.tsx +++ b/packages/app/src/composer/dock/index.tsx @@ -1,8 +1,10 @@ export { ComposerDockBackground } from "./internal/background"; import { ScrollView } from "@/components/ui/scroll-view"; -import type { ReactNode } from "react"; +import { useState, useCallback, useMemo, type ReactNode } from "react"; import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { resolveInlineComposerCapacity } from "./internal/capacity"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; interface ComposerDockProps { @@ -14,23 +16,33 @@ export function ComposerDock({ children: [content, composer, overlay], centered = false, }: ComposerDockProps) { + const compact = useIsCompactFormFactor(); + const [availableHeight, setAvailableHeight] = useState(); + const constraint = useMemo( + () => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }), + [availableHeight, compact], + ); + const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + if (height > 0) setAvailableHeight(height); + }, []); if (centered) { return ( - + {content} - {composer} + {composer} {overlay} ); } return ( - + {content} - {composer} + {composer} {overlay} ); diff --git a/packages/app/src/composer/dock/internal/capacity.test.ts b/packages/app/src/composer/dock/internal/capacity.test.ts index 87796f23d4..7e98b7ef3d 100644 --- a/packages/app/src/composer/dock/internal/capacity.test.ts +++ b/packages/app/src/composer/dock/internal/capacity.test.ts @@ -1,11 +1,24 @@ import { describe, expect, it } from "vitest"; import { resolveCenteredClearance, + 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("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..ca4fed2991 100644 --- a/packages/app/src/composer/dock/internal/capacity.ts +++ b/packages/app/src/composer/dock/internal/capacity.ts @@ -50,3 +50,13 @@ export function updateComposerCapacity( capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }), }; } + +/** 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; +} diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 0812fb280d..c64753694a 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,6 +117,7 @@ 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(), })); @@ -123,6 +126,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr (nextText: string) => { textReplacementRevisionRef.current += 1; setTextReplacement({ + kind: "replace", key: `${draftKey}:${textReplacementRevisionRef.current}`, text: nextText, }); @@ -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..4aa22a324d 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -86,46 +86,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 +127,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 +160,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 +201,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 +215,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) { 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..d593e5a64e --- /dev/null +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { ComposerEditingSession } from "./index"; + +function leaveLiveDraft(session: ComposerEditingSession) { + session.captureForRemount({ + snapshot: { text: "Unpublished live draft", selection: { start: 4, end: 17 } }, + focused: true, + replacementKey: "draft:applied", + }); +} + +describe("composer editing remount handoff", () => { + it("keeps live text and selection when remounting from an initial draft publication", () => { + const session = new ComposerEditingSession(); + leaveLiveDraft(session); + const handoff = session.takeHandoff({ kind: "initial", key: "draft:initial", text: "stale" }); + expect(handoff?.snapshot).toEqual({ + text: "Unpublished live draft", + selection: { start: 4, end: 17 }, + }); + expect(handoff?.focused).toBe(true); + }); + + it("keeps local edits when the last applied replacement is still current", () => { + const session = new ComposerEditingSession(); + leaveLiveDraft(session); + const handoff = session.takeHandoff({ + kind: "replace", + key: "draft:applied", + text: "old replacement", + }); + expect(handoff?.snapshot).toEqual({ + text: "Unpublished live draft", + selection: { start: 4, end: 17 }, + }); + }); + + it.each(["New explicit draft", ""])( + "honors an explicit replacement made while the editor is absent: %j", + (text) => { + const session = new ComposerEditingSession(); + leaveLiveDraft(session); + const handoff = session.takeHandoff({ kind: "replace", key: "draft:new", text }); + expect(handoff?.snapshot).toEqual({ + text, + selection: { start: text.length, end: text.length }, + }); + expect(handoff?.focused).toBe(true); + expect(session.takeHandoff({ kind: "replace", key: "draft:new", text })).toBeNull(); + }, + ); +}); 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..97f7a79c51 --- /dev/null +++ b/packages/app/src/composer/editing-session/index.tsx @@ -0,0 +1,142 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { TextReplacement } from "@/composer/types"; +import type { ComposerInputMode } from "@/composer/input-mode"; + +export interface ComposerEditingHandoff { + snapshot: { text: string; selection: { start: number; end: number } }; + focused: boolean; + replacementKey: string; +} + +/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */ +export class ComposerEditingSession { + private fullscreen = false; + private handoff: ComposerEditingHandoff | null = null; + private listeners = new Set<() => void>(); + getFullscreen = () => this.fullscreen; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) { + this.captureForRemount(handoff); + if (fullscreen === this.fullscreen) return; + this.fullscreen = fullscreen; + for (const listener of this.listeners) listener(); + } + captureForRemount(handoff: ComposerEditingHandoff) { + this.handoff = handoff; + } + peekHandoff(replacement: TextReplacement): ComposerEditingHandoff | null { + const handoff = this.handoff; + if (!handoff || replacement.kind === "initial" || replacement.key === handoff.replacementKey) + return handoff; + // A replacement command issued since capture supersedes the old editor's live buffer. + return { + snapshot: { + text: replacement.text, + selection: { start: replacement.text.length, end: replacement.text.length }, + }, + focused: handoff.focused, + replacementKey: replacement.key, + }; + } + takeHandoff(replacement: TextReplacement) { + const handoff = this.peekHandoff(replacement); + this.handoff = null; + return handoff; + } +} + +class ComposerEditingSessions { + private workspaces = new Map< + string, + Map }> + >(); + 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(); + tab.sessions.set(purpose, session); + } + return session; + } + retain(workspaceKey: string, tabIds: readonly string[]) { + const workspace = this.workspaces.get(workspaceKey); + if (!workspace) return; + const retained = new Set(tabIds); + for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId); + if (workspace.size === 0) this.workspaces.delete(workspaceKey); + } +} + +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); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c36d7f9e41..c77b39d332 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,3 +1,4 @@ +import { useComposerEditingSession, useComposerFullscreen } from "./editing-session"; import type { ComposerTextSource } from "./text-source"; import { createStore, type StoreApi } from "zustand/vanilla"; import { useStore } from "zustand"; @@ -1003,8 +1004,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( @@ -1336,6 +1339,8 @@ function ComposerContentImpl({ const nextPendingFileId = useRef(0); const isUploadingFile = pendingFiles.length > 0; const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); + const editingSession = useComposerEditingSession(inputMode); + const isFullscreen = useComposerFullscreen(editingSession); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); @@ -2364,7 +2369,7 @@ function ComposerContentImpl({ {queueList} - {sendErrorNode} + {!isFullscreen && sendErrorNode} @@ -2387,6 +2392,8 @@ function ComposerContentImpl({ void; + active: boolean; +}) { + useEffect(() => { + if (!active) return; + const handler = BackHandler.addEventListener("hardwareBackPress", () => { + onExit(); + return true; + }); + return () => handler.remove(); + }, [active, onExit]); + const insets = useSafeAreaInsets(); + const { height } = useWindowDimensions(); + const { layoutShift } = useKeyboardShift(); + const frame = useAnimatedStyle(() => ({ + height: Math.max(0, height - layoutShift.value), + paddingTop: insets.top, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + paddingRight: insets.right, + })); + return ( + + + {children} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + hidden: { display: "none" }, + background: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: theme.colors.surface1, + }, +})); diff --git a/packages/app/src/composer/input/fullscreen-host.tsx b/packages/app/src/composer/input/fullscreen-host.tsx new file mode 100644 index 0000000000..ba634b268d --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-host.tsx @@ -0,0 +1,77 @@ +import { useMemo, useLayoutEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { View } from "react-native"; +import { + getOverlayRoot, + useOverlayLayer, + OverlayLayerProvider, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; + +export function ComposerFullscreenHost({ + children, + onExit, + active, +}: { + children: ReactNode; + onExit: () => void; + active: boolean; +}) { + const layer = useOverlayLayer("modal"); + const registration = useWebOverlayRegistration({ + active, + layer, + onKeyDown: (event) => { + if (event.key !== "Escape") return false; + event.preventDefault(); + onExit(); + return true; + }, + }); + const [viewport, setViewport] = useState(readVisibleViewport); + useLayoutEffect(() => { + if (!active) return; + const update = () => setViewport(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]); + const frameStyle = useMemo( + () => ({ + display: active ? ("flex" as const) : ("none" as const), + position: "absolute" as const, + top: viewport.top, + left: viewport.left, + width: viewport.width, + zIndex: layer, + pointerEvents: "auto" as const, + height: viewport.height, + }), + [active, layer, viewport], + ); + return createPortal( + + + {children} + + , + getOverlayRoot(), + ); +} + +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, + }; +} 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..a0476ff7a7 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, @@ -56,6 +61,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 +83,7 @@ import { applyDictationTranscript, computeCanStartDictation, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -106,6 +113,8 @@ export interface ComposerKeyPressEvent { } export interface MessageInputProps { + editingSession?: ComposerEditingSession; + submissionError?: React.ReactNode; value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; @@ -206,6 +215,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< >; interface TextAreaHandle { + value?: string; scrollHeight?: number; clientHeight?: number; offsetHeight?: number; @@ -215,7 +225,7 @@ interface TextAreaHandle { style?: { height?: string; overflowY?: string; - } & Record; + }; } function AttachButtonIcon({ @@ -433,7 +443,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM } interface PasteImagesEffectArgs { - getWebTextArea: () => TextAreaHandle | null; + textarea: HTMLElement | null; isConnected: boolean; disabled: boolean; isDictating: boolean; @@ -443,7 +453,7 @@ interface PasteImagesEffectArgs { function usePasteImagesEffect(args: PasteImagesEffectArgs): void { const { - getWebTextArea, + textarea, isConnected, disabled, isDictating, @@ -454,19 +464,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 +491,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( @@ -628,6 +619,7 @@ function FocusHint({ interface ComposerTextSurfaceProps { readOnly: boolean; value: string; + textReplacementKey: string; textInputRef: React.Ref; textInputStyle: EditingTextInputProps["style"]; readOnlyTextStyle: React.ComponentProps["style"]; @@ -643,6 +635,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 +649,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 +676,28 @@ 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 +887,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 +902,7 @@ interface StartDictationContext { canStartDictation: () => boolean; toast: { error: (msg: string) => void }; startDictation: () => Promise; + exitFullscreen: () => void; } async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise { @@ -882,6 +913,7 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis if (!ctx.canStartDictation()) { return; } + ctx.exitFullscreen(); await ctx.startDictation(); } @@ -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)); } @@ -1007,9 +1040,12 @@ function getComposerInputSnapshot( current: ComposerTextInputHandle | null, fallbackText: string, fallbackSelection: ComposerInputSnapshot["selection"], + mountedTextArea?: TextAreaHandle | null, ): ComposerInputSnapshot { - const text = current?.getText() ?? fallbackText; - const textArea = getWebTextAreaImpl(current); + // The inner DOM ref can detach before the outer editing handle. Keep the mounted + // element available until this owner's ref callback captures the remount handoff. + const textArea = mountedTextArea ?? 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 +1175,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 +1244,15 @@ export const MessageInput = forwardRef( textReplacement, submitLabel, } = resolveMessageInputProps(props); + const { isActiveComposer } = 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 +1269,19 @@ export const MessageInput = forwardRef( const inputWrapperRef = useRef(null); const textInputRef = useRef(null); const isInputFocusedRef = useRef(false); - const valueRef = useRef(value); - const selectionRef = useRef({ start: value.length, end: value.length }); - 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 handoff = editingSession.peekHandoff(textReplacement); + const focusIntentRef = useRef(Boolean(handoff?.focused)); + const valueRef = useRef(handoff?.snapshot.text ?? value); + const selectionRef = useRef( + handoff?.snapshot.selection ?? { start: value.length, end: value.length }, ); - + const activeComposerRef = useRef(isActiveComposer); + activeComposerRef.current = isActiveComposer; + const [webTextArea, setWebTextArea] = useState(null); + const appliedTextReplacementKeyRef = useRef(textReplacement.key); + const textReplacementRef = useRef(textReplacement); + textReplacementRef.current = textReplacement; + const webTextareaRef = useRef(null); const updateLiveTextPresence = useCallback((text: string) => { const nextHasLiveText = text.trim().length > 0; if (hasLiveTextRef.current === nextHasLiveText) return; @@ -1235,6 +1289,82 @@ export const MessageInput = forwardRef( setHasLiveText(nextHasLiveText); }, []); + const assignTextInput = useCallback( + (input: ComposerTextInputHandle | null) => { + const previous = textInputRef.current; + if (!input && previous) { + editingSession.captureForRemount({ + snapshot: getComposerInputSnapshot( + previous, + valueRef.current, + selectionRef.current, + webTextareaRef.current, + ), + focused: previous.isFocused() || isInputFocusedRef.current, + replacementKey: appliedTextReplacementKeyRef.current, + }); + } + textInputRef.current = input; + if (isWeb) { + const element = getWebTextAreaImpl(input) as HTMLElement | null; + webTextareaRef.current = element; + setWebTextArea(element); + } + if (!input) return; + const transfer = editingSession.takeHandoff(textReplacementRef.current); + if (transfer) { + appliedTextReplacementKeyRef.current = textReplacementRef.current.key; + valueRef.current = transfer.snapshot.text; + updateLiveTextPresence(transfer.snapshot.text); + selectionRef.current = transfer.snapshot.selection; + input.replaceText(transfer.snapshot.text, transfer.snapshot.selection); + if (transfer.focused && activeComposerRef.current) input.focus(); + } + }, + [editingSession, updateLiveTextPresence], + ); + useLayoutEffect(() => { + if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus(); + }, [isActiveComposer]); + 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 changePresentation = useCallback( + (fullscreen: boolean) => { + if (editingSession.getFullscreen() === fullscreen) return; + const snapshot = getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ); + editingSession.changePresentation(fullscreen, { + snapshot, + focused: textInputRef.current?.isFocused() ?? isInputFocusedRef.current, + replacementKey: appliedTextReplacementKeyRef.current, + }); + valueRef.current = snapshot.text; + selectionRef.current = snapshot.selection; + }, + [editingSession], + ); + const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]); + + const handleComposerLayout = useCallback( + (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height), + [onHeightChange], + ); + const replaceText = useCallback( (nextText: string, selection?: { start: number; end: number }) => { updateComposerHeightForText?.(valueRef.current, nextText); @@ -1412,8 +1542,9 @@ export const MessageInput = forwardRef( canStartDictation, toast, startDictation, + exitFullscreen, }), - [canStartDictation, dictationUnavailableMessage, startDictation, toast], + [canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen], ); const handleVoicePress = useCallback( @@ -1485,10 +1616,12 @@ export const MessageInput = forwardRef( handleStopRealtimeVoice, toast, interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"), + exitFullscreen, }); }, [ disabled, handleStopRealtimeVoice, + exitFullscreen, isAgentRunning, isConnected, t, @@ -1572,10 +1705,10 @@ export const MessageInput = forwardRef( if (isWeb) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; } - }, [getWebTextArea]); + }, [getWebTextArea, isFullscreen]); usePasteImagesEffect({ - getWebTextArea, + textarea: webTextArea, isConnected, disabled, isDictating, @@ -1596,16 +1729,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 +1760,8 @@ export const MessageInput = forwardRef( disabled, handleAlternateSendAction, handleDefaultSendAction, - }); - } + ], + ); const primaryActionKind = resolvePrimaryActionKind({ hasSendableContent: hasSendableComposerContent({ @@ -1678,16 +1825,18 @@ export const MessageInput = forwardRef( ); const handleInputFocus = useCallback(() => { + focusIntentRef.current = true; isInputFocusedRef.current = true; setIsInputFocused(true); onFocusChange?.(true); }, [onFocusChange]); const handleInputBlur = useCallback(() => { + if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); - }, [onFocusChange]); + }, [isFullscreen, onFocusChange]); const handlePasteError = useCallback( (message: string) => { @@ -1735,8 +1884,12 @@ 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, + ], + [composerHeightStyle, mode.isMonospace, isFullscreen], ); // Static content has no textarea to mirror, so it grows with its own text // instead of the measured input height. @@ -1780,107 +1933,192 @@ export const MessageInput = forwardRef( [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], ); - return ( - - ( + - {/* Regular input */} - - {attachmentSlot} - {/* Text input */} - - ( + + + + ), + [ + readOnly, + textReplacement, + editingSession, + isActiveComposer, + 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, + ], + ); + const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]); + return ( + + + ); }, @@ -1947,6 +2241,19 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputWrapperReadOnly: { borderStyle: "dotted", }, + compactTextSurface: { paddingRight: 36 }, + fullscreenTextSurface: { flex: 1, minHeight: 0 }, + fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" }, + lineMeasurement: { + position: "absolute", + top: 0, + left: 0, + right: 36, + 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..d02821696a --- /dev/null +++ b/packages/app/src/composer/input/presentation.tsx @@ -0,0 +1,146 @@ +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 { isWeb } from "@/constants/platform"; +import { StyleSheet } from "react-native-unistyles"; +import { ComposerFullscreenHost } from "./fullscreen-host"; + +function keepEditorFocused(event: import("react-native").PointerEvent) { + if (isWeb) event.preventDefault(); +} + +/** Both presentations receive the same editing surface and send capability. */ +export function ComposerInputPresentation({ + fullscreen, + iconSize, + active, + surface, + toolbar, + send, + error, + onExit, + inputWrapperRef, + inputWrapperStyle, + inputPointerEvents, + attachmentSlot, + showFullscreenButton, + onEnter, + overlay, +}: { + fullscreen: boolean; + iconSize: number; + active: boolean; + 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(); + // ArrowUp ends at x19; the fullscreen glyphs end at x21 in Lucide's 24px viewBox. + // Compensate for that difference to align visible ink with Send, not just boxes. + const railAdjustment = iconSize / 12; + const maximizeIcon = useCallback( + (color: string) => , + [iconSize], + ); + const minimizeIcon = useCallback( + (color: string) => , + [iconSize], + ); + const enterStyle = useMemo( + () => [styles.fullscreenButton, { right: -6 + railAdjustment }], + [railAdjustment], + ); + const exitStyle = useMemo( + () => [styles.iconButton, { marginRight: railAdjustment }], + [railAdjustment], + ); + if (!fullscreen) + return ( + <> + + {attachmentSlot} + + {surface} + {showFullscreenButton ? ( + + ) : null} + + {toolbar} + + {overlay} + + ); + return ( + + + + + + {surface} + {error} + {send} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + inlineTextSurface: { flexShrink: 1, position: "relative" }, + iconButton: { + width: 28, + height: 28, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + fullscreenButton: { + position: "absolute", + top: 0, + width: 28, + height: 28, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + fullscreen: { + flex: 1, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[4], + gap: theme.spacing[3], + }, + exitRow: { alignItems: "flex-end", flexShrink: 0 }, + sendRow: { flexShrink: 0, 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..a7f4113b28 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -4,6 +4,7 @@ import { computeCanStartDictation, resolveActiveSendBehavior, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -316,3 +317,26 @@ 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("uses the live handoff when its replacement has already been applied", () => { + const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" }; + expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe( + "Live remount handoff", + ); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index a63aeb02dc..3786613eb2 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,11 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise< await ctx.voice.stopVoice(); } + +export function resolveComposerSurfaceText( + liveText: string, + replacement: TextReplacement, + appliedReplacementKey: string, +): string { + return replacement.key === appliedReplacementKey ? liveText : replacement.text; +} 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/types.ts b/packages/app/src/composer/types.ts index 2f03b7e89a..ee01144ed7 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -10,6 +10,7 @@ export interface MessagePayload { } export interface TextReplacement { + kind: "initial" | "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/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 = ( + + { }); 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 }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 4a843124f2..54d2b59cdb 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -1285,6 +1285,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..8b632befbd 100644 --- a/packages/app/src/keyboard/shortcut-string.ts +++ b/packages/app/src/keyboard/shortcut-string.ts @@ -266,7 +266,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/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2d8a605c3f..bb51832aa0 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1153,6 +1153,8 @@ 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"; const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const tasks = useSessionStore((state): TodoEntry[] | undefined => state.sessions[serverId]?.agentTasks.get(agentId), @@ -1216,7 +1218,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ composerState, ], ); - const composerSection = ( + const composerSection = showsComposer ? ( - ); + ) : null; const streamContent = ( diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 962e095b89..3e1634c113 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"; @@ -17,7 +17,7 @@ import { 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 +49,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,45 +59,36 @@ 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 handleOpenChanges = useCallback(() => { if (!workspaceKey) { 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/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 a33eaeffde..b340a053f7 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..5a247590d5 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -33,6 +33,10 @@ type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; class FakeDirectoryClient { + supportsBackgroundWorkspaces(): boolean { + return false; + } + supportsWorkspaceLabels = false; listWorkspaceLabelsCalls = 0; fetchAgentsCalls = 0; diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 0f10d7f893..6589914825 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -535,6 +535,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 +635,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..2ecbe60448 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", }); }, }) diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index bb21530a2b..76e9e66464 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -859,3 +859,36 @@ describe("ReplicaCache", () => { expect(storage.cleanups).toBe(1); }); }); + +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..3fc8458551 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -421,6 +421,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 +585,7 @@ function deserializeBuiltinTimelineItem( error: tool.error, detail: tool.detail, ...(tool.metadata ? { metadata: tool.metadata } : {}), + ...(tool.agentMessage ? { agentMessage: tool.agentMessage } : {}), }, }, }; 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..39c414d9e7 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -31,8 +31,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 +891,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 +971,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise ({ key: launchFocusKey, text: terminalComposerValue }), + () => ({ kind: "replace" as const, key: launchFocusKey, text: terminalComposerValue }), [launchFocusKey, terminalComposerValue], ); @@ -2368,7 +2367,6 @@ export function NewWorkspaceScreen({ }, }); - const screenHeaderLeft = useMemo(() => , []); const importSession = useImportSession({ serverId: selectedServerId }); const composer = isTerminalLaunch ? ( @@ -2431,7 +2429,7 @@ export function NewWorkspaceScreen({ ); return ( - + state.open); const [pendingProviderId, setPendingProviderId] = useState(null); const [removingProviderId, setRemovingProviderId] = useState(null); @@ -338,6 +338,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; + /** Custom providers are deletable only when config.json holds their `extends` definition. */ + const isConfigOwnedProvider = useCallback( + (providerId: string) => Boolean(config?.providers?.[providerId]?.extends), + [config], + ); + const handleOpenProviderSettings = useCallback( (providerId: string) => { openProviderSettings({ serverId, provider: providerId }); @@ -444,7 +450,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/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..2fb7176e10 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"; @@ -36,16 +30,13 @@ import type { Theme } from "@/styles/theme"; 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 = ; @@ -203,28 +194,25 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace normalizedServerId: string; 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. */ export function WorkspaceHeaderMenuMobile({ normalizedServerId, 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), @@ -239,7 +227,7 @@ export function WorkspaceHeaderMenuMobile({ - - {t("workspace.header.actions.newAgent")} - {showCreateBrowserTab ? ( {t("workspace.tabs.actions.terminalProfilesMenu")} - - {t("workspace.header.actions.newTerminal")} - {profiles.map((profile) => ( + {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..62c9ee4fa6 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,4 @@ +import { WorkspaceComposerEditingSessionsProvider } from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; @@ -14,23 +15,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, @@ -69,6 +73,7 @@ import { getFocusedBrowserId, FOCUSED_PANE_PLACEMENT, selectExplorerSidebarPaneId, + selectPaneFocusSuspended, type WorkspaceLayout, type WorkspaceTabPlacement, useWorkspaceLayoutStore, @@ -115,30 +120,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 +161,8 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { WorkspaceSheetHost } from "@/screens/workspace/workspace-sheet-host"; +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 +186,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"; @@ -246,12 +242,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 +294,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 +316,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 +443,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 +450,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 +466,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 +491,16 @@ function WorkspaceHeaderTitleBar({ )} - {isMobile ? ( - - ) : ( - - )} - {isMobile && workspaceScripts.length > 0 ? ( - - ) : null} + ); @@ -1293,7 +734,7 @@ function shouldInspectWorkspaceRecovery( function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { return ( <> - + {children} ); @@ -1313,17 +754,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 +820,10 @@ function WorkspaceDocumentTitleEffectSlot({ ); } +function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean { + return supportsStreamTopOverlay && activeTab?.kind === "agent"; +} + function shouldShowWorkspaceScreenHeader(input: { isFocusModeEnabled: boolean; isMobile: boolean; @@ -1819,6 +1268,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 +1309,9 @@ function WorkspaceScreenContent({ const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const sheetTab = useWorkspaceLayoutStore((state) => + persistenceKey ? (state.sheetByWorkspace[persistenceKey]?.tab ?? null) : null, + ); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState); @@ -1929,6 +1384,7 @@ function WorkspaceScreenContent({ const visibleAgentIds = useVisibleAgentIds({ layout: workspaceLayout, tabs: uiTabs, + sheetTab, routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, }); @@ -2230,7 +1686,6 @@ function WorkspaceScreenContent({ } const tabId = openPreferredWorkspaceTarget({ - isCompact: isMobile, workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), source: "chatFiles", @@ -2300,14 +1755,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 +1800,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 +2069,12 @@ function WorkspaceScreenContent({ const handleCloseTabById = useCallback( async (tabId: string) => { + if (persistenceKey && sheetTab?.tabId === tabId) { + if (await confirmDiscardModifiedTab(tabId)) { + closeWorkspaceTab(persistenceKey, tabId); + } + return; + } const tab = allTabDescriptorsById.get(tabId); if (!tab) { return; @@ -2667,10 +2094,13 @@ function WorkspaceScreenContent({ }, [ allTabDescriptorsById, + closeWorkspaceTab, confirmDiscardModifiedTab, handleCloseAgentTab, handleClosePassiveTab, handleCloseTerminalTab, + persistenceKey, + sheetTab, ], ); @@ -3504,17 +2934,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 +2965,6 @@ function WorkspaceScreenContent({ onOpenPreferredTarget: (target, source) => { if (!persistenceKey) return; const tabId = openPreferredWorkspacePreview({ - isCompact: isMobile, workspaceKey: persistenceKey, serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -3595,7 +3026,6 @@ function WorkspaceScreenContent({ canRenderDesktopPaneSplits, openImportSheet, openInSidePane, - isMobile, requestFileNavigation, revealWorkspaceChildTab, persistenceKey, @@ -3639,6 +3069,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 +3095,8 @@ function WorkspaceScreenContent({ focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, - paneFocusSuspended: Boolean(unfocusedPaneId), + // The sheet owns input while it covers the tab. + paneFocusSuspended, onFocusPane: handleFocusPane, buildMobilePaneContentModel, }); @@ -3759,7 +3201,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 +3323,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 +3338,110 @@ 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, - workspaceScripts, + ], + ); + const compactWorkspaceHeader = useMemo( + () => + isMobile ? ( + + + + + ) : null, + [ + activeTabKey, + canOpenImportSheet, + 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 +3523,46 @@ function WorkspaceScreenContent({ const desktopContent = desktopSplitContent ?? content; const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null; - const workspacePanelContent = ( - + const workspacePanelTabIds = useMemo( + () => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])], + [uiTabs, sheetTab], ); + const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`; - const workspaceCenterColumn = ( + // The sheet is the column's last child, so it covers the compact header as a modal. + const workspaceCenterColumn = isMobile ? ( + + + + {(topOverlayInset) => ( + + {content} + + )} + + + + + + + ) : ( {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} - {isMobile ? ( - - ) : null} - {shouldRenderDesktopPaneFallback ? ( ) : null} - {workspacePanelContent} + + + {desktopContent} + + ); @@ -4190,30 +3699,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 +3757,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 +3871,3 @@ const styles = StyleSheet.create((theme) => ({ textAlign: "center", }, })); - -const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; diff --git a/packages/app/src/screens/workspace/workspace-sheet-host.tsx b/packages/app/src/screens/workspace/workspace-sheet-host.tsx new file mode 100644 index 0000000000..d44bdb6cc9 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-sheet-host.tsx @@ -0,0 +1,245 @@ +import BottomSheet, { + BottomSheetBackdrop, + type BottomSheetBackdropProps, +} from "@gorhom/bottom-sheet"; +import { useIsFocused } from "@react-navigation/native"; +import { type ComponentType, useCallback, useEffect, useMemo, useRef } from "react"; +import { BackHandler, 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 { SheetHeaderView } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { isNative } from "@/constants/platform"; +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 WorkspaceSheet } from "@/stores/workspace-layout-store"; + +const SNAP_POINTS = ["92%"]; + +const ThemedBottomSheet = withUnistyles(BottomSheet, (theme) => ({ + backgroundStyle: { + backgroundColor: theme.colors.surface0, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + }, + handleIndicatorStyle: { + backgroundColor: theme.colors.palette.zinc[600], + }, +})); + +function PanelIcon({ + icon: Icon, + size, + color, +}: { icon: ComponentType } & PanelIconProps) { + return ; +} + +const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +function renderBackdrop(props: BottomSheetBackdropProps) { + return ( + + ); +} + +export interface WorkspaceSheetHostProps { + 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 WorkspaceSheetPresentationProps extends Omit< + WorkspaceSheetHostProps, + "workspaceKey" | "onCloseTab" +> { + sheet: WorkspaceSheet; + onClose: () => Promise; + onOpenAsTab: () => void; +} + +/** + * Hosts the workspace's sheet tab over the compact tab strip. It renders in the workspace tree, + * not through a portal, so the panel keeps every context an ordinary pane has. + */ +export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) { + const sheet = useWorkspaceLayoutStore((state) => + workspaceKey ? (state.sheetByWorkspace[workspaceKey] ?? null) : null, + ); + const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); + const sheetTabId = sheet?.tab.tabId ?? null; + const handleClose = useCallback(async () => { + if (sheetTabId) await onCloseTab(sheetTabId); + }, [onCloseTab, sheetTabId]); + const handleOpenAsTab = useCallback(() => { + if (workspaceKey) openSheetAsTab(workspaceKey); + }, [openSheetAsTab, workspaceKey]); + if (!sheet) return null; + return ( + + ); +} + +function WorkspaceSheetPresentation({ + sheet, + serverId, + workspaceId, + isWorkspaceFocused, + buildContent, + onClose, + onOpenAsTab, +}: WorkspaceSheetPresentationProps) { + const sheetRef = useRef(null); + const isScreenFocused = useIsFocused(); + const ownsBackPress = isNative && isWorkspaceFocused && isScreenFocused; + const handleChange = useCallback( + (index: number) => { + if (index !== -1) return; + // A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up. + void onClose().then(() => sheetRef.current?.snapToIndex(0)); + }, + [onClose], + ); + useEffect(() => { + // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back. + if (!ownsBackPress) return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + void onClose(); + return true; + }); + return () => subscription.remove(); + }, [onClose, ownsBackPress]); + + return ( + + + + + + ); +} + +function WorkspaceSheetBody({ + sheet, + serverId, + workspaceId, + isWorkspaceFocused, + buildContent, + onClose, + onOpenAsTab, +}: WorkspaceSheetPresentationProps) { + const { t } = useTranslation(); + const { tab } = sheet; + ensurePanelsRegistered(); + const registration = getPanelRegistration(tab.target.kind); + if (!registration) { + throw new Error(`No panel registration for kind: ${tab.target.kind}`); + } + // The body is keyed by tab id and kind, so the registration and its descriptor hook stay fixed. + const descriptor = registration.useDescriptor(tab.target as never, { + serverId, + workspaceId, + tabId: tab.tabId, + }); + 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]); + const handleClose = useCallback(() => { + void onClose(); + }, [onClose]); + // 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], + ); + const header = useMemo( + () => ({ + title: descriptor.label, + leading: , + actions: sheet.policy.openAsTab ? ( + + ) : undefined, + }), + [descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t], + ); + + return ( + + + + + + + ); +} + +const styles = StyleSheet.create({ + host: { + ...StyleSheet.absoluteFillObject, + }, + body: { + flex: 1, + minHeight: 0, + }, + content: { + flex: 1, + minHeight: 0, + }, +}); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 0d59b1c6b5..a741f99ddb 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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, 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..8c061d18b3 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,580 @@ 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: {}, sheetByWorkspace: {} }); + store.getState().setLayoutMode("compact"); + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + return store; + } + + 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(store.getState().sheetByWorkspace[workspaceKey]?.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(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId); + }); + + it("replaces the sheet content with the next sheet target", () => { + const store = createCompactStore(); + 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" }); + + expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(nextTarget); + expect(tabKinds(store)).toEqual(["terminal"]); + }); + + 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(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(fileTarget); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + expect(store.getState().sheetByWorkspace[workspaceKey]).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); + + expect(tabId).toBe(sheetTabId); + expect(store.getState().sheetByWorkspace[workspaceKey]).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(store.getState().sheetByWorkspace[workspaceKey]).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().sheetByWorkspace).toEqual({}); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + }); + + it("keeps the sheet content as a tab when the layout widens", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + store.getState().setLayoutMode("split-panes"); + + expect(store.getState().sheetByWorkspace).toEqual({}); + 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(store.getState().sheetByWorkspace[workspaceKey]).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(store.getState().sheetByWorkspace[workspaceKey]).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(store.getState().sheetByWorkspace[workspaceKey]).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(store.getState().sheetByWorkspace[workspaceKey]?.parentTabId).toBe(rootTabId); + + store.getState().openSheetAsTab(workspaceKey); + 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(store.getState().sheetByWorkspace[workspaceKey]).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(store.getState().sheetByWorkspace[workspaceKey]).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 store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId ?? null; + } + + it("opens the next sheet target as a tab under a modified sheet", () => { + const store = createCompactStore(); + const modifiedSheetTabId = 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(modifiedSheetTabId); + expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.state).toEqual({ scrollTop: 40 }); + expect(stripTab(store, nextTabId ?? "")).toMatchObject({ target: nextTarget }); + expect(focusedTabId(store)).toBe(nextTabId); + }); + + 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("replaces an unmodified sheet with the next sheet target", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + store.getState().openTab({ + workspaceKey, + target: { kind: "file", path: "src/other.ts" }, + intent: "reveal", + }); + + expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual({ + kind: "file", + path: "src/other.ts", + }); + expect(tabKinds(store)).toEqual(["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(store.getState().sheetByWorkspace[workspaceKey]).toBeDefined(); + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true }); + expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + }); + + 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(store.getState().sheetByWorkspace[workspaceKey]?.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(store.getState().sheetByWorkspace[workspaceKey]).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(store.getState().sheetByWorkspace[workspaceKey]).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(store.getState().sheetByWorkspace[workspaceKey]).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); + + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + store.getState().closeSheet(workspaceKey); + 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..226265860a 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,24 @@ export interface OpenWorkspaceTabInput { state?: JsonValue; } +/** + * How the app shell lays out a workspace: compact (one tab at a time, plus the sheet), a single + * wide pane (touch tablets, which cannot render splits), or split panes. + */ +export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes"; + +/** A tab hosted by the compact workspace sheet instead of a pane. */ +export interface WorkspaceSheet { + 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; + /** At most one sheet tab per workspace. Not persisted: a reload returns to the tab strip. */ + sheetByWorkspace: Record; layoutByWorkspace: Record; splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; @@ -116,7 +138,19 @@ 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 the workspace 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 the sheet. */ + opensInSheet: ( + input: Pick, + ) => boolean; + setLayoutMode: (mode: WorkspaceLayoutMode) => void; + closeSheet: (workspaceKey: string) => void; + /** Moves the sheet's tab, keeping its tab id, into the focused pane. */ + openSheetAsTab: (workspaceKey: string) => string | null; /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ autoOpenPullRequestTab: ( workspaceKey: string, @@ -472,6 +506,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.sheetByWorkspace + ); +} + /** Whether the Explorer sidebar pane is currently on screen. */ export function selectIsExplorerSidebarVisible( state: ExplorerSidebarState, @@ -637,6 +682,189 @@ function attachParentTab(input: { }); } +function withoutSheet( + state: Pick, + workspaceKey: string, +): Pick { + if (!(workspaceKey in state.sheetByWorkspace)) { + return { sheetByWorkspace: state.sheetByWorkspace }; + } + const { [workspaceKey]: _removed, ...sheetByWorkspace } = state.sheetByWorkspace; + return { sheetByWorkspace }; +} + +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 target opened from inside the sheet names the sheet's + * tab, which the open replaces or closes, so it inherits the sheet's own parent instead. + */ +function resolveOpenParentTabId( + state: Pick, + workspaceKey: string, + parentTabId: string | undefined, +): string | null { + const sheet = state.sheetByWorkspace[workspaceKey]; + if (sheet && parentTabId === sheet.tab.tabId) return sheet.parentTabId; + return parentTabId ?? null; +} + +/** A reveal of the resource the sheet already shows keeps the sheet's tab. */ +function sheetKeepsTabFor( + sheet: WorkspaceSheet | undefined, + target: WorkspaceTabTarget, + intent: WorkspaceTabOpenIntent, +): sheet is WorkspaceSheet { + return ( + intent === "reveal" && + sheet !== undefined && + panelResourceKey(sheet.tab.target) === panelResourceKey(target) + ); +} + +function placeTargetInSheet(input: { + /** The sheet whose tab the target keeps, if any. */ + kept: WorkspaceSheet | undefined; + target: WorkspaceTabTarget; + intent: WorkspaceTabOpenIntent; + policy: CompactSheetPolicy; + parentTabId: string | null; + state: JsonValue | undefined; +}): WorkspaceSheet { + 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 a workspace's sheet tab into its focused pane, keeping its tab id and state. */ +function promoteSheetToTab( + state: WorkspaceLayoutStore, + workspaceKey: string, +): { tabId: string; patch: Partial } | null { + const sheet = state.sheetByWorkspace[workspaceKey]; + 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), + ...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( + state: Pick, + workspaceKey: string, +): boolean { + const sheet = state.sheetByWorkspace[workspaceKey]; + return sheet !== undefined && isSheetTabModified(workspaceKey, sheet.tab.tabId); +} + +/** + * Where an open lands in the sheet, or null when it opens as a tab. A reveal of the resource the + * sheet shows keeps its tab; any other target never displaces unsaved work and opens as a tab. + */ +function resolveSheetPlacement( + state: WorkspaceLayoutStore, + workspaceKey: string, + target: WorkspaceTabTarget, + intent: WorkspaceTabOpenIntent, +): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null { + const policy = resolveSheetPolicy(state, workspaceKey, target, intent); + if (!policy) return null; + const sheet = state.sheetByWorkspace[workspaceKey]; + if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet }; + return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined }; +} + +/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */ +function dismissSheet( + state: WorkspaceLayoutStore, + workspaceKey: string, +): Partial { + return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, 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: WorkspaceSheet | undefined, + 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 +904,8 @@ export function createWorkspaceLayoutStore( } return tabId; }, + layoutMode: "split-panes", + sheetByWorkspace: {}, layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, @@ -691,6 +921,31 @@ 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 sheet = placeTargetInSheet({ + kept: sheetPlacement.kept, + target: normalizedTarget, + intent: input.intent, + policy: sheetPlacement.policy, + parentTabId, + state: input.state, + }); + set((state) => ({ + sheetByWorkspace: { ...state.sheetByWorkspace, [normalizedWorkspaceKey]: sheet }, + })); + return sheet.tab.tabId; + } const placement = getOpenTabPlacement( get(), normalizedWorkspaceKey, @@ -734,6 +989,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" ? {} : dismissSheet(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace @@ -751,17 +1008,52 @@ 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 the sheet as an ordinary tab. + const next: WorkspaceLayoutStore = { ...state, layoutMode: mode }; + for (const workspaceKey of Object.keys(state.sheetByWorkspace)) { + const promoted = promoteSheetToTab(next, workspaceKey); + Object.assign(next, promoted?.patch ?? withoutSheet(next, workspaceKey)); + } + return next; + }); + }, + closeSheet: (workspaceKey) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) return; + set((state) => withoutSheet(state, normalizedWorkspaceKey)); + }, + openSheetAsTab: (workspaceKey) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) return null; + const promoted = promoteSheetToTab(get(), normalizedWorkspaceKey); + if (!promoted) return null; + set(promoted.patch); + return promoted.tabId; + }, showExplorerSidebar: (workspaceKey) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -881,6 +1173,9 @@ export function createWorkspaceLayoutStore( } set((state) => { + if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) { + return withoutSheet(state, normalizedWorkspaceKey); + } const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); const explorerSidebarPaneId = resolveExplorerSidebarPaneId( layout, @@ -988,6 +1283,8 @@ export function createWorkspaceLayoutStore( return { ...withoutFocusRestoration(state, normalizedWorkspaceKey), + // Choosing a tab dismisses the sheet that covers it. + ...dismissSheet(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1011,6 +1308,8 @@ export function createWorkspaceLayoutStore( }); return nextLayout ? { + // Choosing a tab dismisses the sheet that covers it. + ...dismissSheet(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1024,6 +1323,31 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null; + const sheet = get().sheetByWorkspace[normalizedWorkspaceKey]; + if (sheet?.tab.tabId === normalizedTabId) { + const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget); + const replaced: WorkspaceSheet = { + ...sheet, + policy: policy ?? sheet.policy, + tab: { + ...sheet.tab, + target: normalizedTarget, + ...(tabState !== undefined ? { state: tabState } : {}), + }, + }; + const next: WorkspaceLayoutStore = { + ...get(), + sheetByWorkspace: { ...get().sheetByWorkspace, [normalizedWorkspaceKey]: replaced }, + }; + if (policy) { + set({ sheetByWorkspace: next.sheetByWorkspace }); + return normalizedTabId; + } + // A target the sheet does not host becomes the tab it would have opened as. + const promoted = promoteSheetToTab(next, normalizedWorkspaceKey); + set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey)); + return promoted?.tabId ?? null; + } const result = replaceTabTargetInLayout({ layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), tabId: normalizedTabId, @@ -1054,6 +1378,19 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); if (!normalizedWorkspaceKey || !normalizedTabId) return; set((state) => { + const sheet = state.sheetByWorkspace[normalizedWorkspaceKey]; + if (sheet?.tab.tabId === normalizedTabId) { + const { state: _previous, ...tab } = sheet.tab; + return { + sheetByWorkspace: { + ...state.sheetByWorkspace, + [normalizedWorkspaceKey]: { + ...sheet, + tab: tabState === undefined ? tab : { ...tab, state: tabState }, + }, + }, + }; + } const layout = setTabStateInLayout({ layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), tabId: normalizedTabId, @@ -1148,15 +1485,22 @@ export function createWorkspaceLayoutStore( ); } } + const sheetAgentGone = sheetAgentNoLongerFits( + normalizedWorkspaceKey, + state.sheetByWorkspace[normalizedWorkspaceKey], + snapshot, + ); if ( state.layoutByWorkspace[normalizedWorkspaceKey] && nextLayout === rawLayout && - pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace + pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace && + !sheetAgentGone ) { return state; } return { + ...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}), pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, @@ -1661,7 +2005,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.sheetByWorkspace; if (!hasAny) { return state; } @@ -1690,6 +2035,7 @@ export function createWorkspaceLayoutStore( const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } = state.sidePaneIdByWorkspace; return { + ...withoutSheet(state, normalizedWorkspaceKey), pullRequestTabAutoOpenedByWorkspace, layoutByWorkspace, splitSizesByWorkspace, @@ -1795,10 +2141,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 +2152,19 @@ 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.sheetByWorkspace).map( + ([workspaceKey, sheet]) => [workspaceKey, [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 +2175,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.sheetByWorkspace !== before.sheetByWorkspace + ) { + 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..5b452bcb93 --- /dev/null +++ b/packages/app/src/styles/density.ts @@ -0,0 +1,57 @@ +import { isCompactBreakpoint } from "@/constants/layout"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +/** + * Density sizes for surfaces that opt into touch sizing: rows, icons, and text 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; +type TextSize = "sm" | "base"; + +const COMPACT_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; +const COMPACT_TEXT_SIZE: Record = { sm: "base", base: "lg" }; + +// 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(40, 28), + /** The line height of a row's `base` text; slots that align to its first line share it. */ + rowLineHeight: byLayout(22, 20), + /** 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 densityFontSize(theme: Theme, size: TextSize) { + return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[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/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/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 020faf61ec..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,17 +71,22 @@ const styles = StyleSheet.create((theme) => ({ })); /** - * One host's usage and the title-row controls that go with it, for the Usage modal. + * 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/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/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/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..ac181eeb75 --- /dev/null +++ b/packages/app/src/workspace-tabs/open-beside.test.ts @@ -0,0 +1,177 @@ +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", + sheetByWorkspace: {}, + 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.sheetByWorkspace[WORKSPACE_KEY]?.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("reuses one preview tab instead of adding a tab per link", () => { + 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 }, + ); + + const firstPreviewTabId = previewFile("src/first.ts"); + const secondPreviewTabId = previewFile("src/second.ts"); + + const state = useWorkspaceLayoutStore.getState(); + expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab.target).toEqual(sheetTarget); + expect(secondPreviewTabId).toBe(firstPreviewTabId); + const root = state.layoutByWorkspace[WORKSPACE_KEY].root; + const tabsById = new Map(collectAllTabs(root).map((tab) => [tab.tabId, tab.target])); + expect(findPaneById(root, "main")?.tabIds.map((tabId) => tabsById.get(tabId))).toEqual([ + { kind: "terminal", terminalId: "term-1" }, + { kind: "file", path: "src/second.ts" }, + ]); + }); +}); + +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/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..8aedf73fad 100644 --- a/packages/cli/src/commands/agent/ls.test.ts +++ b/packages/cli/src/commands/agent/ls.test.ts @@ -23,15 +23,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 +46,7 @@ describe("buildAgentLsFetchOptions", () => { expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({ filter: { includeArchived: true, + includeBackground: true, }, }); }); diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index bb2189c7c4..0990446bc5 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) { @@ -163,7 +169,7 @@ export function buildAgentLsFetchOptions( * 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( 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/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/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/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.test.ts b/packages/client/src/daemon-client.test.ts index d7ab645241..95921a96a0 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3100,6 +3100,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 +7481,49 @@ 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; + }, +); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 858c61e5b3..f900fe41e5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -424,6 +424,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 +460,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 +471,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< @@ -1195,6 +1200,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), @@ -2341,6 +2365,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 +2378,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 +2395,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 +2404,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 +2423,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 +2487,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 +2498,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 +2872,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 +2917,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 +3480,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 +3493,7 @@ export class DaemonClient { requestId, agentId, text, + sourceAgentId: options?.sourceAgentId, ...(messageId ? { messageId } : {}), ...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}), ...(options?.images ? { images: options.images } : {}), @@ -4552,6 +4592,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 +4626,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 } : {}), @@ -6932,6 +6977,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/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index b96129e317..fb0958aad1 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -287,7 +287,6 @@ async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) { provider: "mock/ten-second-stream", settings: { modeId: "load-test" }, initialPrompt: "Remain available while the browser bridge regression runs.", - background: true, }, }); const result = response.structuredContent; 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/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/main.ts b/packages/desktop/src/main.ts index 63ab83281a..9acf329216 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -43,6 +43,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 +774,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 c245d42da1..a943e6dd4a 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -144,6 +144,34 @@ 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>"; diff --git a/packages/highlight/src/astro/parser.ts b/packages/highlight/src/astro/parser.ts index 202c921252..cef0748b5a 100644 --- a/packages/highlight/src/astro/parser.ts +++ b/packages/highlight/src/astro/parser.ts @@ -45,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/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/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.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.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 6f5e4abd3f..fefc104279 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. @@ -3695,6 +3725,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 +4050,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 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/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/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/package.json b/packages/server/package.json index c11efb3571..b80941d56f 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/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/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/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\"", "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/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 0bcd46636a..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. @@ -338,12 +345,15 @@ export async function sendPromptToAgent( 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, @@ -362,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") { @@ -389,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; @@ -442,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; @@ -499,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, @@ -519,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) { @@ -526,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; } @@ -556,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 @@ -590,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/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..f1311d31e2 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", @@ -3227,6 +3300,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", @@ -3279,6 +3353,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 +3668,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 +3721,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", @@ -3704,6 +3781,7 @@ 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, @@ -3723,12 +3801,37 @@ 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(); 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 +3840,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 () => { @@ -3838,6 +3955,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 +3993,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 +4005,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 +4033,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 +4078,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 +4277,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 +4348,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 +4359,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 +4405,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 +4482,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/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 6bded49feb..169421fca9 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1750,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; @@ -1867,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(); @@ -2841,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) { 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..1f0c5bd36c --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts @@ -0,0 +1,112 @@ +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: string, reportedLimit: number): ClaudeQueryFactory { + return ({ prompt }) => { + let model = runtimeModel; + const messages = (async function* () { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + for await (const _message of prompt) { + 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: { [model]: { contextWindow: reportedLimit } }, + 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(runtime, 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(); + } + }, +); 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.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 747a466c32..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 @@ -486,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); @@ -498,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(); @@ -1191,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", @@ -1233,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-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-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 ddac56a0eb..9936969e7d 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, @@ -76,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, @@ -84,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, @@ -412,6 +413,7 @@ export interface ClaudeContentChunk { } interface ClaudeAgentClientOptions { + discoverModels?: typeof discoverClaudeModels; defaults?: { agents?: Record<string, AgentDefinition> }; logger: Logger; runtimeSettings?: ProviderRuntimeSettings; @@ -452,6 +454,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, @@ -1505,6 +1516,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; @@ -1517,6 +1529,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; @@ -1599,8 +1612,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 { @@ -1815,30 +1855,59 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined { return maxContextWindow; } -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 { @@ -1907,13 +1976,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; @@ -1923,7 +1997,7 @@ class ClaudeContextUsageState { } beginTurn(): void { - this.streamRequestInputTokens = undefined; + this.streamRequestInputUsage = undefined; this.streamRequestOutputTokens = undefined; this.compactedContextWindowUsedTokens = undefined; } @@ -1947,11 +2021,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); @@ -1959,6 +2033,12 @@ class ClaudeContextUsageState { return null; } this.streamRequestOutputTokens = outputTokens; + if (this.streamRequestInputUsage) { + this.streamRequestInputUsage = mergeStreamDeltaInputUsage( + this.streamRequestInputUsage, + streamEvent, + ); + } } else { return null; } @@ -2005,13 +2085,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; } @@ -2030,7 +2112,7 @@ class ClaudeContextUsageState { } buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent { - this.streamRequestInputTokens = undefined; + this.streamRequestInputUsage = undefined; this.streamRequestOutputTokens = undefined; this.compactedContextWindowUsedTokens = postTokens; const usage: AgentUsage = {}; @@ -2070,11 +2152,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; @@ -2126,6 +2209,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; @@ -2151,7 +2240,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; @@ -2272,6 +2361,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 = () => { @@ -2282,13 +2372,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"); }); }; @@ -2298,6 +2393,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"); } @@ -2311,6 +2411,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"), ); @@ -2359,7 +2460,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); } @@ -2370,7 +2471,7 @@ class ClaudeAgentSession implements AgentSession { } } catch (error) { if (uuid) { - this.queuedSteerUuids.delete(uuid); + this.unstartedMessageUuids.delete(uuid); this.permissionClearingSteerUuids.delete(uuid); } throw error; @@ -2471,7 +2572,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; @@ -3182,6 +3283,8 @@ class ClaudeAgentSession implements AgentSession { 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 }, { @@ -3301,7 +3404,12 @@ 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" }, + ], }); } @@ -3368,6 +3476,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, @@ -3898,35 +4010,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") { @@ -4068,7 +4212,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( @@ -4082,8 +4232,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(), @@ -4095,14 +4253,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 = ( @@ -4110,14 +4269,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( @@ -4166,7 +4328,7 @@ class ClaudeAgentSession implements AgentSession { } } - this.forgetReadSteer(message); + this.forgetStartedMessage(message); switch (message.type) { case "system": @@ -4200,11 +4362,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); } @@ -4656,6 +4821,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 }, @@ -5124,26 +5295,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[] { @@ -6519,7 +6675,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. */ @@ -6528,7 +6685,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/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/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 index ec17dd7ecf..fdddb4941c 100644 --- 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 @@ -1,4 +1,4 @@ -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"; @@ -68,8 +68,60 @@ describe("GenericACPAgentClient archived history", () => { ).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) { @@ -88,17 +140,20 @@ async function withFakeACPAgent( 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], + command: [process.execPath, scriptPath, loadRequestsPath, pidPath], removedCwd: path.join(testDir, "removed-worktree"), loadRequestsPath, + pidPath, }); } finally { await rm(testDir, { recursive: true, force: true }); @@ -110,6 +165,10 @@ 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 }); 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/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/timeline.test.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts index 1bbd86490a..9189f5d3c5 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 @@ -54,3 +54,44 @@ 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([]); +}); 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..c58316df9e 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"; @@ -158,8 +162,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 +204,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/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 0ec792e871..727c7b7899 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -832,6 +832,9 @@ 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; @@ -843,6 +846,7 @@ export async function createPaseoDaemon( 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) { @@ -1031,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({ @@ -1081,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; }; @@ -1399,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; @@ -1874,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/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-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..f0cd4495e1 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -0,0 +1,87 @@ +import { execFileSync } from "node:child_process"; +import { 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, +); 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..00b365881f --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts @@ -0,0 +1,130 @@ +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", + }, + }, + ] + .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, + }); + 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/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 ac138a8c01..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,6 +69,39 @@ describe("daemon E2E", () => { }, 60000); describe("file download tokens", () => { + 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); + + 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 }); + } + }, + ); + test.each([ { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, { 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..7fee55e216 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,16 @@ -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 { 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 { @@ -23,6 +32,448 @@ afterEach(async () => { }, 60000); 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 +576,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/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/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/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-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/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/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.ts b/packages/server/src/server/persisted-config.ts index 79e3d97218..4998455713 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -237,7 +237,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 +289,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(), 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/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/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..50c0ee3c76 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, }); } 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..5ca1177799 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -190,6 +190,34 @@ 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("parses a simple diff with one hunk", () => { const files = parseDiff(SIMPLE_DIFF); @@ -597,6 +625,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..97d68d1fcd 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -79,16 +79,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 +106,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.ts b/packages/server/src/server/websocket-server.ts index 4dcdc680ab..219e04b0c2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -246,6 +246,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); @@ -1793,6 +1794,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. @@ -1923,6 +1925,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..b377c22374 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 @@ -64,6 +64,48 @@ test("workspace.create worktree source forwards action=checkout + refName into t } }, 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(); + rmSync(tempRoot, { recursive: true, force: true }); + }); + 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(); 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/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-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..cdb0119b64 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" }>, 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/gitlab-service.test.ts b/packages/server/src/services/gitlab-service.test.ts index c74224e444..371198e447 100644 --- a/packages/server/src/services/gitlab-service.test.ts +++ b/packages/server/src/services/gitlab-service.test.ts @@ -1328,6 +1328,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..1fff0e2a02 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); } @@ -1359,9 +1393,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..f83b6b9ba3 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -24,6 +24,8 @@ import { } from "node:fs"; import { spawnSync } from "node:child_process"; import * as pty from "node-pty"; +import xterm from "@xterm/headless"; +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; @@ -50,6 +52,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 +529,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({ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b9d1b74017..1e708062b8 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -580,7 +580,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 +641,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, 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..d30ae1d7ce --- /dev/null +++ b/packages/server/src/utils/checkout-git.patch-format.test.ts @@ -0,0 +1,213 @@ +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, getCommitFileDiff } from "./checkout-git.js"; + +const repos: string[] = []; + +afterEach(() => { + vi.unstubAllEnvs(); + for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true }); +}); + +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..21791fa49e 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/ --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..caa7e65cb0 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -636,8 +636,16 @@ function normalizeNumstatPath(pathField: string): string { return 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 @@ -713,12 +721,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 +742,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 +1187,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 +1591,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)"], { @@ -2630,10 +2665,11 @@ export async function getCommitFileDiff({ sha: string; path: string; }): Promise<ParsedDiffFile | null> { + const worktreeRoot = await requireGitWorktreeRoot(cwd); const { stdout } = await runGitCommand( - ["show", sha, "--format=", "--diff-merges=first-parent", "--", path], + ["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path], { - cwd, + cwd: worktreeRoot, envOverlay: READ_ONLY_GIT_ENV, }, ); @@ -2642,9 +2678,9 @@ 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 @@ -3302,26 +3338,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, }), ), 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-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.ts b/packages/server/src/utils/worktree.ts index f7f28205e0..f454a7225a 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, diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 1efc20115c..6eb9f7d816 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -15,6 +15,51 @@ function pluginCards(page: Page, section: string): Locator { .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, @@ -41,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: "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$/); @@ -61,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( @@ -81,6 +133,21 @@ 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 }) => { @@ -113,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" }); @@ -213,7 +309,7 @@ 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: "Behavior", exact: true }), ).toBeVisible(); @@ -304,6 +400,20 @@ 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); } @@ -345,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); + ).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(); @@ -500,7 +609,9 @@ test.describe("registry fixture layout", () => { 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/ }); - await expect(card.locator("img, video")).toHaveCount(0); + // 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"), @@ -534,6 +645,18 @@ test.describe("registry fixture layout", () => { /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[]) { @@ -646,6 +769,7 @@ async function expectPluginOverviewSafe(page: Page) { }); 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( diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json index 2f6560780c..4ad4eabf1b 100644 --- a/packages/website/e2e/registry.fixture.json +++ b/packages/website/e2e/registry.fixture.json @@ -84,9 +84,9 @@ "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", + "reviewedAt": "2026-09-24T00:00:00.000Z", "publishedAt": "2026-09-24T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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." }, @@ -117,9 +117,9 @@ "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", + "reviewedAt": "2026-09-22T00:00:00.000Z", "publishedAt": "2026-09-22T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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" }, @@ -150,9 +150,9 @@ "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", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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" }, @@ -183,9 +183,9 @@ "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", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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" }, @@ -215,9 +215,9 @@ "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", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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" }, @@ -247,9 +247,9 @@ "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", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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" }, @@ -277,9 +277,9 @@ }, "media": [], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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" }, @@ -309,9 +309,9 @@ "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", + "reviewedAt": "2026-09-22T00:00:00.000Z", "publishedAt": "2026-09-22T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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." }, @@ -342,9 +342,9 @@ "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", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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." }, @@ -375,9 +375,9 @@ "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", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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." }, @@ -409,9 +409,9 @@ "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", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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." }, @@ -442,9 +442,9 @@ "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", + "reviewedAt": "2026-09-18T00:00:00.000Z", "publishedAt": "2026-09-18T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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" }, @@ -475,9 +475,9 @@ "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", + "reviewedAt": "2026-10-03T09:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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." }, @@ -505,9 +505,9 @@ }, "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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." }, @@ -538,9 +538,9 @@ "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", + "reviewedAt": "2026-09-18T00:00:00.000Z", "publishedAt": "2026-09-18T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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." }, @@ -568,9 +568,9 @@ }, "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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." }, @@ -599,16 +599,16 @@ "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", + "reviewedAt": "2026-10-03T15:00:00.000Z", "publishedAt": "2026-09-22T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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", @@ -628,9 +628,9 @@ }, "media": [], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", "publishedAt": "2026-09-22T00:00:00.000Z", - "updatedAt": "2026-10-03T00: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/package.json b/packages/website/package.json index bf40c0e1a6..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": { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index c1cfbb4b6c..415d307ca0 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", + "enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] + }, + "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/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index 1ab11f9bdb..198a60c9ce 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -19,7 +19,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { <footer className={`${widthClasses} mx-auto mt-12 md:mt-16`}> <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 +80,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 +121,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 +135,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 +164,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/sponsorship.tsx b/packages/website/src/components/sponsorship.tsx index e3420aa36f..05a04362de 100644 --- a/packages/website/src/components/sponsorship.tsx +++ b/packages/website/src/components/sponsorship.tsx @@ -55,8 +55,13 @@ export function FounderNote() { <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> - It is built by one person, full time, with no investors, no board and no company behind it. - 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, @@ -234,10 +239,10 @@ export function SponsorSection() { <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> - It is built by one person, full time, with 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. + 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{" "} diff --git a/packages/website/src/content/alternatives/claude-desktop.md b/packages/website/src/content/alternatives/claude-desktop.md index 53f3118cc6..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) diff --git a/packages/website/src/content/alternatives/codex-app.md b/packages/website/src/content/alternatives/codex-app.md index d184dac1cc..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) diff --git a/packages/website/src/content/alternatives/conductor.md b/packages/website/src/content/alternatives/conductor.md index a3455bb0aa..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) diff --git a/packages/website/src/content/alternatives/happy-coder.md b/packages/website/src/content/alternatives/happy-coder.md index f0641666c6..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) diff --git a/packages/website/src/content/alternatives/opencode-desktop.md b/packages/website/src/content/alternatives/opencode-desktop.md index 5279fb55a2..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) diff --git a/packages/website/src/content/alternatives/orca.md b/packages/website/src/content/alternatives/orca.md index 4286f3794e..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) @@ -23,7 +23,7 @@ Orca is made by Stably AI, a venture-funded company (Y Combinator). Paseo is ind ## 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. 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/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 274c68a5c0..38b6393662 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -16,7 +16,9 @@ import { mostInstalled, newestFirst, type Plugin, + recentlyUpdated, searchPlugins, + updatedAgo, } from "./registry"; import { WindowSwitch } from "./window-switch"; @@ -34,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, @@ -47,8 +49,7 @@ export function BrowsePage({ query: BrowseQuery; }) { const category = query.category ? getCategory(query.category) : null; - const ordered = - query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, 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; @@ -89,21 +90,19 @@ export function BrowsePage({ </div> )} {results.length === 0 && !query.q && ( - <p className="text-sm text-muted-foreground">No plugins in this category yet.</p> + <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) => - query.sort === "new" ? ( - <PluginCard key={plugin.id} plugin={plugin}> - {addedAgo(plugin, now)} - </PluginCard> - ) : ( - <PluginCard key={plugin.id} plugin={plugin}> - <InstallCount count={installs[plugin.id]?.[query.window] ?? 0} /> - </PluginCard> - ), - )} + {results.map((plugin) => ( + <PluginCard key={plugin.id} plugin={plugin}> + <CardDetail plugin={plugin} installs={installs} now={now} query={query} /> + </PluginCard> + ))} </div> )} </PluginSection> @@ -113,6 +112,33 @@ export function BrowsePage({ ); } +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; @@ -208,6 +234,13 @@ 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> diff --git a/packages/website/src/plugins/install-count.tsx b/packages/website/src/plugins/install-count.tsx index 4867415596..198905ddea 100644 --- a/packages/website/src/plugins/install-count.tsx +++ b/packages/website/src/plugins/install-count.tsx @@ -4,7 +4,7 @@ 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="inline-flex items-center gap-1 tabular-nums"> + <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> 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/overview.test.tsx b/packages/website/src/plugins/overview.test.tsx index c9e6591c94..45bccecb37 100644 --- a/packages/website/src/plugins/overview.test.tsx +++ b/packages/website/src/plugins/overview.test.tsx @@ -1,7 +1,7 @@ import registry from "../../e2e/registry.fixture.json"; import { PluginTile } from "./plugin-tile"; import { PluginCard } from "./plugin-card"; -import type { Plugin } from "./registry"; +import { authorAvatarUrl, getAuthor, type Plugin } from "./registry"; import React, { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { parseFragment, type DefaultTreeAdapterMap } from "parse5"; @@ -25,6 +25,15 @@ function render(markdown: string) { 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", @@ -118,7 +127,7 @@ describe("registry images", () => { createElement(PluginCard, { plugin }), ]) { expect( - elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"), + imageSources(renderToStaticMarkup(component)).filter((src) => src !== avatarUrl(plugin)), ).toEqual([]); } }); @@ -131,8 +140,8 @@ it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () icon: undefined, media: ["data:image/png;base64,AAAA", source], } as Plugin; - const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin }))); - expect( - nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")), - ).toEqual([source]); + expect(imageSources(renderToStaticMarkup(createElement(PluginCard, { plugin })))).toEqual([ + source, + avatarUrl(plugin), + ]); }); diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index b68ae654db..c46b9f553a 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -35,19 +35,20 @@ function PluginShot({ plugin }: { plugin: Plugin }) { } /** - * What's new and Featured 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. + * 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 NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) { +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"> <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 text-xs leading-5 text-muted-foreground"> + <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"> @@ -64,23 +65,6 @@ export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ); } -/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */ -export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) { - return ( - <a href={pluginHref(plugin.id)} className="group block"> - <PluginShot plugin={plugin} /> - <div className="mt-3 flex items-center gap-2"> - <PluginTile plugin={plugin} size="xs" /> - <p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{plugin.name}</p> - <span className="flex-shrink-0 text-xs text-extra-muted-foreground">{children}</span> - </div> - <p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground"> - {plugin.description} - </p> - </a> - ); -} - /** Ranked row for the directory's Most installed list. */ export function PluginRankRow({ plugin, diff --git a/packages/website/src/plugins/plugin-search.tsx b/packages/website/src/plugins/plugin-search.tsx index fdd3a4b5f5..400341d5a4 100644 --- a/packages/website/src/plugins/plugin-search.tsx +++ b/packages/website/src/plugins/plugin-search.tsx @@ -46,6 +46,12 @@ export function PluginSearch({ }, [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>) => edit(event.target.value), [edit], @@ -70,7 +76,7 @@ export function PluginSearch({ 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/registry.test.ts b/packages/website/src/plugins/registry.test.ts index b6135351b3..d8f343b266 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -10,7 +10,10 @@ import { newestFirst, type Plugin, readmeBody, + recentlyUpdated, + reportBugUrl, searchPlugins, + updatedAgo, } from "./registry"; import { readInstallCounts, recordInstall } from "./installs"; import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published"; @@ -76,22 +79,22 @@ describe("plugin registry", () => { ]); expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]); }); - it("lists what's new by first-listing date, not submission or update dates", () => { + 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-20", - reviewedAt: "2026-09-21", - updatedAt: "2026-10-02", - publishedAt: "2026-09-02", + 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-01", - reviewedAt: "2026-09-01", - updatedAt: "2026-09-01", - publishedAt: "2026-09-30", + 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", @@ -99,9 +102,33 @@ describe("plugin registry", () => { ]); const now = "2026-10-03T12:00:00.000Z"; expect(addedAgo(listedLater, now)).toBe("3d ago"); - expect(addedAgo(listedFirst, now)).toBe("4w ago"); + expect(updatedAgo(listedFirst, now)).toBe("yesterday"); }); - it("orders by listing date and by installs in a window, keeping index order for ties", () => { + 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([ @@ -263,9 +290,23 @@ 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. */ diff --git a/packages/website/src/plugins/registry.ts b/packages/website/src/plugins/registry.ts index 13bfc0d1ca..7d99cb6789 100644 --- a/packages/website/src/plugins/registry.ts +++ b/packages/website/src/plugins/registry.ts @@ -1,4 +1,9 @@ import type { z } from "zod"; +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"; @@ -41,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" @@ -53,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; } @@ -106,9 +119,18 @@ 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; equal dates keep index order. */ +/** + * 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.publishedAt.localeCompare(a.publishedAt)); + 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( @@ -121,7 +143,14 @@ export function mostInstalled( } /** 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.publishedAt)) / 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/thumbnails.test.ts b/packages/website/src/plugins/thumbnails.test.ts index fb9a571b93..aaaaf47418 100644 --- a/packages/website/src/plugins/thumbnails.test.ts +++ b/packages/website/src/plugins/thumbnails.test.ts @@ -2,7 +2,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails"; -import { NewPluginCard, PluginCard } from "./plugin-card"; +import { PluginCard } from "./plugin-card"; import type { Plugin } from "./registry"; const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png"; @@ -29,6 +29,8 @@ const plugin: Plugin = { 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( @@ -42,7 +44,7 @@ describe("plugin card thumbnails", () => { const html = renderToStaticMarkup( createElement(PluginCard, { plugin: { ...plugin, media: [video] } }), ); - expect(html).not.toContain("<img"); + expect(html.match(/<img [^>]*>/g)).toEqual([expect.stringContaining(avatar)]); expect(html).not.toContain("<video"); expect(html).toContain(">E</div>"); }); @@ -56,11 +58,14 @@ describe("plugin card thumbnails", () => { expect(html).not.toContain("/plugins/thumb/"); }); - it.each([ - ["PluginCard", () => createElement(PluginCard, { plugin }, "today")], - ["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")], - ])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => { - const html = renderToStaticMarkup(card()); + 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"`, ); diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 39e7f64a7b..5f0bc664ae 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -17,16 +17,19 @@ function Home() { <LandingPage title={ <> - The control plane + The agentic development <br /> - for coding agents + environment </> } subtitle={ <> - Run many coding agents at once, on your machines. + Run many coding agents at once, + <br className="sm:hidden" /> + <span className="hidden sm:inline"> </span> + on any machine. <br /> - Desktop and mobile. Open source. + From your desk and from your phone. </> } /> diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index 2000067e00..b3d02177aa 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -1,5 +1,6 @@ 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 { PluginOverview, PluginContentLink } from "~/plugins/overview"; import { SiteShell } from "~/components/site-shell"; @@ -14,6 +15,7 @@ import { npmUrl, pluginVersion, readmeBody, + reportBugUrl, } from "~/plugins"; import { AuthorAvatar, AuthorLink } from "~/plugins/author-link"; import { InstallCount } from "~/plugins/install-count"; @@ -48,6 +50,8 @@ 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"; @@ -56,6 +60,7 @@ function PluginPage() { const category = getCategory(plugin.categories[0]); const author = getAuthor(plugin); const npm = npmUrl(plugin); + const reportBug = reportBugUrl(plugin); return ( <SiteShell width="default"> @@ -64,8 +69,16 @@ function PluginPage() { </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 w-fit max-w-full"> - <CodeBlock size="sm">{installCommand(plugin)}</CodeBlock> + <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> + {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}> diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index 505b8177fc..613afc375c 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -27,6 +27,8 @@ import { mostInstalled, newestFirst, pluginOwner, + recentlyUpdated, + updatedAgo, } from "~/plugins"; import { ContributeSection } from "~/plugins/contribute-links"; import { PluginsHero } from "~/plugins/hero"; @@ -41,7 +43,7 @@ import { parseWindow, } from "~/plugins/links"; import { InstallCount } from "~/plugins/install-count"; -import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card"; +import { PluginCard, PluginRankRow } from "~/plugins/plugin-card"; import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section"; import { WindowSwitch } from "~/plugins/window-switch"; import "~/styles.css"; @@ -61,9 +63,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = { utils: Wrench, }; -// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up. +// 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 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4"; + "-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"; @@ -79,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, @@ -100,6 +102,7 @@ function PluginsPage() { 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( () => ({ @@ -138,9 +141,9 @@ function PluginsPage() { </PluginSectionHeader> <div className={CARD_ROW_CLASS}> {featured.map((plugin) => ( - <NewPluginCard key={plugin.id} plugin={plugin}> + <PluginCard key={plugin.id} plugin={plugin}> <InstallCount count={installs[plugin.id]?.all ?? 0} /> - </NewPluginCard> + </PluginCard> ))} </div> </PluginSection> @@ -156,13 +159,35 @@ function PluginsPage() { </PluginSectionHeader> <div className={CARD_ROW_CLASS}> {newest.map((plugin) => ( - <NewPluginCard key={plugin.id} plugin={plugin}> + <PluginCard key={plugin.id} plugin={plugin}> {addedAgo(plugin, now)} - </NewPluginCard> + </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"> diff --git a/packages/website/src/routes/sponsor.tsx b/packages/website/src/routes/sponsor.tsx index 83184e6512..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 an independent project used by tens of thousands of developers daily, 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/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/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..1c348960b3 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 @@ -197,6 +257,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 +452,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..33ba212056 100644 --- a/public-docs/community.md +++ b/public-docs/community.md @@ -30,7 +30,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/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/reference.md b/public-docs/plugins/reference.md index 1a1440ad97..04c88bdf1a 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -730,10 +730,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`): @@ -787,7 +786,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:** 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/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/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..06d8892c60 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. { 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");