diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index a4a248e1cf..f37a8b0cdf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: PASEO_HOME="$(mktemp -d)" export PASEO_LISTEN=127.0.0.1:6767 - WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log" + WRAPPER_LOG="$PASEO_HOME/paseo-cli.log" cleanup() { if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -47,7 +47,7 @@ jobs: } trap cleanup EXIT - PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 & + PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 & DAEMON_PID=$! deadline=$((SECONDS + 30)) @@ -57,7 +57,22 @@ jobs: echo "$STATUS_JSON" curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html" [[ -s "$PASEO_HOME/web-ui.html" ]] - exit 0 + if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then + break + fi + TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")" + ./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter + deadline=$((SECONDS + 15)) + while (( SECONDS < deadline )); do + if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \ + | jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then + ./result/bin/paseo terminal kill "$TERMINAL_ID" + exit 0 + fi + sleep 1 + done + echo "Nix terminal did not execute the command." + break fi if ! kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -72,7 +87,7 @@ jobs: cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "" echo "::endgroup::" - echo "::group::paseo-server stdout/stderr" + echo "::group::paseo daemon run stdout/stderr" cat "$WRAPPER_LOG" 2>/dev/null || echo "" echo "::endgroup::" 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 269097aab4..4582602a97 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -84,6 +84,45 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + { + "files": [ + "plugins/*-provider/server/**/*.ts", + "packages/plugin/src/server/acp-internal/**/*.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + }, + { + "name": "child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + } + ] + } + ] + } + }, { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { @@ -213,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 186dad677e..0e2b24ac5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,63 +1,70 @@ # Changelog -## 0.11.0-beta.3 - 2026-10-02 - -### Added - -- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876)) - -### Changed - -- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876)) +## 0.11.2 - 2026-10-09 ### Fixed -- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876)) -- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876)) -- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876)) +- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445)) -## 0.11.0-beta.2 - 2026-10-01 +## 0.11.1 - 2026-10-07 ### Added -- 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)) - -### Changed - -- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844)) -- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844)) -- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332)) ### Fixed -- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844)) -- 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)) -- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838)) +- 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-beta.1 - 2026-10-01 +## 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, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795)) -- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#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)) -- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- 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 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)) +- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it, listing each login's error when none works ([#5876](https://github.com/getpaseo/paseo/pull/5876), [#6156](https://github.com/getpaseo/paseo/pull/6156)) - Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m)) - 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 Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708)) -- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680)) -- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753)) -- Added provider option defaults in `config.json` under `agents.providers..options`, with per-agent overrides, for every provider ([#5780](https://github.com/getpaseo/paseo/pull/5780)) -- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707)) -- Added sidebar header and footer items and screens to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) -- Added `activeTurnBehavior` to the SDK's `send()` and `run()` so a caller can steer instead of interrupt an active turn ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy)) +- 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)) +### 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 -- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805)) +- 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), [#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)) +- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090)) +- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805)) +- Changed the sidebar workspace row to show last activity by default on new installs ([#6087](https://github.com/getpaseo/paseo/pull/6087)) +- Changed compact sidebar footer buttons to 44×44 tap targets ([#6044](https://github.com/getpaseo/paseo/pull/6044)) - Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds)) - Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750)) - Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550)) @@ -65,19 +72,44 @@ ### 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 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)) - Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx)) - Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550)) - Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD)) - Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil)) - Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork)) -- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Fixed OMP tool rows showing raw objects or generic labels for failed tools, Task, Wait, web search, and fetch ([#5550](https://github.com/getpaseo/paseo/pull/5550)) - Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi)) - Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff)) - 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)) - Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae)) @@ -88,23 +120,58 @@ - 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 schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765)) +- 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)) +- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone)) +- 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)) - Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585)) -- 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 agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727)) -- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636)) -- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703)) -- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598)) -- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8)) -- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620)) -- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602)) -- 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 a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772)) +- Fixed the centered New workspace form collapsing and overlapping when the keyboard opens on tablets ([#6093](https://github.com/getpaseo/paseo/pull/6093) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058)) - Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang)) -- Fixed iPad chat text keeping its narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716)) +- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716)) +- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598)) +- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703)) +- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636)) +- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed `paseo agent delete --all` and `--cwd` leaving archived agents behind ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed `paseo agent` commands reporting a generic failure instead of `AGENT_NOT_FOUND` for an unknown agent ID ([#6053](https://github.com/getpaseo/paseo/pull/6053)) +- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727)) +- Fixed `paseo restart` exiting with `RESTART_NOT_CONFIRMED` when provider checks take more than a second ([#6009](https://github.com/getpaseo/paseo/pull/6009)) +- 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)) - Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815)) ## 0.10.3 - 2026-10-02 diff --git a/CLAUDE.md b/CLAUDE.md index 197009f8fa..7076471ff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine. -**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Muse Code, and Hoplite. Hoplite's agent runs in a Hoplite cloud workspace, not on your machine. +**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code. ## Repository map @@ -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 5a0214cc8e..2fc0fc8f3d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

-

One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, Muse Code, and Hoplite agents.

+

One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.

Paseo app screenshot @@ -39,21 +39,33 @@ Paseo mobile app

-Run agents in parallel on your own machines. Ship from your phone or your desk. +Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android. +- **Parallel agents:** Run many agents at once, each in its own worktree. +- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers. +- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. -- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, Muse Code, and Hoplite through the same interface. Pick the right model for each job. Hoplite is the exception to self-hosting: its agent runs in a Hoplite cloud workspace. +- **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 -Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted -TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install `. +Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, +iOS, and Android. Write a plugin once and it is on your phone. -Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon -machine and inside connected clients; install only code you trust. +- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes. +- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends. +- **Providers:** add a coding agent as a provider. + +Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory. + +**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)** + +Plugins run with access to your daemon machine and inside connected clients; install only code you trust. ## Getting Started @@ -69,7 +81,6 @@ You need at least one agent CLI installed and configured with your credentials: - [OpenCode](https://github.com/anomalyco/opencode) - [Pi](https://pi.dev) - [Antigravity](https://paseo.sh/docs/supported-providers#antigravity) -- [Hoplite](https://paseo.sh/docs/supported-providers#hoplite) - [Muse Code](https://paseo.sh/docs/muse-code) ### Desktop app (recommended) @@ -78,16 +89,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r To connect from your phone, open **Settings → your host → Pair Device**. -### CLI / headless +### Server -Install the CLI and start Paseo: +For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon: ```bash npm install -g @getpaseo/cli paseo ``` -Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines. +Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host. For full setup and configuration, see: @@ -198,7 +209,7 @@ npm run typecheck ## Sponsors -Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. +Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. 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/browser-capture-harness.md b/docs/browser-capture-harness.md index 594aacf5f6..f760fafa67 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -43,6 +43,18 @@ Run the shared browser profile fixture with: PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop ``` +On macOS, test native titlebar dragging after scrolling with: + +```bash +PASEO_CAPTURE_HARNESS_GROUP=titlebar-drag npm run capture-harness --workspace=@getpaseo/desktop +``` + +This group needs Xcode Command Line Tools and Accessibility permission for the +terminal running it. It sends real mouse input to its test window and checks +window movement, including button clicks and a portal over the titlebar. Leave +the mouse idle during this group. It reads the app's production CSS and writes +screenshots and window bounds to the output directory. + The browser profile group runs two Electron processes in sequence. It verifies that each renderer-side `did-attach` identity maps to the correct main-process guest, that two live tabs share cookies and local storage through one persistent session, and that the data is 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..9415abd85a 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 | Stacks tabs; each closes or becomes a tab | ## Panel host contract @@ -22,6 +23,29 @@ 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. Each sheet holds one ordinary +`WorkspaceTab`, and sheets stack: a target opened from a sheet covers it, and closing the top sheet +(drag, Close, backdrop, or Android Back) uncovers the one below. Revealing a target that already has +a sheet brings that sheet back to the top and closes the sheets above it, except ones holding unsaved +work. Opening a sheet as a tab dismisses the sheets above it, since the +tab would sit under them. The stack is not persisted. + +Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag +handle, because the panel scrolls on its own and content panning would fight it. Sheets render in +the workspace tree rather than through `IsolatedBottomSheetModal`: their panels need 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 +86,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 9c4e793fb8..9241cec56d 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -11,7 +11,7 @@ canonical files and are trying to add or change one. | ---------------------------------------- | ----------------------------------------------------------------- | | `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet | | `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip | -| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal | +| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal | | `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input | Each handles a different mix of concerns: combobox owns input focus, tooltip is @@ -92,7 +92,7 @@ portal can cover them. Painting and keyboard ownership use the same relative layer model. Register desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the highest painted scope alone receives overlay keys, traps focus, and restores -focus when it closes. Do not add component-local global Escape listeners: two +focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two stacked overlays would both close on one keypress. If an overlay is rendered by a global host rather than beneath its opener in @@ -198,6 +198,47 @@ viewport keeps the last keyboard reservation until the next opening replaces it, so a capped draft keeps one height through every keyboard transition instead of expanding on close and shrinking again on open. +Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock +fullscreen` fills the stationary viewport with the composer slot over the +still-mounted content, paints its own surface under the safe areas, and turns +the surface translation off. The composer's single tree keeps the editing +surface at the same position in both modes, so the mounted editor, its native +buffer, selection, and focus survive the toggle and Send's clear always reaches +the live input. The one remount left is a viewport crossing the compact +breakpoint: the app shell swaps its gesture wrapper and the workspace screen +mounts tabs in different trees, so every screen below remounts. The draft owner +restores the text and the editing session restores the caret and focus intent. +New workspace keys its sessions by draft in the app-level store for the same +reason. The centered form honors fullscreen by style alone: no centering, no +translation, setup fields hidden, slot filling the stationary viewport. The +global exit action (Escape, hardware back) is registered only for the active +composer: a retained tab keeps its fullscreen editor mounted while hidden, the +sheet suspends pane focus, and the composer gives up input from the moment the +user asks for the compact agent list or explorer, before the panel's motion +settles. Pane focus itself stays put under those panels: the agent panel reads +it as "the user is in this chat" for attention clearing, and opening the agent +list must not clear the attention it shows. Focus restoration follows pane +focus, not the active composer. The editor keeps its focus intent only across +a blur that lands while its pane is not the user's (a retained tab hidden, a +sheet over it) and refocuses when the pane comes back. A blur while the pane is +theirs drops the intent in every layout mode, and the keyboard the compact +panels dismiss when they settle is such a blur, so closing the agent list or +explorer never reopens the keyboard. Keying restoration on the active composer +made that dismiss look like a hidden-tab blur and refocused on close. +The band beside the inline editor where the fullscreen glyph sits is the input's +own right padding, so a tap anywhere in it focuses the editor whether or not the +glyph shows. On web the slot is a fixed box over the visible viewport, which is +also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard +height as a bottom margin at move start and end (boundary-time, like the +capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per +frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap +and without animating the editor's height. The screen that owns the chrome +unmounts it: the workspace screen reads `useComposerTabFullscreen` for the +active tab and New workspace reads the mounted composer's session. Hosts that +render the dock pass the same `ComposerEditingSession` to the `Composer` so the +dock, chrome, and editor agree on one fullscreen state. Escape (web overlay +registration, focus untouched) and the Android back button leave fullscreen. + Keep setup fields out of the composer's shrink chain. New workspace supplies its setup fields as the first dock child so they move up under the header as the draft grows. Putting the fields and composer in one bounded, shrinkable column made @@ -304,7 +345,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 390acefa19..dcf8178bc7 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** — A compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher; opening another target from a sheet stacks a new one on top. UI action: "Open as tab". Code: `sheetsByWorkspace` 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). @@ -33,8 +34,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). -- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a tooltip on wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. Only the sheet has Refresh. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). -- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. +- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). +- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). diff --git a/docs/hover.md b/docs/hover.md index 46f16af578..fe8b539edc 100644 --- a/docs/hover.md +++ b/docs/hover.md @@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor. -For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`. +For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content. Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook. @@ -150,5 +150,5 @@ Before opening a PR that touches hover: - [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger. - [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`). - [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it. -- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up. +- [ ] If the revealed content sits in a separate layer (portal, floating panel) and the user can point at it, it is a `HoverCard`. - [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state. diff --git a/docs/menus.md b/docs/menus.md index 6791d28e71..50434b9e9d 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -197,7 +197,7 @@ its own. inactive. An async action can navigate before its menu closes; a hidden panel must not leave a portal backdrop blocking the destination. On web the chat suspends one commit after it goes inactive, and that inactive commit is where the surface unmounts. -- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix - positioning bugs there, not at a call site. +- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover + card, and are unit-tested. Fix positioning bugs there, not at a call site. - Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates, status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md). diff --git a/docs/plugins.md b/docs/plugins.md index 510ef68af1..866c74128e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -117,15 +117,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid ## Install from a registry -Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or -`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare -`owner/repo` is GitHub shorthand and the daemon never contacts a registry. - -With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed -artifact, and GitHub shorthand requires `github:`. Registry installs +`paseo plugin add owner/slug` installs the registry's reviewed artifact by default. +Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins). +Use `git:owner/repository` or a full Git URL for a Git source. Registry installs keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref selection is unavailable for registry installs; install an explicit source to select your own. +Registry overviews are untrusted author content, even before installation. Render them with +`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative +URLs, and isolated external links. The website adapter and corpus tests live in +`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is +`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share +this policy. Installed-plugin descriptions in the app remain plain text. + Use `host/owner/slug` for a registry at `https://host`, or set `PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix). Private registry credentials live in daemon config under @@ -138,13 +142,13 @@ owns static hosting, record shapes, pins, and advisory install counts. ## Install a Git source -GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git -URL. An existing directory still wins over source resolution. +GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git +URL. ```bash -paseo plugin install owner/repository +paseo plugin install git:owner/repository paseo plugin install https://gitlab.com/group/repository.git -paseo plugin install owner/monorepo:plugins/review +paseo plugin install git:owner/monorepo:plugins/review paseo plugin install github:owner/repository --ref main ``` @@ -155,8 +159,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r ## Managed source ownership The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier -syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged -through `installPluginSource`; only the daemon resolves host paths and acquires sources. +syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before +calling `installPluginSource`; see the public source reference for CLI and app path semantics. +Only the daemon reads plugin files and acquires sources. `ManagedPluginSources` owns acquisition and offline source description. Config stores the active directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed @@ -248,7 +253,7 @@ dependency; shared types must not refer to React components, hooks, Node APIs, o | Entry | Owns | May depend on | | ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- | | `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only | -| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code | +| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code | | `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code | | `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code | | `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code | @@ -488,9 +493,9 @@ SVG or URL. ## Usage sources -Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. +Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. -The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version. +The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. ## Contribute sidebar items diff --git a/docs/providers.md b/docs/providers.md index 991de2bab1..298bee651b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -6,11 +6,13 @@ server integrations. ## Plugin providers Keep a bundled provider in `plugins//` and register it through -`@getpaseo/plugin/server/provider`. Antigravity, Muse Code, and Hoplite follow this pattern. Built-in loading and SDK +`@getpaseo/plugin/server/provider`. Antigravity and Muse Code follow this pattern. Built-in loading and SDK import rules belong to [plugins.md](plugins.md#built-in-plugins); the [public provider guide](../public-docs/plugins/providers.md) covers the provider contract. -The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns +The plugin owns the CLI transport, session state, catalog, and capabilities. Launch CLI transports +and probes through the [SDK process helpers](../public-docs/plugins/providers.md#launch-the-provider-cli), +which share Windows launcher handling with core providers. The daemon owns executable resolution and applies `agents.providers..command` and `env` before connecting. Register the provider's icon with the plugin rather than adding it to the app's provider icon map. You do not need a core manifest entry or provider factory. @@ -19,7 +21,6 @@ provider icon map. You do not need a core manifest entry or provider factory. | ----------- | ------------------------------------------ | ---------------------------------------------------------------- | | Antigravity | Installed `agy` CLI | [Antigravity](../public-docs/supported-providers.md#antigravity) | | Muse Code | MSP over one `muse serve` host per session | [Muse Code](../public-docs/muse-code.md) | -| Hoplite | ACP shim over `hoplite acp` | [Hoplite](../public-docs/supported-providers.md#hoplite) | ## Provider-native session options @@ -60,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. @@ -102,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/qa-evidence/hoplite-provider/agent-dark.png b/docs/qa-evidence/hoplite-provider/agent-dark.png deleted file mode 100644 index 26a59307a7..0000000000 Binary files a/docs/qa-evidence/hoplite-provider/agent-dark.png and /dev/null differ diff --git a/docs/qa-evidence/hoplite-provider/agent-light.png b/docs/qa-evidence/hoplite-provider/agent-light.png deleted file mode 100644 index 0fdca5993a..0000000000 Binary files a/docs/qa-evidence/hoplite-provider/agent-light.png and /dev/null differ diff --git a/docs/qa-evidence/hoplite-provider/catalog-dark.png b/docs/qa-evidence/hoplite-provider/catalog-dark.png new file mode 100644 index 0000000000..b6163a863d Binary files /dev/null and b/docs/qa-evidence/hoplite-provider/catalog-dark.png differ diff --git a/docs/qa-evidence/hoplite-provider/catalog-light.png b/docs/qa-evidence/hoplite-provider/catalog-light.png new file mode 100644 index 0000000000..f8c72ab508 Binary files /dev/null and b/docs/qa-evidence/hoplite-provider/catalog-light.png differ diff --git a/docs/qa-evidence/hoplite-provider/installed-dark.png b/docs/qa-evidence/hoplite-provider/installed-dark.png new file mode 100644 index 0000000000..b69a87f3ef Binary files /dev/null and b/docs/qa-evidence/hoplite-provider/installed-dark.png differ diff --git a/docs/qa-evidence/hoplite-provider/installed-light.png b/docs/qa-evidence/hoplite-provider/installed-light.png new file mode 100644 index 0000000000..0572cac57f Binary files /dev/null and b/docs/qa-evidence/hoplite-provider/installed-light.png differ diff --git a/docs/qa-evidence/hoplite-provider/new-agent-model-picker.png b/docs/qa-evidence/hoplite-provider/new-agent-model-picker.png deleted file mode 100644 index ee84a1f497..0000000000 Binary files a/docs/qa-evidence/hoplite-provider/new-agent-model-picker.png and /dev/null differ diff --git a/docs/qa-evidence/hoplite-provider/settings-available.png b/docs/qa-evidence/hoplite-provider/settings-available.png deleted file mode 100644 index 746661738d..0000000000 Binary files a/docs/qa-evidence/hoplite-provider/settings-available.png and /dev/null differ diff --git a/docs/qa-evidence/hoplite-provider/settings-unlinked-directory.png b/docs/qa-evidence/hoplite-provider/settings-unlinked-directory.png deleted file mode 100644 index fe89a8c4ba..0000000000 Binary files a/docs/qa-evidence/hoplite-provider/settings-unlinked-directory.png and /dev/null differ diff --git a/docs/release.md b/docs/release.md index 26af0182eb..cdac451f23 100644 --- a/docs/release.md +++ b/docs/release.md @@ -95,7 +95,7 @@ release push as the changelog and version commit. There are two supported release paths: 1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`). -2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`. +2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`. Paseo has one linear release track even though npm dist-tags are independent pointers. The npm invariant is: @@ -194,7 +194,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z - Desktop assets now come from the Electron package at `packages/desktop` - Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together. - Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published. -- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section. +- **A beta series has one changelog entry.** Each beta folds into it and renames its heading to the new `## X.Y.Z-beta.N`, which `Release Notes Sync` mirrors into that prerelease body. Promotion renames it to the stable heading. See the Changelog policy section. Use the beta path when you need to: @@ -414,7 +414,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w - Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`. - The default download target only moves when you publish the final stable release tag like `v0.1.41`. - The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them. -- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor. +- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor. - The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases. ## Fixing a failed release build @@ -521,17 +521,20 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or ## Changelog policy -- `CHANGELOG.md` includes stable releases and every entry in the current beta series. +- `CHANGELOG.md` holds stable releases and at most one entry for the current beta series. - The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`. -- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag. -- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry. -- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series. +- Each subsequent beta rewrites that entry in place: the heading becomes the new beta number and date, and the body describes the full change from the previous stable release to this beta. Never add a second beta entry. +- The entry is a list of changes, not of PRs. When a later beta reverts, reworks, or follows up on something an earlier beta added, rewrite the bullet to the current behavior or delete it. A stable user never saw the earlier beta behavior, so fixes to it are not "Fixed" bullets. +- Anything that already shipped to stable through a patch release on an older line stays out of the beta entry. +- Stable promotion renames the entry to `## 0.1.60 - YYYY-MM-DD` after a final review. + +Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry. ## Changelog ownership - **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent. - **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding. -- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it. +- Every entry, beta or stable, is drafted from the previous stable release to the release source. For a later beta, read the PRs since the previous beta tag, then rework the existing entry so each bullet still describes current behavior. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it. ## Changelog wording @@ -563,7 +566,7 @@ that changed, the failure that was fixed, or the capability that was added? If t claims a general improvement, rewrite it with the concrete change. - **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**. -- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior. +- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. - **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them. ## Changelog conciseness @@ -631,12 +634,7 @@ Use `git diff ..` as the review input. T Changelog scope follows the release being described: -- **First beta**: `previous stable tag → release source` -- **Later beta**: `previous beta tag → release source` -- **Direct stable release**: `previous stable tag → release source` -- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source` - -Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next. +Every entry covers `previous stable release → release source`: first beta, later beta, direct stable release, and promotion alike. The beta entry always reads as the stable entry would if the beta were promoted today. ## Completion checklist @@ -644,7 +642,7 @@ Each beta entry records what its testers receive. Promotion produces the single - [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog -- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release +- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release - [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved - [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag - [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully @@ -667,7 +665,7 @@ Each beta entry records what its testers receive. Promotion produces the single - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog - [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command - [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command -- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release +- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format - [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag - [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully diff --git a/docs/service-proxy.md b/docs/service-proxy.md index 3ec3144289..cab7f42774 100644 --- a/docs/service-proxy.md +++ b/docs/service-proxy.md @@ -4,7 +4,7 @@ Paseo proxies HTTP traffic to services running inside your workspaces. Localhost ## How it works -When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. +When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. HTTP requests, WebSocket upgrades, and service health checks select the reachable IPv4 or IPv6 loopback address. Services can bind to `127.0.0.1` or `::1` without changing their proxy URL. The generated hostname is built from the script name, branch, and project: diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 8264fe2683..d1a770f219 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -223,7 +223,7 @@ submission. Overlapping sends settle independently rather than collapsing to one message. Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted -prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that +human prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted row is recorded before handler output. The app tracks submission transactions only for hosts with this capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC @@ -238,11 +238,25 @@ foreground control ownership remains a separate daemon concern. Cancellation req with that record rather than in a React component, so an old request cannot clear a newer one. Submissions remain a separate pre-turn registry and retire on canonical acknowledgement. -Canonical turns and visible responses are different boundaries. System-injected prompts are absent from -the Paseo timeline, so one visible response can span several canonical turns without a user message -between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact +Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle +notifications project into tool rows, so they do not create a human-message boundary. Legacy system +prompts, including schedule injections, remain hidden. One visible response can span several canonical +turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact fork positions retain the canonical `turnId` boundaries. +Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes +provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is +also the projected call ID, so a provider echo cannot create a second row. Sender IDs are opaque and +do not imply local ownership or authorization. Capture the sender title at delivery: archived or +removed senders must remain understandable without a live directory lookup. + +The optional `agentMessage` payload carries event, sender, and delivered text. Shared client +presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon +must not construct UI labels or prepend sender labels to the semantic message body. The existing +`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and +expand its body. New clients use the typed payload, never infer the event from a tool name or parse +XML. Stream reduction and disk-cache serialization preserve the payload without rendering it. + The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a @@ -250,7 +264,7 @@ second running state. Disconnect preserves the last replicated turn until cache advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn liveness, never from submission records or whichever timeline rows happen to be mounted. -The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the +For human prompts, the daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the submission's `clientMessageId`, so the row is born with its final identity and remains immutable on the wire. A correlated provider echo records the provider's native identity internally without dispatching another timeline event. Rewind resolves the wire identity to that provider identity at diff --git a/fastlane/metadata/android/en-US/changelogs/110001.txt b/fastlane/metadata/android/en-US/changelogs/110001.txt new file mode 100644 index 0000000000..1624e3b4a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110001.txt @@ -0,0 +1,7 @@ +Added +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls +- Added Antigravity as a provider that drives the installed agy CLI, always in Full access +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110002.txt b/fastlane/metadata/android/en-US/changelogs/110002.txt new file mode 100644 index 0000000000..1624e3b4a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110002.txt @@ -0,0 +1,7 @@ +Added +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls +- Added Antigravity as a provider that drives the installed agy CLI, always in Full access +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110003.txt b/fastlane/metadata/android/en-US/changelogs/110003.txt new file mode 100644 index 0000000000..1624e3b4a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110003.txt @@ -0,0 +1,7 @@ +Added +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls +- Added Antigravity as a provider that drives the installed agy CLI, always in Full access +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110004.txt b/fastlane/metadata/android/en-US/changelogs/110004.txt new file mode 100644 index 0000000000..1624e3b4a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110004.txt @@ -0,0 +1,7 @@ +Added +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls +- Added Antigravity as a provider that drives the installed agy CLI, always in Full access +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110011.txt b/fastlane/metadata/android/en-US/changelogs/110011.txt new file mode 100644 index 0000000000..c5b462f3b5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110011.txt @@ -0,0 +1,8 @@ +Added +- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer + +Fixed +- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents +- Fixed subagents that finish while the app is disconnected staying "working" until one is opened + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110012.txt b/fastlane/metadata/android/en-US/changelogs/110012.txt new file mode 100644 index 0000000000..c5b462f3b5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110012.txt @@ -0,0 +1,8 @@ +Added +- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer + +Fixed +- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents +- Fixed subagents that finish while the app is disconnected staying "working" until one is opened + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110013.txt b/fastlane/metadata/android/en-US/changelogs/110013.txt new file mode 100644 index 0000000000..c5b462f3b5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110013.txt @@ -0,0 +1,8 @@ +Added +- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer + +Fixed +- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents +- Fixed subagents that finish while the app is disconnected staying "working" until one is opened + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110014.txt b/fastlane/metadata/android/en-US/changelogs/110014.txt new file mode 100644 index 0000000000..c5b462f3b5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110014.txt @@ -0,0 +1,8 @@ +Added +- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer + +Fixed +- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents +- Fixed subagents that finish while the app is disconnected staying "working" until one is opened + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110021.txt b/fastlane/metadata/android/en-US/changelogs/110021.txt new file mode 100644 index 0000000000..92dc176fa3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110021.txt @@ -0,0 +1,4 @@ +Fixed +- Fixed intermittent text clipping in iOS chats and Markdown previews + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110022.txt b/fastlane/metadata/android/en-US/changelogs/110022.txt new file mode 100644 index 0000000000..92dc176fa3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110022.txt @@ -0,0 +1,4 @@ +Fixed +- Fixed intermittent text clipping in iOS chats and Markdown previews + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110023.txt b/fastlane/metadata/android/en-US/changelogs/110023.txt new file mode 100644 index 0000000000..92dc176fa3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110023.txt @@ -0,0 +1,4 @@ +Fixed +- Fixed intermittent text clipping in iOS chats and Markdown previews + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/110024.txt b/fastlane/metadata/android/en-US/changelogs/110024.txt new file mode 100644 index 0000000000..92dc176fa3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/110024.txt @@ -0,0 +1,4 @@ +Fixed +- Fixed intermittent text clipping in iOS chats and Markdown previews + +Full notes: https://paseo.sh/changelog diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index d00b382083..3fd5f47093 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -86,6 +86,12 @@ buildNpmPackage { # Expo's web build pulls in some pre-bundled assets; ensure it doesn't try # to phone home during the build. CI = "1"; + # Metro's worker runs out of heap transforming the generated + # ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is + # 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB + # macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this + # only raises the limit on smaller builders, like desktop-release.yml does. + NODE_OPTIONS = "--max-old-space-size=4096"; }; buildPhase = '' @@ -157,6 +163,9 @@ buildNpmPackage { cp -a "$path" "$out/share/paseo-desktop/$path" done < desktop-files.txt + # Shell hooks invoke the retained CLI bin directly, without a system Node. + patchShebangs --build "$out/share/paseo-desktop" + # Keep the same unpackaged monorepo layout expected by main.js. cp package.json $out/share/paseo-desktop/ mkdir -p $out/share/paseo-desktop/packages/app diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index fa431c78ff..8906abc66b 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc= +sha256-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ= diff --git a/nix/package.nix b/nix/package.nix index 417f3d2ad1..b032d88396 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -120,6 +120,9 @@ buildNpmPackage rec { cp -a "$path" "$out/lib/paseo/$path" done < daemon-files.txt + # Shell hooks invoke the retained CLI bin directly, without a system Node. + patchShebangs --build "$out/lib/paseo" + # Root package.json lets node resolve the workspace layout when the # CLI/server bin starts from $out. cp package.json $out/lib/paseo/ diff --git a/package-lock.json b/package-lock.json index 01d3bd7f4e..c31951b1b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.11.0-beta.3", + "version": "0.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.11.0-beta.3", + "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.3", + "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.3", + "version": "0.11.2", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.11.0-beta.3", - "@getpaseo/protocol": "0.11.0-beta.3", - "@getpaseo/server": "0.11.0-beta.3", + "@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.3", + "version": "0.11.2", "dependencies": { - "@getpaseo/protocol": "0.11.0-beta.3", - "@getpaseo/relay": "0.11.0-beta.3", + "@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.3", + "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.3", + "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.3", + "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.3", + "version": "0.11.2", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.11.0-beta.3", - "@getpaseo/protocol": "0.11.0-beta.3", + "@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.3", - "@getpaseo/protocol": "0.11.0-beta.3", + "@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.3", + "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.3", + "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.3", + "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.3", - "@getpaseo/highlight": "0.11.0-beta.3", - "@getpaseo/plugin": "0.11.0-beta.3", - "@getpaseo/protocol": "0.11.0-beta.3", - "@getpaseo/relay": "0.11.0-beta.3", + "@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,11 +40146,12 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.11.0-beta.3", + "version": "0.11.2", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", "@fontsource-variable/geist": "^5.3.0", + "@getpaseo/protocol": "*", "@tanstack/react-router": "^1.170.16", "@tanstack/react-start": "^1.168.26", "framer-motion": "^12.35.2", @@ -40165,8 +40166,7 @@ "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", - "wrangler": "^4.75.0", - "@getpaseo/protocol": "*" + "wrangler": "^4.75.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.8", @@ -40174,6 +40174,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.1", + "parse5": "^7.3.0", "typescript": "^5.9.3", "vite": "^7.3.3", "vite-tsconfig-paths": "^5.1.4", @@ -41753,7 +41754,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.11.0-beta.3", + "version": "0.11.2", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index f27be7301c..ecbfd83891 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.11.0-beta.3", + "version": "0.11.2", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 79d68249f2..e09a06f71d 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); const pkg = require("./package.json"); const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size"); +const withAndroidOptionalCamera = require("./plugins/with-android-optional-camera"); const withAndroidProfileable = require("./plugins/with-android-profileable"); const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking"); const withPasteInput = require("./plugins/with-paste-input"); @@ -145,6 +146,7 @@ export default { withAndroidScroll, [withAndroidAsyncStorageSize, 64], ...buildProfile.cameraPlugins, + withAndroidOptionalCamera, [ "expo-splash-screen", { diff --git a/packages/app/app.config.test.ts b/packages/app/app.config.test.ts new file mode 100644 index 0000000000..b98258ff9a --- /dev/null +++ b/packages/app/app.config.test.ts @@ -0,0 +1,61 @@ +import { createRequire } from "node:module"; +import { afterEach, describe, expect, it } from "vitest"; + +// Load the copies the Expo CLI uses for prebuild, not older hoisted ones. +const requireFromExpo = createRequire(require.resolve("expo/package.json")); +const requireFromExpoCli = createRequire(requireFromExpo.resolve("@expo/cli/package.json")); +const { getPrebuildConfigAsync } = requireFromExpoCli("@expo/prebuild-config"); +const { compileModsAsync } = requireFromExpoCli( + "@expo/config-plugins/build/plugins/mod-compiler.js", +); + +interface ManifestEntry { + $: Record; +} + +const originalFdroidFlag = process.env.PASEO_FDROID_BUILD; + +async function resolveAndroidManifest() { + const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["android"] }); + await compileModsAsync(exp, { + projectRoot: __dirname, + introspect: true, + platforms: ["android"], + assertMissingModProviders: false, + }); + return exp._internal.modResults.android.manifest.manifest; +} + +describe("Android app config", () => { + afterEach(() => { + if (originalFdroidFlag === undefined) { + delete process.env.PASEO_FDROID_BUILD; + } else { + process.env.PASEO_FDROID_BUILD = originalFdroidFlag; + } + }); + + it.each([ + ["Google Play", "0"], + ["F-Droid", "1"], + ])( + "%s build installs on devices without a camera", + async (_build, fdroidFlag) => { + process.env.PASEO_FDROID_BUILD = fdroidFlag; + const manifest = await resolveAndroidManifest(); + const permissions = (manifest["uses-permission"] ?? []).map( + (entry: ManifestEntry) => entry.$["android:name"], + ); + const features = (manifest["uses-feature"] ?? []).map((entry: ManifestEntry) => entry.$); + + expect(permissions).toContain("android.permission.CAMERA"); + expect(features).toEqual( + expect.arrayContaining([ + { "android:name": "android.hardware.camera", "android:required": "false" }, + { "android:name": "android.hardware.camera.autofocus", "android:required": "false" }, + ]), + ); + }, + 30_000, + ); +}); diff --git a/packages/app/e2e/browser/acp-provider-catalog.spec.ts b/packages/app/e2e/browser/acp-provider-catalog.spec.ts index a0d94e5c02..77b10c94a1 100644 --- a/packages/app/e2e/browser/acp-provider-catalog.spec.ts +++ b/packages/app/e2e/browser/acp-provider-catalog.spec.ts @@ -4,6 +4,7 @@ import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import { getServerId } from "../support/helpers/server-id"; import { expectProviderInstalledInSettings, + findAcpCatalogProvider, installAcpCatalogProvider, openAddProviderArea, openSettingsHost, @@ -41,3 +42,29 @@ test.describe("ACP provider catalog", () => { } }); }); + +for (const theme of ["light", "dark"] as const) { + test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-catalog-e2e", + }); + try { + await page.addInitScript((value) => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value })); + }, theme); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await openAddProviderArea(page); + await findAcpCatalogProvider(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) }); + await installAcpCatalogProvider(page, "Hoplite"); + await expectProviderInstalledInSettings(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) }); + } finally { + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined); + await client.close().catch(() => undefined); + } + }); +} diff --git a/packages/app/e2e/browser/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts index 027d6156e7..9fd7f9413d 100644 --- a/packages/app/e2e/browser/add-project-flow.spec.ts +++ b/packages/app/e2e/browser/add-project-flow.spec.ts @@ -223,6 +223,7 @@ test.describe("Add Project command-center flow", () => { await page.keyboard.press("Enter"); await expectAddProjectPage(page, "directory-search"); + await expect(addProjectFlowInput(page)).toBeFocused(); await page.keyboard.type(projectPickerFixture.fuzzyQuery); await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, { timeout: 30_000, diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 75cf7ff0c9..3bfda6b4b0 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -284,7 +284,6 @@ async function expectRejectedSubmissionRestored( await expectComposerDraft(page, input.prompt); await expectComposerEditable(page); await expectAttachmentPill(page, "composer-image-attachment-pill"); - await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0); if (input.preservesActiveTurn) { await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); return; @@ -295,13 +294,21 @@ async function expectRejectedSubmissionRestored( async function retryRestoredSubmission(page: Page, prompt: string): Promise { await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter"); - const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }); - await expect(userMessage).toHaveCount(1); + const attempts = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(attempts).toHaveCount(2); + const userMessage = attempts.last(); await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); } +async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) { + const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message")); + await expect(messages).toHaveCount(2); + await expect(messages.nth(0)).toContainText(prompt); + await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`); +} + async function configureSteerInSettings(page: Page): Promise { const modifier = process.platform === "darwin" ? "Meta" : "Control"; await page.keyboard.press(`${modifier}+Comma`); @@ -1173,13 +1180,14 @@ test.describe("Agent message submission", () => { await toasts.expectNeverShown("agent-updating-toast"); }); - test("restores a rejected submission and accepts its retry", async ({ + test("retains a rejected submission before its error and accepts its retry", async ({ page, rejectionScenario, }) => { const prompt = "Restore this rejected submission."; await submitMessageThatWillBeRejected(page, prompt); await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario }); + await expectRejectedPromptBeforeError(page, prompt, rejectionScenario.errorMessage); await retryRestoredSubmission(page, prompt); }); @@ -1209,6 +1217,7 @@ test.describe("Agent message submission", () => { errorMessage: "Requested mock steer transport failure", preservesActiveTurn: true, }); + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0); expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1); expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore); diff --git a/packages/app/e2e/browser/agent-relative-time.spec.ts b/packages/app/e2e/browser/agent-relative-time.spec.ts index 17dae013ec..4fe1efe887 100644 --- a/packages/app/e2e/browser/agent-relative-time.spec.ts +++ b/packages/app/e2e/browser/agent-relative-time.spec.ts @@ -4,7 +4,6 @@ import { getServerId } from "../support/helpers/server-id"; import { seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab"; -import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar"; test("agent list age advances and command center says just now for a fresh agent", async ({ page, @@ -41,9 +40,6 @@ test("agent list age advances and command center says just now for a fresh agent "3m ago", ); - await openSidebarDisplayPage(page, "sidebar-display-show"); - await page.getByRole("menuitem", { name: "Last activity", exact: true }).click(); - await closeSidebarDisplayPreferences(page); await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m"); } finally { await session.cleanup(); diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts index 5f70bdafe3..841314343f 100644 --- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -16,6 +16,7 @@ import { } from "../support/helpers/assistant-images"; import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import path from "node:path"; const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ imageWorkspace: async ({ page: _page }, provide) => { @@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ }, }); +test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({ + imageWorkspace: workspace, + page, +}) => { + test.setTimeout(120_000); + const image = await createSmallAssistantPng(workspace, { + alt: "Angle destination image", + fileName: "My image Мои.png", + }); + const agent = await createSettledMockAgent(workspace, "Encoded image paths"); + await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`); + await openAssistantImageTimeline(page, agent); + await expectAssistantImageRendered(page, image); + + const encodedImage = { ...image, alt: "Encoded destination image" }; + await emitSettledAssistantImage( + workspace.client, + agent, + encodedImage, + encodeURI(path.join(workspace.repoPath, image.relativePath)), + ); + await expectAssistantImageRendered(page, encodedImage); + + const literalPercentImage = await createSmallAssistantPng(workspace, { + alt: "Literal percent filename image", + fileName: "literal%20name.png", + }); + await emitSettledAssistantImage( + workspace.client, + agent, + literalPercentImage, + encodeURI(literalPercentImage.relativePath), + ); + await expectAssistantImageRendered(page, literalPercentImage); +}); + test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({ imageWorkspace: workspace, page, diff --git a/packages/app/e2e/browser/appearance-font-family.spec.ts b/packages/app/e2e/browser/appearance-font-family.spec.ts new file mode 100644 index 0000000000..6ec8600017 --- /dev/null +++ b/packages/app/e2e/browser/appearance-font-family.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "../support/fixtures"; +import type { Page } from "@playwright/test"; +import { openSettingsSection } from "../support/helpers/settings"; + +async function openAppearance(page: Page) { + await page.goto("/settings"); + await openSettingsSection(page, "appearance"); +} + +async function setFontFamily(page: Page, name: string, family: string) { + const input = page.getByRole("textbox", { name }); + await input.fill(family); + await input.press("Tab"); +} + +for (const name of ["Code font family", "Interface font family"]) { + test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => { + await openAppearance(page); + await setFontFamily(page, name, "'Fira Code', monospace"); + await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace"); + + await setFontFamily(page, name, "'foo"); + await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace"); + await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport(); + await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px"); + + await page.screenshot({ path: testInfo.outputPath("rejected-font.png") }); + await page.reload(); + await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace"); + await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport(); + await setFontFamily(page, name, ""); + await expect(page.getByRole("textbox", { name })).toHaveValue(""); + }); +} + +test("opens settings with defaults when a malformed font was already saved", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }), + ); + }); + await openAppearance(page); + await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue(""); + await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue(""); + await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport(); + await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px"); +}); diff --git a/packages/app/e2e/browser/appearance-theme-picker.spec.ts b/packages/app/e2e/browser/appearance-theme-picker.spec.ts index 8df71f0911..b0d86e164e 100644 --- a/packages/app/e2e/browser/appearance-theme-picker.spec.ts +++ b/packages/app/e2e/browser/appearance-theme-picker.spec.ts @@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo) await row.click(); await expect(row).toHaveAttribute("aria-selected", "true"); - await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)"); + await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)"); await page.screenshot({ path: testInfo.outputPath("light-selected-workspace.png"), fullPage: true, diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index 5d4838f133..bf8e455c88 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -120,6 +120,84 @@ async function selectAssistantElement(page: Page, selector: string): Promise { + await assistantMessageBlocks(page).evaluateAll( + (blocks, selected) => { + let startNode: Text | null = null; + for (const block of blocks) { + const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + if (walker.currentNode.textContent?.endsWith(selected.startText)) { + startNode = walker.currentNode as Text; + } + } + } + const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean); + if (!startNode || !end) { + throw new Error(`Could not find ${selected.startText} — ${selected.selector}`); + } + const range = document.createRange(); + range.setStart(startNode, startNode.length); + range.setEnd(end, end.childNodes.length); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, + { startText, selector }, + ); +} + +/** A drag over an image alone selects it inside its rendered frame. */ +async function selectAssistantImage(page: Page): Promise { + await assistantMessageBlocks(page) + .locator("img") + .evaluate((element) => { + const range = document.createRange(); + range.selectNode(element); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }); +} + +/** Press in the gap below the image and drag to the end of `text` in the block after it. */ +async function dragFromBelowImageTo(page: Page, text: string): Promise { + const blocks = assistantMessageBlocks(page); + const target = blocks.getByText(text); + await target.scrollIntoViewIfNeeded(); + const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox(); + const end = await target.evaluate((element, selectedText) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const offset = node.textContent?.indexOf(selectedText) ?? -1; + if (offset >= 0) { + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset + selectedText.length); + const box = range.getBoundingClientRect(); + return { x: box.right - 1, y: box.top + box.height / 2, left: box.left }; + } + } + throw new Error(`Could not find ${selectedText}`); + }, text); + if (!frame) { + throw new Error("Expected a rendered image"); + } + await page.mouse.move(end.left + 5, frame.y + frame.height + 4); + await page.mouse.down(); + await page.mouse.move(end.x, end.y, { steps: 10 }); + await page.mouse.up(); +} + async function selectAssistantText(page: Page, text: string): Promise { await selectAssistantTextRange(page, text, text); } @@ -565,6 +643,19 @@ test("copying an assistant selection preserves Markdown structure and links", as const completeCodeClipboard = await readRichClipboard(page); expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;'); + // Touching the block before it without selecting any of its text is still a + // selection inside the code. + await selectAssistantElementFromEndOf( + page, + "not a generated link.", + '[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]', + ); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe( + 'const answer = "yes";\n return answer;', + ); + // Crossing the block boundary expresses an intent to carry its Markdown structure. await selectAssistantAcrossCodeLines(page, "const", "After code."); await copySelection(page); @@ -601,3 +692,55 @@ test("copying an assistant selection preserves Markdown structure and links", as await agent.cleanup(); } }); + +test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({ + context, + page, +}) => { + const image = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "assistant-selection-copy-image-", + title: "Assistant selection copy image", + initialPrompt: "Render the image fixture.", + featureValues: { + mockAssistantResponse: [ + "Before the image.", + "", + `![chart](${image})`, + "", + "After the image.", + ].join("\n"), + }, + }); + + try { + await allowRichClipboard(context); + await agent.client.waitForAgentUpsert( + agent.agentId, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + await openAgentRoute(page, agent); + await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1); + + await selectAssistantTextRange(page, "Before the image.", "After the image."); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe( + `Before the image.\n\n![chart](${image})\n\nAfter the image.`, + ); + + await selectAssistantImage(page); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe(`![chart](${image})`); + + await dragFromBelowImageTo(page, "After the"); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe("After the"); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/changes-first-edit.spec.ts b/packages/app/e2e/browser/changes-first-edit.spec.ts new file mode 100644 index 0000000000..72c3890936 --- /dev/null +++ b/packages/app/e2e/browser/changes-first-edit.spec.ts @@ -0,0 +1,122 @@ +import { appendFile } from "node:fs/promises"; +import path from "node:path"; +import type { Page } from "@playwright/test"; +import { test, expect } from "../support/fixtures"; +import { openChangesTreePanel } from "../support/helpers/workspace-tabs"; + +function changesTree(page: Page) { + return page.getByTestId("changes-tree-panel").filter({ visible: true }); +} + +async function expectEmptyChanges(page: Page) { + await expect(changesTree(page).getByText("No changes to display", { exact: true })).toBeVisible(); +} + +async function editReadme(repoPath: string, text: string) { + await appendFile(path.join(repoPath, "README.md"), text); +} + +async function expectReadmeChanges(page: Page, additions: number) { + await expect(changesTree(page).getByText("README.md", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(changesTree(page)).toContainText(`+${additions}`); +} + +async function openEmptyDiff(page: Page) { + await changesTree(page).getByTestId("changes-options-menu").click(); + await page.getByTestId("changes-open-tab").click(); + await expect( + page + .getByTestId("working-diff-panel") + .filter({ visible: true }) + .getByText("No changes to display", { exact: true }), + ).toBeVisible(); +} + +async function expectReadmeDiff(page: Page, additions: number) { + await expect( + page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"), + ).toHaveAccessibleName(`README.md, +${additions}, -0`, { timeout: 30_000 }); +} + +async function openDiffFromNewTabMenu(page: Page) { + await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first().click(); + await page.getByTestId("workspace-new-tab-menu-diff").filter({ visible: true }).first().click(); + await expect( + page + .getByTestId("working-diff-panel") + .filter({ visible: true }) + .getByText("No changes to display", { exact: true }), + ).toBeVisible(); +} + +async function refreshDiff(page: Page) { + await page + .getByTestId("working-diff-panel") + .filter({ visible: true }) + .getByTestId("changes-refresh") + .click(); +} + +async function reloadWorkspace(page: Page) { + await page.reload(); +} + +test("Changes follows the first edit to a clean workspace without reloading", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace(); + await page.setViewportSize({ width: 1400, height: 900 }); + await workspace.navigateTo(); + await openChangesTreePanel(page); + await expectEmptyChanges(page); + + await editReadme(workspace.repoPath, "first edit\n"); + await expectReadmeChanges(page, 1); + + await editReadme(workspace.repoPath, "second edit\n"); + await expectReadmeChanges(page, 2); +}); + +test("the full diff follows the first edit to a clean workspace without reloading", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace(); + await page.setViewportSize({ width: 1400, height: 900 }); + await workspace.navigateTo(); + await openChangesTreePanel(page); + await expectEmptyChanges(page); + await openEmptyDiff(page); + + await editReadme(workspace.repoPath, "first edit\n"); + await expectReadmeDiff(page, 1); + + await editReadme(workspace.repoPath, "second edit\n"); + await expectReadmeDiff(page, 2); +}); + +test("a directly opened Diff tab follows the first edit without an active Changes tree", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace(); + await page.setViewportSize({ width: 1400, height: 900 }); + await workspace.navigateTo(); + await openDiffFromNewTabMenu(page); + + await editReadme(workspace.repoPath, "first edit\n"); + await expectReadmeDiff(page, 1); + + await editReadme(workspace.repoPath, "second edit\n"); + await expectReadmeDiff(page, 2); + + await refreshDiff(page); + await expectReadmeDiff(page, 2); + await reloadWorkspace(page); + await expectReadmeDiff(page, 2); + await editReadme(workspace.repoPath, "third edit\n"); + await expectReadmeDiff(page, 3); +}); diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 637d1b633b..377adbb3b6 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) => const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts"); - await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0"); + await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0"); const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/); const toggleTestId = await renamedToggle.getAttribute("data-testid"); expect(toggleTestId).not.toBeNull(); diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts index 7ed957f513..0ce6c561ed 100644 --- a/packages/app/e2e/browser/chat-find.spec.ts +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -428,7 +428,16 @@ async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo } } -test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => { +test("opens and refocuses Find with Control+f from the composer on Linux", async ({ + page, +}, testInfo) => { + // Chromium retains the host navigator.platform even when its user agent is overridden. + const session = await page.context().newCDPSession(page); + await session.send("Emulation.setUserAgentOverride", { + userAgent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + platform: "Linux x86_64", + }); await findFromComposer(page, "Control+f", testInfo); }); 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/codex-speed.spec.ts b/packages/app/e2e/browser/codex-speed.spec.ts index 654f15207d..ca8c3d8991 100644 --- a/packages/app/e2e/browser/codex-speed.spec.ts +++ b/packages/app/e2e/browser/codex-speed.spec.ts @@ -26,6 +26,10 @@ test.use({ }, }); +// Rendered light-theme colors: palette yellow 400 for Fast and Ultrafast, muted foreground for Normal. +const ACTIVE_SPEED_COLOR = "rgb(251, 191, 36)"; +const NORMAL_SPEED_COLOR = "rgb(113, 113, 122)"; + async function openSpeedSelector(page: Page): Promise { const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ }); const features = page.getByRole("button", { name: "Open agent features", exact: true }); @@ -51,6 +55,13 @@ async function selectSpeed(page: Page, label: string): Promise { else await expect(sheetTrigger).toContainText(label); } +async function expectSpeedIconColor(page: Page, color: string): Promise { + const trigger = page + .getByRole("button", { name: /^(Speed: .+|Select speed)$/ }) + .filter({ visible: true }); + await expect(trigger.locator("svg").first()).toHaveCSS("stroke", color); +} + async function expectSpeedChoices(page: Page, choices: string[]): Promise { for (const choice of ["Normal", "Fast", "Ultrafast"]) { const option = page.getByText(choice, { exact: true }).last(); @@ -100,15 +111,21 @@ for (const viewport of [ await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") }); } await openSpeedSelector(page); + await selectSpeed(page, "Fast"); + await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR); + await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-fast-selected.png`) }); + await openSpeedSelector(page); await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) }); await selectSpeed(page, "Ultrafast"); + await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR); await openSpeedSelector(page); await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`), }); await selectSpeed(page, "Normal"); + await expectSpeedIconColor(page, NORMAL_SPEED_COLOR); if (viewport.name === "desktop") { await switchAgentModel(agent.id, "gpt-6-sol"); await openSpeedSelector(page); 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-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index d30e69b18c..d83046d8b2 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -9,7 +9,12 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; -import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar"; +import { + closeMobileAgentSidebar, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + openMobileAgentSidebar, +} from "../support/helpers/sidebar"; import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace"; const TEST_COMMANDS = [ @@ -96,16 +101,6 @@ interface PopoverFrameRecorderWindow extends Window { __stopComposerAutocompleteFrameRecorder?: () => void; } -async function getTopTestIdAtPoint(page: Page, x: number, y: number) { - return page.evaluate( - ([pointX, pointY]) => { - const element = document.elementFromPoint(pointX, pointY); - return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null; - }, - [x, y], - ); -} - async function installListCommandsStub(page: Page): Promise { await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); @@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => { timeout: 30_000, }); - await page.getByRole("button", { name: "Open menu" }).click(); + // The sidebar covers the composer, so the composer gives up its popover while the + // sidebar is open and brings it back once the sidebar closes. + await openMobileAgentSidebar(page); await expectMobileAgentSidebarVisible(page); + await expect(popover).toHaveCount(0); - const popoverBox = await popover.boundingBox(); - expect(popoverBox).not.toBeNull(); - - const topTestId = await getTopTestIdAtPoint( - page, - popoverBox!.x + popoverBox!.width / 2, - popoverBox!.y + popoverBox!.height / 2, - ); - - expect(topTestId).not.toBe("composer-autocomplete-popover"); + await closeMobileAgentSidebar(page); + await expectMobileAgentSidebarHidden(page); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible(); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 455c58180a..00705faccd 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -1,7 +1,14 @@ +import type { Locator } from "@playwright/test"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { gateNextAgentMessage } from "../support/helpers/agent-message-gate"; +import { + fillComposerDraft, + expectComposerDraft, + attachFileFromMenu, +} from "../support/helpers/composer"; import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs"; const APP_SETTINGS_KEY = "@paseo:app-settings"; @@ -26,14 +33,19 @@ async function openComposerDiff(page: Page) { await composerChangesPill(page).click(); } -async function seedChangedAgent(repoPrefix: string) { +async function seedChangedAgent( + repoPrefix: string, + featureValues?: Record, + files: Array<{ path: string; content: string }> = [], +) { const workspace = await seedMockAgentWorkspace({ repoPrefix, + featureValues, title: "Composer diff stat", repo: { withRemote: true, // Exclude the local remote before the daemon starts observing this checkout. - files: [{ path: ".gitignore", content: "/remote.git/\n" }], + files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files], }, }); try { @@ -156,3 +168,691 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan await workspace.cleanup(); } }); + +async function tapFirstAddedDiffLine( + page: Page, + surface: Locator = page.getByTestId("working-diff-panel"), +) { + const body = surface.getByTestId("diff-file-0-body"); + await expect(body).toBeVisible({ timeout: 30000 }); + const bounds = await body.boundingBox(); + if (!bounds) throw new Error("Expanded diff body has no bounds"); + const fontSize = await surface + .getByTestId("git-diff-canvas") + .evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)); + await page.touchscreen.tap(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5); +} + +async function openReviewDiff(page: Page, workspace: Awaited>) { + await page.setViewportSize({ width: 1400, height: 900 }); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId }); + await expect(composerChangesPill(page)).toBeVisible({ timeout: 30000 }); + if (await page.getByTestId("workspace-tab-working_diff").count()) { + await selectDiffTab(page); + } else { + await revealComposerChangesInExplorer(page); + await openComposerDiff(page); + } + await expect(page.getByTestId("diff-file-0-body")).toBeVisible(); +} + +async function saveReviewComment(page: Page, body: string) { + await page.getByRole("textbox", { name: "Review comment" }).fill(body); + await page.getByRole("button", { name: "Save review comment", exact: true }).click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); +} + +async function cancelReviewComment(page: Page) { + await page.getByRole("button", { name: "Cancel review comment", exact: true }).click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); +} + +test.describe("review comments", () => { + test.use({ hasTouch: true }); + + test("compact review comments open a sheet and preserve saved comments through save, edit, cancel and reload", async ({ + page, + }) => { + const workspace = await seedChangedAgent("compact-review-comment-"); + try { + await test.step("Open a compact sheet with reachable input and actions", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await tapFirstAddedDiffLine(page); + await expectCompactCommentSheet(page); + }); + await test.step("Save, discard an edit, then save an updated comment", async () => { + await saveReviewComment(page, " Please simplify this. "); + await expectSavedReview(page, "Please simplify this."); + await editSavedReview(page, "Please simplify this."); + await fillReviewComment(page, "Discard this edit"); + await cancelReviewComment(page); + await expectSavedReview(page, "Please simplify this."); + await editSavedReview(page, "Please simplify this."); + await saveReviewComment(page, "Updated comment"); + await expectSavedReview(page, "Updated comment"); + }); + await test.step("Discard a new comment and retain the saved one after reload", async () => { + await tapFirstAddedDiffLine(page); + await fillReviewComment(page, "Discard new comment"); + await cancelReviewComment(page); + await expectReviewAbsent(page, "Discard new comment"); + await reloadReview(page); + await expectSavedReview(page, "Updated comment"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("wide review comments retain inline editing", async ({ page }) => { + const workspace = await seedChangedAgent("wide-review-comment-"); + try { + await test.step("Save and cancel an inline edit on a wide screen", async () => { + await openReviewDiff(page, workspace); + await tapFirstAddedDiffLine(page); + await expectInlineCommentEditor(page); + await saveReviewComment(page, "Wide comment"); + await expectSavedReview(page, "Wide comment"); + await editSavedReview(page, "Wide comment"); + await expectInlineCommentEditor(page); + await cancelReviewComment(page); + await expectSavedReview(page, "Wide comment"); + }); + await test.step("The existing composer still sends the review", async () => { + await sendReviewThroughComposer(page, workspace.agentId); + await selectDiffTab(page); + await expectReviewAbsent(page, "Wide comment"); + }); + } finally { + await workspace.cleanup(); + } + }); +}); + +async function sendReviewThroughComposer(page: Page, agentId: string) { + await page.getByTestId(`workspace-tab-agent_${agentId}`).click(); + await expect(page.getByTestId("composer-review-attachment-pill")).toBeVisible(); + await page.getByRole("textbox", { name: "Message agent..." }).fill("Address this review"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect(page.getByText("Address this review", { exact: true }).first()).toBeVisible(); + await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0); +} + +async function addSavedReview( + page: Page, + body: string, + surface: Locator = page.getByTestId("working-diff-panel"), +) { + await tapFirstAddedDiffLine(page, surface); + await saveReviewComment(page, body); + await expectSavedReview(page, body, surface); +} + +async function expectSavedReview( + page: Page, + body: string, + surface: Locator = page.getByTestId("working-diff-panel"), +) { + await expect(surface.getByText(body, { exact: true })).toBeVisible(); +} + +async function editSavedReview(page: Page, expectedBody: string) { + await page + .getByTestId("working-diff-panel") + .getByRole("button", { name: "Edit review comment", exact: true }) + .click(); + await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(expectedBody); +} + +async function sendFeedback(page: Page, count: number) { + await page.getByRole("button", { name: `Send feedback (${count})`, exact: true }).click(); +} + +async function expectFeedbackSent(page: Page, recipient: string) { + await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible(); + await expect(page.getByTestId("working-diff-panel")).toBeVisible(); +} + +test.describe("send review feedback", () => { + test.use({ hasTouch: true }); + + test("compact feedback sends directly to the only workspace agent and preserves its composer draft", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-feedback-single-"); + try { + await test.step("Save a review without changing the chat draft", async () => { + await openReviewDiff(page, workspace); + await selectAgentTab(page, workspace.agentId); + await fillComposerDraft(page, "Keep this thought"); + await attachFileFromMenu(page, { + name: "unrelated.txt", + mimeType: "text/plain", + buffer: Buffer.from("Unrelated draft attachment"), + }); + await selectDiffTab(page); + await showCompactLayout(page); + await addSavedReview(page, "Please simplify this."); + }); + await test.step("Send directly and stay on the diff", async () => { + await sendFeedback(page, 1); + await expectFeedbackSent(page, "Composer diff stat"); + await expectReviewAbsent(page, "Please simplify this."); + await captureFeedback(page, "single"); + }); + await test.step("Return to the untouched composer draft", async () => { + await showWideLayout(page); + await selectAgentTab(page, workspace.agentId); + await expectComposerDraft(page, "Keep this thought"); + await expect(page.getByText("unrelated.txt", { exact: true })).toBeVisible(); + await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0); + }); + } finally { + await workspace.cleanup(); + } + }); + test("feedback pending ownership follows the draft across Diff and compact Changes", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-shared-pending-"); + const gate = await gateNextAgentMessage(page); + try { + await test.step("Send feedback from the Diff tab and hold its real request", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await addSavedReview(page, "One draft across surfaces"); + await sendFeedback(page, 1); + await gate.waitForRequest(); + }); + await test.step("The compact Changes surface observes the same pending operation", async () => { + await revealCompactChanges(page); + await expect( + compactReviewSurface(page).getByRole("button", { + name: "Sending feedback (1)", + exact: true, + }), + ).toBeDisabled(); + expect(gate.requestCount()).toBe(1); + gate.accept(); + await expect( + compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", { + exact: true, + }), + ).toBeVisible(); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("an unsaved review survives presentation changes and acknowledgement before Save", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-editor-ownership-"); + const gate = await gateNextAgentMessage(page); + try { + await test.step("Carry unsaved inline text into the compact sheet and back", async () => { + await openReviewDiff(page, workspace); + await tapFirstAddedDiffLine(page); + await fillReviewComment(page, "Body across presentations"); + await showCompactLayout(page); + await expectReviewEditorBody(page, "Body across presentations"); + await showWideLayout(page); + await expectReviewEditorBody(page, "Body across presentations"); + await saveReviewComment(page, "Body across presentations"); + }); + await test.step("Keep an open edit saveable after acknowledgement removes its original", async () => { + await sendFeedback(page, 1); + await gate.waitForRequest(); + await editSavedReview(page, "Body across presentations"); + await fillReviewComment(page, "Keep this unsaved edit"); + gate.accept(); + await expectFeedbackSent(page, "Composer diff stat"); + await expectReviewEditorBody(page, "Keep this unsaved edit"); + await showCompactLayout(page); + await expectReviewEditorBody(page, "Keep this unsaved edit"); + await saveReviewComment(page, "Keep this unsaved edit"); + await expectSavedReview(page, "Keep this unsaved edit"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("saved feedback preserves navigation across multiple diff files", async ({ page }) => { + const workspace = await seedMultiFileReview(); + try { + await test.step("Save compact feedback and retain both floating actions", async () => { + await openCompactReviewExplorer(page, workspace); + await addSavedReview(page, "Keep file navigation available", compactReviewSurface(page)); + await expectReviewNavigationReachable(page); + }); + await test.step("Jump to another file without covering the feedback action", async () => { + await jumpToReviewFile(page, "z-last.ts"); + await scrollReviewToLastLine(page); + await expectReviewNavigationReachable(page); + await captureFeedback(page, "navigation-compact"); + }); + await test.step("Wide file navigation and feedback remain reachable", async () => { + await openReviewDiff(page, workspace); + await navigateWideReviewFile(page, "README.md"); + await expectFeedbackReachable(page); + await expectSavedReview(page, "Keep file navigation available"); + await captureFeedback(page, "navigation-wide"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("feedback sends only matching comments and preserves orphaned comments", async ({ + page, + }) => { + const workspace = await seedMultiFileReview(); + try { + await test.step("A saved comment whose diff disappears has no sendable context", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await addSavedReview(page, "Retain this orphan"); + await setReadmeDiff(workspace, false); + await expectUnmatchedFeedback(page); + }); + await test.step("Send the matching subset with an accurate count", async () => { + await addSavedReview(page, "Send this matching comment"); + await sendFeedback(page, 1); + await expectFeedbackSent(page, "Composer diff stat"); + await expectReviewAbsent(page, "Send this matching comment"); + await expectUnmatchedFeedback(page); + }); + await test.step("Restoring the missing diff reveals the unsent comment", async () => { + await setReadmeDiff(workspace, true); + await expectSavedReview(page, "Retain this orphan"); + await expectFeedbackReachable(page); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("zero open agent tabs retain feedback with a disabled reason", async ({ page }) => { + const workspace = await seedChangedAgent("review-feedback-zero-"); + try { + await test.step("Close the agent tab while keeping the review", async () => { + await openReviewDiff(page, workspace); + await addSavedReview(page, "Keep until an agent is opened"); + await closeAgentTab(page, workspace.agentId); + }); + await test.step("The disabled action explains why in wide and compact layouts", async () => { + await expectFeedbackUnavailable(page); + await captureFeedback(page, "zero-wide"); + await showCompactLayout(page); + await expectFeedbackUnavailable(page); + await expectSavedReview(page, "Keep until an agent is opened"); + await captureFeedback(page, "zero-compact"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("only open tabs in this workspace appear in compact and wide recipient menus", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-feedback-multiple-"); + const other = await seedChangedAgent("review-feedback-other-"); + try { + const second = await createReviewRecipient(workspace, "Second reviewer"); + const historical = await createReviewRecipient(workspace, "Closed historical agent"); + await test.step("Open two local tabs and a tab in another workspace", async () => { + await openReviewDiff(page, other); + await addSavedReview(page, "Other workspace review"); + await openReviewDiff(page, workspace); + await closeAgentTab(page, historical.id); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: second.id }); + await selectDiffTab(page); + await addSavedReview(page, "Review for second agent"); + }); + await test.step("The wide menu includes only local open agents", async () => { + await captureWideFeedbackAppearance(page); + }); + await test.step("Escape returns keyboard focus to feedback so the chooser can reopen", async () => { + await openFeedbackWithKeyboard(page); + await dismissRecipientMenu(page); + await expectFeedbackFocused(page); + await reopenFeedbackWithKeyboard(page); + await dismissRecipientMenu(page); + await expectFeedbackFocused(page); + }); + await test.step("Choose the second agent in the compact menu and stay on the diff", async () => { + await showCompactLayout(page); + await captureFeedback(page, "cta-compact"); + await sendFeedback(page, 1); + await expectRecipientMenu(page); + await captureFeedback(page, "menu-compact"); + await chooseFeedbackRecipient(page, "Second reviewer"); + await expectFeedbackSent(page, "Second reviewer"); + await expectReviewAbsent(page, "Review for second agent"); + }); + await test.step("The other workspace's saved review remains", async () => { + await openReviewDiff(page, other); + await expectSavedReview(page, "Other workspace review"); + }); + } finally { + await workspace.cleanup(); + await other.cleanup(); + } + }); + + test("failed feedback remains editable and retries through the real provider", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-feedback-retry-", { mockPromptRejections: 1 }); + try { + await test.step("A provider rejection retains the saved review", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await addSavedReview(page, "Initial feedback"); + await sendFeedback(page, 1); + await expectFeedbackFailure(page); + await expectSavedReview(page, "Initial feedback"); + await captureFeedback(page, "failure"); + }); + await test.step("Edit the retained feedback and retry successfully", async () => { + await editSavedReview(page, "Initial feedback"); + await saveReviewComment(page, "Corrected feedback"); + await sendFeedback(page, 1); + await expectFeedbackSent(page, "Composer diff stat"); + await expectReviewAbsent(page, "Corrected feedback"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("pending feedback blocks repeat submission and preserves edits and additions", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-feedback-pending-"); + const gate = await gateNextAgentMessage(page); + try { + await test.step("Hold the real request while feedback is pending", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await addSavedReview(page, "Sent version"); + await sendFeedback(page, 1); + const request = await gate.waitForRequest(); + expect(request.agentId).toBe(workspace.agentId); + expect(request.text).toBe("Please address this code review."); + expect(request.attachments).toMatchObject([ + { + type: "review", + cwd: workspace.cwd, + comments: [ + { + filePath: "README.md", + lineNumber: 2, + body: "Sent version", + context: { targetLine: { content: "export const one = 1;", newLineNumber: 2 } }, + }, + ], + }, + ]); + await expectFeedbackPending(page); + }); + await test.step("Save an edit and another comment before acknowledgement", async () => { + await editSavedReview(page, "Sent version"); + await saveReviewComment(page, "Edited during send"); + await addSavedReview(page, "Added during send"); + await expectFeedbackPending(page); + expect(gate.requestCount()).toBe(1); + await captureFeedback(page, "pending"); + }); + await test.step("A real acknowledgement preserves both new versions", async () => { + gate.accept(); + await expectFeedbackSent(page, "Composer diff stat"); + await expectSavedReview(page, "Edited during send"); + await expectSavedReview(page, "Added during send"); + await expectReviewAbsent(page, "Sent version"); + }); + } finally { + await workspace.cleanup(); + } + }); +}); + +async function showCompactLayout(page: Page) { + await page.setViewportSize({ width: 390, height: 844 }); +} +async function showWideLayout(page: Page) { + await page.setViewportSize({ width: 1400, height: 900 }); +} +async function selectAgentTab(page: Page, agentId: string) { + await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ position: { x: 12, y: 13 } }); +} +async function selectDiffTab(page: Page) { + await page.getByTestId("workspace-tab-working_diff").click({ position: { x: 12, y: 13 } }); +} +async function fillReviewComment(page: Page, body: string) { + await page.getByRole("textbox", { name: "Review comment" }).fill(body); +} +async function expectReviewAbsent(page: Page, body: string) { + await expect(page.getByTestId("working-diff-panel").getByText(body, { exact: true })).toHaveCount( + 0, + ); +} +async function reloadReview(page: Page) { + await page.reload(); + await expect(page.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30000 }); +} +async function expectInlineCommentEditor(page: Page) { + await expect(page.getByTestId("inline-review-editor")).toBeVisible(); + await expect(page.getByTestId("review-comment-sheet")).toHaveCount(0); +} +async function expectCompactCommentSheet(page: Page) { + await expect(page.getByTestId("review-comment-sheet")).toBeVisible(); + await expect(page.getByText("README.md · +2", { exact: true })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Review comment" })).toBeInViewport({ ratio: 1 }); + for (const action of ["Save", "Cancel"]) + await expect( + page.getByRole("button", { name: `${action} review comment`, exact: true }), + ).toBeInViewport({ ratio: 1 }); + await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Save review comment", exact: true }), + ).toBeDisabled(); +} +function feedbackButton(page: Page) { + return page.getByRole("button", { name: "Send feedback (1)", exact: true }); +} +async function openFeedbackWithKeyboard(page: Page) { + await feedbackButton(page).focus(); + await expectFeedbackFocused(page); + await reopenFeedbackWithKeyboard(page); +} +async function reopenFeedbackWithKeyboard(page: Page) { + await page.keyboard.press("Enter"); + await expectRecipientMenu(page); + const recipient = page.getByRole("menuitem", { name: "Composer diff stat", exact: true }); + await recipient.focus(); + await expect(recipient).toBeFocused(); +} +async function expectFeedbackFocused(page: Page) { + await expect(feedbackButton(page)).toBeFocused(); +} + +async function captureWideFeedbackAppearance(page: Page) { + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expectSavedReview(page, "Review for second agent"); + await captureFeedback(page, `cta-wide-${colorScheme}-closed`); + await sendFeedback(page, 1); + await expectRecipientMenu(page); + await captureFeedback(page, `cta-wide-${colorScheme}-open`); + await dismissRecipientMenu(page); + } + await page.emulateMedia({ colorScheme: "light" }); +} + +async function captureFeedback(page: Page, name: string) { + await page.screenshot({ path: `/tmp/phase2-feedback-${name}.png` }); +} + +async function closeAgentTab(page: Page, agentId: string) { + await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ button: "right" }); + await page.getByTestId(`workspace-tab-context-agent_${agentId}-close`).click(); + await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0); +} +async function expectFeedbackUnavailable(page: Page) { + await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeDisabled(); + await expect( + page.getByText("Open an agent tab in this workspace to send feedback.", { exact: true }), + ).toBeVisible(); +} +async function createReviewRecipient( + workspace: Awaited>, + title: string, +) { + return workspace.client.createAgent({ + provider: "mock", + cwd: workspace.cwd, + workspaceId: workspace.workspaceId, + title, + modeId: "load-test", + model: "e2e-fast-stream", + }); +} +async function expectRecipientMenu(page: Page) { + for (const name of ["Composer diff stat", "Second reviewer"]) { + await expect(page.getByRole("menuitem", { name, exact: true })).toBeInViewport({ ratio: 1 }); + } + await expect(page.getByRole("menuitem")).toHaveCount(2); +} +async function dismissRecipientMenu(page: Page) { + await page.keyboard.press("Escape"); + await expect(page.getByRole("menuitem")).toHaveCount(0); +} +async function chooseFeedbackRecipient(page: Page, name: string) { + await page.getByRole("menuitem", { name, exact: true }).click(); +} +async function expectFeedbackFailure(page: Page) { + await expect( + page.getByText("Requested mock prompt rejection", { exact: true }).first(), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeEnabled(); +} +async function expectFeedbackPending(page: Page, count = 1) { + await expect( + page.getByRole("button", { name: `Sending feedback (${count})`, exact: true }), + ).toBeDisabled(); +} + +async function seedMultiFileReview() { + const workspace = await seedChangedAgent("review-feedback-navigation-", undefined, [ + { path: "z-last.ts", content: "export const final = 0;\n" }, + ]); + await writeFile( + path.join(workspace.cwd, "z-last.ts"), + Array.from({ length: 120 }, (_, index) => `export const value${index} = ${index};`).join("\n") + + "\n", + ); + await workspace.client.checkoutRefresh(workspace.cwd); + return workspace; +} +async function expectFeedbackReachable(page: Page) { + await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeInViewport( + { ratio: 1 }, + ); +} +async function expectReviewNavigationReachable(page: Page) { + await expectFeedbackReachable(page); + const jump = page.getByRole("button", { name: "Jump to file", exact: true }); + await expect(jump).toBeInViewport({ ratio: 1 }); + const navigationBounds = await jump.boundingBox(); + const feedbackBounds = await page + .getByRole("button", { name: "Send feedback (1)", exact: true }) + .boundingBox(); + if (!navigationBounds || !feedbackBounds) throw new Error("Review actions have no bounds"); + expect(feedbackBounds.y + feedbackBounds.height).toBeLessThanOrEqual(navigationBounds.y); +} +async function jumpToReviewFile(page: Page, name: string) { + await page.getByRole("button", { name: "Jump to file", exact: true }).click(); + const sheet = page.getByTestId("changes-jump-to-file-sheet"); + await page + .getByTestId("changes-file-tree") + .filter({ visible: true }) + .getByText(name, { exact: true }) + .click(); + await expect(sheet).toHaveCount(0); + await expect(reviewFileHeader(compactReviewSurface(page), name)).toBeInViewport(); +} +async function navigateWideReviewFile(page: Page, name: string) { + const explorer = await ensureExplorerSidebar(page); + await explorer.getByTestId("changes-tree-panel").getByText(name, { exact: true }).click(); + await expect(reviewFileHeader(page.getByTestId("working-diff-panel"), name)).toBeInViewport(); +} + +async function setReadmeDiff( + workspace: Awaited>, + changed: boolean, +) { + await writeFile( + path.join(workspace.cwd, "README.md"), + changed ? "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n" : "# Temp Repo\n", + ); + await workspace.client.checkoutRefresh(workspace.cwd); +} +async function expectUnmatchedFeedback(page: Page) { + await expect(page.getByRole("button", { name: "Send feedback (0)", exact: true })).toBeDisabled(); + await expect( + page.getByText("Saved comments no longer match this diff.", { exact: true }), + ).toBeVisible(); +} + +function compactReviewSurface(page: Page) { + return page.getByTestId("explorer-content-area").filter({ visible: true }); +} +async function openCompactReviewExplorer( + page: Page, + workspace: Awaited>, +) { + await showWideLayout(page); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId }); + await selectAgentTab(page, workspace.agentId); + await showCompactLayout(page); + await composerChangesPill(page).click(); + await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible(); +} + +function reviewFileHeader(surface: Locator, name: string) { + return surface.locator(`[data-diff-header-path="${name}"]`).getByTestId(/^diff-file-\d+$/); +} + +async function scrollReviewToLastLine(page: Page) { + const surface = compactReviewSurface(page); + const bounds = await surface.boundingBox(); + if (!bounds) throw new Error("Compact review has no bounds"); + await page.mouse.move(bounds.x + 120, bounds.y + 200); + await page.mouse.wheel(0, 10000); + await expect + .poll(async () => { + const body = await surface.getByTestId("diff-file-1-body").boundingBox(); + const feedback = await page + .getByRole("button", { name: "Send feedback (1)", exact: true }) + .boundingBox(); + if (!body || !feedback) return false; + return body.y + body.height <= feedback.y; + }) + .toBe(true); +} + +async function revealCompactChanges(page: Page) { + await page.getByTestId("workspace-explorer-toggle").first().click(); + await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click(); + await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible(); +} +async function expectReviewEditorBody(page: Page, body: string) { + await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue( + body, + ); +} diff --git a/packages/app/e2e/browser/composer-email-mentions.spec.ts b/packages/app/e2e/browser/composer-email-mentions.spec.ts new file mode 100644 index 0000000000..283fca26ca --- /dev/null +++ b/packages/app/e2e/browser/composer-email-mentions.spec.ts @@ -0,0 +1,104 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { + composerLocator, + expectComposerDraft, + expectComposerVisible, + fillComposerDraft, + enterComposerFullscreen, + exitComposerFullscreen, + resizeComposerViewport, + selectComposerDraftRange, +} from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +// The popover has no semantic role; its rows are React Native Pressables. +function fileSuggestions(page: Page) { + return page.getByTestId("composer-autocomplete-popover"); +} + +async function selectReadmeMention(page: Page): Promise { + await fillComposerDraft(page, "@README"); + await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible(); + await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, '"README.md"'); +} + +async function sendEmailIntact(page: Page, email: string): Promise { + await fillComposerDraft(page, email); + await expect(fileSuggestions(page)).not.toBeVisible(); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, ""); + await expect(page.getByText(email, { exact: true })).toBeVisible(); +} + +for (const email of [ + "user <12345+user@noreply.example.com>", + "alice@example.com", + "noreply+@example.com", +]) { + test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-email-", + title: "Email mention regression", + repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] }, + }); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await test.step("complete a workspace file mention with Enter", async () => { + await selectReadmeMention(page); + }); + await test.step("send an email without offering or inserting workspace files", async () => { + await sendEmailIntact(page, email); + }); + } finally { + await agent.cleanup(); + } + }); +} + +test("fullscreen mention keys edit the draft while inline keys still complete files", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-mention-", + title: "Fullscreen mention keys", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const draft = "First line\nSecond line\n@README"; + await fillComposerDraft(page, draft); + await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible(); + await enterComposerFullscreen(page); + await expect(fileSuggestions(page)).not.toBeVisible(); + await selectComposerDraftRange(page, draft.length, draft.length); + await composerLocator(page).press("ArrowUp"); + await expect + .poll(() => + composerLocator(page).evaluate((element) => { + const input = element as HTMLTextAreaElement; + return input.selectionStart === input.selectionEnd && input.selectionStart < 23; + }), + ) + .toBe(true); + await selectComposerDraftRange(page, draft.length, draft.length); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, `${draft}\n`); + await exitComposerFullscreen(page); + await selectReadmeMention(page); + const commandDraft = "/cl\nSecond line\nThird line"; + await fillComposerDraft(page, commandDraft); + await selectComposerDraftRange(page, 2, 2); + await composerLocator(page).press("ArrowRight"); + await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible(); + await enterComposerFullscreen(page); + await expect(fileSuggestions(page)).not.toBeVisible(); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, "/cl\n\nSecond line\nThird line"); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index b79cfd9255..cbc710a94a 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,13 +1,30 @@ import { expect, test, type Page } from "../support/fixtures"; import { + composerLocator, + blurComposerInput, + enterComposerFullscreen, + exitComposerFullscreen, expectComposerDraft, expectComposerFocused, + expectComposerFullscreenControls, expectComposerNotFocused, expectComposerVisible, + expectWorkspaceHeaderVisible, + resizeComposerViewport, submitMessage, typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + closeMobileAgentSidebar, + closeMobileExplorer, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + expectMobileExplorerHidden, + expectMobileExplorerVisible, + swipeOpenMobileAgentSidebar, + swipeOpenMobileExplorer, +} from "../support/helpers/sidebar"; test("submitting a message leaves the composer ready for the next message", async ({ page }) => { const agent = await seedMockAgentWorkspace({ @@ -76,3 +93,76 @@ test.describe("touch screen wide enough for the desktop layout", () => { } }); }); + +const COMPACT_PANELS = [ + { + name: "agent list", + open: swipeOpenMobileAgentSidebar, + expectOpen: expectMobileAgentSidebarVisible, + close: closeMobileAgentSidebar, + expectClosed: expectMobileAgentSidebarHidden, + }, + { + name: "Explorer", + open: swipeOpenMobileExplorer, + expectOpen: expectMobileExplorerVisible, + close: closeMobileExplorer, + expectClosed: expectMobileExplorerHidden, + }, +] as const; + +for (const panel of COMPACT_PANELS) { + test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-panel-", + title: `Composer focus under ${panel.name}`, + }); + const draft = "Line one\nLine two\nLine three"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + + await test.step("an untouched editor stays untouched", async () => { + await expectComposerNotFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + }); + + await test.step("an inline editor blurred under the panel stays blurred", async () => { + await composerLocator(page).fill(draft); + await composerLocator(page).click(); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerDraft(page, draft); + }); + + await test.step("a fullscreen editor blurred under the panel stays blurred", async () => { + await composerLocator(page).click(); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerFullscreenControls(page); + await expectComposerDraft(page, draft); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + }); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 024446967f..ca8704ed25 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -1,3 +1,5 @@ +import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { seedWorkspace } from "../support/helpers/seed-client"; import { expect, test, type Page } from "../support/fixtures"; import { expectNearBottom, @@ -5,8 +7,38 @@ import { waitForScrollableChat, } from "../support/helpers/agent-bottom-anchor"; import { awaitAssistantMessage } from "../support/helpers/agent-stream"; -import { composerLocator, expectComposerVisible } from "../support/helpers/composer"; +import { + attachComposerTestImage, + pasteComposerTestImage, + enterComposerFullscreen, + exitComposerFullscreen, + resizeComposerViewport, + selectComposerDraftRange, + expectComposerDraftSelection, + expectComposerFullscreenControls, + sendComposerWithButton, + queueComposerWithButton, + configureComposerQueueSend, + activateRetainedComposerTab, + switchComposerTabWithKeyboard, + expectWorkspaceHeaderHidden, + expectWorkspaceHeaderVisible, + recordVisibleComposerGeometry, + expectNoCollapsedVisibleComposer, + expectAttachmentPill, + expectQueuedMessageButton, + startRunningMockAgent, + composerLocator, + expectComposerVisible, + expectComposerFullscreenFitsVisibleViewport, +} from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + closeMobileAgentSidebar, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + swipeOpenMobileAgentSidebar, +} from "../support/helpers/sidebar"; async function composerHeight(page: Page): Promise { return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height); @@ -133,3 +165,469 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } await agent.cleanup(); } }); + +/** Freeze the page's timers and animation frames until `page.clock.resume()`. */ +async function pausePageClock(page: Page): Promise { + const now = Date.now(); + await page.clock.install({ time: now }); + await page.clock.pauseAt(now); +} + +/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */ +async function expectFullscreenErrorAboveSend(page: Page): Promise { + const frame = page.getByTestId("composer-fullscreen"); + const alertBox = (await frame.getByRole("alert").boundingBox())!; + const sendBox = (await page + .getByRole("button", { name: "Send message", exact: true }) + .boundingBox())!; + const editorBox = (await composerLocator(page).boundingBox())!; + expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height); + expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y); +} + +async function writeCompactDraft(page: Page, text: string): Promise { + await composerLocator(page).fill(text); + await expect(composerLocator(page)).toHaveValue(text); +} + +test("compact long drafts stop growing before the header", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-cap-", + title: "Composer cap", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "A long draft line\n".repeat(40)); + await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160); + } finally { + await agent.cleanup(); + } +}); + +test("compact fullscreen editing preserves the live draft and selection and 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 test.step("the affordance appears after two rendered lines, including soft wraps", async () => { + await attachComposerTestImage(page); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await writeCompactDraft(page, "First line\nSecond line"); + await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0); + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible(); + await writeCompactDraft( + page, + "A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat( + 4, + ), + ); + await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible(); + }); + await test.step("fullscreen covers the header and keeps the live draft and selection", async () => { + await selectComposerDraftRange(page, 4, 17); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectWorkspaceHeaderHidden(page); + await expectComposerDraftSelection(page, 4, 17); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line"); + await selectComposerDraftRange(page, 3, 12); + }); + await test.step("exit restores the inline composer with the fullscreen edits", async () => { + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + await expect(composerLocator(page)).toHaveValue( + "Edited fullscreen draft\nSecond line\nThird line", + ); + await expectComposerDraftSelection(page, 3, 12); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await expect(composerLocator(page)).toBeFocused(); + }); + await test.step("sending inline after a fullscreen edit clears the visible editor", async () => { + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await writeCompactDraft(page, "Typed after the fullscreen cycle"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + }); + await test.step("sending inside fullscreen clears the editor and keeps fullscreen open", async () => { + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await writeCompactDraft(page, "Short draft"); + await expectComposerFullscreenControls(page); + await selectComposerDraftRange(page, 0, 5); + await resizeComposerViewport(page, "landscape"); + await expectComposerFullscreenControls(page); + await expectComposerDraftSelection(page, 0, 5); + await expect(composerLocator(page)).toHaveValue("Short draft"); + await expect(composerLocator(page)).toBeFocused(); + await resizeComposerViewport(page, "portrait"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expectComposerFullscreenControls(page); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); + }); + await test.step("typing and sending keep working after leaving fullscreen again", async () => { + await writeCompactDraft(page, "Typed inside fullscreen"); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen"); + await writeCompactDraft(page, "Typed inside fullscreen, finished inline"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await writeCompactDraft(page, "One more inline message"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + }); + } finally { + await agent.cleanup(); + } +}); + +test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-remount-caret-", + title: "Remount caret", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "Short draft"); + await selectComposerDraftRange(page, 5, 5); + // The draft owner publishes web edits after the next paint. A rotation inside that + // window remounts the editor before the edit has reached the draft store; the paused + // clock holds the page there instead of racing the frame. + await pausePageClock(page); + await composerLocator(page).press("X"); + await resizeComposerViewport(page, "landscape"); + await page.clock.resume(); + await expect(composerLocator(page)).toHaveValue("ShortX draft"); + await expectComposerDraftSelection(page, 6, 6); + await expect(composerLocator(page)).toBeFocused(); + await composerLocator(page).press("Y"); + await expect(composerLocator(page)).toHaveValue("ShortXY draft"); + } finally { + await agent.cleanup(); + } +}); + +test("fullscreen submit rejection retains the editable draft and supports retry", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-retry-", + title: "Fullscreen retry", + featureValues: { mockPromptRejections: 1 }, + }); + const draft = "First line\nSecond line\nThird line"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, draft); + await enterComposerFullscreen(page); + await sendComposerWithButton(page); + await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText( + "Requested mock prompt rejection", + ); + await expectFullscreenErrorAboveSend(page); + await expect(composerLocator(page)).toHaveValue(draft); + await expect(composerLocator(page)).toBeEditable(); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expectComposerFullscreenControls(page); + } finally { + await agent.cleanup(); + } +}); + +async function blurComposer(page: Page): Promise { + await composerLocator(page).blur(); + await expect(composerLocator(page)).not.toBeFocused(); +} + +/** Mid-band at the given height and near the editor's bottom edge. */ +async function reservedBandPoints( + page: Page, + bandX: number, + topY: number, +): Promise> { + const editor = (await composerLocator(page).boundingBox())!; + return [ + { x: bandX, y: topY }, + { x: bandX, y: editor.y + editor.height - 6 }, + ]; +} + +test("the reserved band beside the inline editor focuses it", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-band-focus-", + title: "Composer band focus", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true }); + const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true }); + const longDraft = "Band first line\nBand second line\nBand third line"; + await writeCompactDraft(page, longDraft); + await expect(fullscreenButton).toBeVisible(); + const button = (await fullscreenButton.boundingBox())!; + const bandX = button.x + button.width / 2; + + await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => { + for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) { + await blurComposer(page); + await page.mouse.click(point.x, point.y); + await expect(composerLocator(page)).toBeFocused(); + await expect(composerLocator(page)).toHaveValue(longDraft); + await expect(exitButton).toHaveCount(0); + } + }); + + await test.step("the button itself still toggles fullscreen", async () => { + await fullscreenButton.click(); + await expectComposerFullscreenControls(page); + await exitComposerFullscreen(page); + await expect(exitButton).toHaveCount(0); + await expect(composerLocator(page)).toHaveValue(longDraft); + }); + + await test.step("with no fullscreen button, the whole band still focuses the editor", async () => { + await writeCompactDraft(page, "Short"); + await expect(fullscreenButton).toHaveCount(0); + const editor = (await composerLocator(page).boundingBox())!; + for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) { + await blurComposer(page); + await page.mouse.click(point.x, point.y); + await expect(composerLocator(page)).toBeFocused(); + await expect(composerLocator(page)).toHaveValue("Short"); + await expect(exitButton).toHaveCount(0); + } + }); + } finally { + await agent.cleanup(); + } +}); + +test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({ + page, +}) => { + await configureComposerQueueSend(page); + await resizeComposerViewport(page, "portrait"); + const agent = await startRunningMockAgent(page, { + prefix: "composer-fullscreen-queue-", + model: "one-minute-stream", + prompt: "Stay running for fullscreen queue test.", + }); + try { + await writeCompactDraft(page, "Queued first line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await queueComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await 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 expectWorkspaceHeaderHidden(page); + await selectComposerDraftRange(page, 4, 17); + // Fullscreen covers the compact header, so a phone leaves the tab by exiting first; + // a desktop-width window still has the tab shortcut. + await resizeComposerViewport(page, "landscape"); + await switchComposerTabWithKeyboard(page, "previous"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + // The exit action belongs to the active pane: Escape here leaves the hidden tab alone. + await composerLocator(page).press("Escape"); + await switchComposerTabWithKeyboard(page, "next"); + await expectComposerFullscreenControls(page); + await switchComposerTabWithKeyboard(page, "previous"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await resizeComposerViewport(page, "portrait"); + await expectWorkspaceHeaderVisible(page); + await writeCompactDraft(page, "Other tab draft"); + await shrinkVisibleViewport(page); + await activateRetainedComposerTab(page, second.title); + await expectComposerFullscreenControls(page); + await expectWorkspaceHeaderHidden(page); + await expect(composerLocator(page)).toHaveValue(draft); + await expectComposerFullscreenFitsVisibleViewport(page); + await expectComposerDraftSelection(page, 4, 17); + await expect(composerLocator(page)).toBeFocused(); + expect(await pasteComposerTestImage(page)).toBe(true); + await exitComposerFullscreen(page); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + } finally { + await workspace.cleanup(); + } +}); + +test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-sidebar-", + title: "Fullscreen under sidebar", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + const draft = "Line one\nLine two\nLine three"; + await writeCompactDraft(page, draft); + await enterComposerFullscreen(page); + await expectWorkspaceHeaderHidden(page); + // Fullscreen hides the header's menu button; the edge swipe still opens the list over it. + await swipeOpenMobileAgentSidebar(page); + await expectMobileAgentSidebarVisible(page); + // The list covers the pane, so the editor's exit action is not armed. + await page.keyboard.press("Escape"); + await closeMobileAgentSidebar(page); + await expectMobileAgentSidebarHidden(page); + await expectComposerFullscreenControls(page); + await expect(composerLocator(page)).toHaveValue(draft); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + } finally { + await agent.cleanup(); + } +}); + +test("retained inline composers keep usable capacity through hidden tab geometry", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" }); + try { + const first = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "First capacity chat", + }); + const second = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Second capacity chat", + }); + await openWorkspaceWithAgents(page, [first, second]); + await resizeComposerViewport(page, "portrait"); + const draft = "Retained long line\n".repeat(40); + await writeCompactDraft(page, draft); + await recordVisibleComposerGeometry(page); + await activateRetainedComposerTab(page, first.title); + await writeCompactDraft(page, "Other visible draft"); + await activateRetainedComposerTab(page, second.title); + await expect(composerLocator(page)).toHaveValue(draft); + await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible(); + await expectNoCollapsedVisibleComposer(page); + } finally { + await workspace.cleanup(); + } +}); + +async function shrinkVisibleViewport(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 }); + await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422); + await cdp.detach(); +} + +async function panVisibleViewport(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: 4, y: 300 }], + }); + for (let y = 290; y >= 100; y -= 10) { + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: 4, y }], + }); + } + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0); + await cdp.detach(); +} + +test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-visible-viewport-", + title: "Visible viewport composer", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await expectComposerFullscreenFitsVisibleViewport(page); + await shrinkVisibleViewport(page); + await expectComposerFullscreenFitsVisibleViewport(page); + await panVisibleViewport(page); + await expectComposerFullscreenFitsVisibleViewport(page); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 7905d2c1cd..5d024de761 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -1,16 +1,21 @@ import type { Locator, Page } from "@playwright/test"; import { expect, test as base } from "../support/fixtures"; -import { expectComposerVisible } from "../support/helpers/composer"; +import { + composerLocator, + expectComposerVisible, + submitMessageWithButton, +} from "../support/helpers/composer"; import { type AgentUsageScript, closeContextWindowSheet, - contextWindowSheet, + contextWindowDetails, expectRefreshButtons, expiredLogin, gate, hoverContextWindowMeter, leaveContextWindowMeter, type MockAgentSession, + moveOntoContextWindowDetails, onPersonalLogin, onWorkLogin, openAgent, @@ -24,6 +29,7 @@ import { } from "../support/helpers/context-window"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item"; +import { installLoginUsage } from "../support/helpers/usage-login"; const test = base.extend<{ agent: MockAgentSession }>({ agent: async ({ page: _page }, provide) => { @@ -107,6 +113,66 @@ const DESKTOP = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/; +for (const layout of [ + { + name: "desktop", + viewport: DESKTOP, + open: hoverContextWindowMeter, + close: leaveContextWindowMeter, + }, + { + name: "compact", + viewport: COMPACT, + open: pressContextWindowMeter, + close: closeContextWindowSheet, + }, +]) { + test(`shows account usage before the first turn (${layout.name})`, async ({ page }) => { + test.setTimeout(180_000); + const source = await installLoginUsage({ + status: "available", + windows: [{ id: "session", label: "Session", usedPct: 31 }], + }); + try { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "context-window-empty-", + title: "Agent before its first turn", + }); + try { + await page.setViewportSize(layout.viewport); + await openAgent(page, agent); + + await test.step("the empty meter opens context details and account usage", async () => { + const details = await layout.open(page, "Context window: No context data"); + await expect(details.getByText("No context data", { exact: true })).toBeVisible(); + await expect(details.getByText("0% used", { exact: true })).toHaveCount(0); + await expect(details.getByText(/tokens$/)).toHaveCount(0); + const account = usageCard(details, "login-journey:account"); + await expect(account.getByText("Session", { exact: true })).toBeVisible(); + await expect(account.getByText("31%", { exact: true })).toBeVisible(); + await qaScreenshot(page, `context-without-data-${layout.name}`); + await layout.close(page); + }); + + await test.step("the first context reading replaces the empty state", async () => { + await submitMessageWithButton(page, "emit 32000 byte file agent stream payload"); + const details = await layout.open(page); + await expect(details.getByText("25% used", { exact: true })).toBeVisible(); + await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible(); + await expect(details.getByText("No context data", { exact: true })).toHaveCount(0); + await expect( + usageCard(details, "login-journey:account").getByText("31%", { exact: true }), + ).toBeVisible(); + }); + } finally { + await agent.cleanup(); + } + } finally { + await source.cleanup(); + } + }); +} + type OpenDetails = (page: Page) => Promise; type Shot = (state: string) => Promise; @@ -142,7 +208,6 @@ async function expectCardsToStreamIn( // Only the agent's login, never the host's default one, and no pins. await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0); await expectUnpinnableRows(details); - await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2); return details; } @@ -177,24 +242,49 @@ async function expectOnlyContextWindowWithoutUsage( } for (const theme of ["light", "dark"] as const) { - test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => { + test(`the context window hover card shows the agent's usage (${theme})`, async ({ + page, + agent, + }) => { test.setTimeout(240_000); const [claude] = claudeAndCodexReports(); const usage = await scriptAgentUsage(page); await page.emulateMedia({ colorScheme: theme }); await page.setViewportSize(DESKTOP); await openAgent(page, agent); - const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`); + const shot: Shot = (state) => qaScreenshot(page, `hover-card-desktop-${theme}-${state}`); - await test.step("reports stream in one card at a time, without Refresh", async () => { - const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot); - // The tooltip cannot be pressed, so its cards have no Refresh. - await expectRefreshButtons(tooltip, 0); + await test.step("reports stream in one card at a time, each with Refresh", async () => { + const card = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot); + await expectRefreshButtons(card, 2); await shot("ready"); expect(usage.agentRequests()).toEqual([agent.agentId]); }); + await test.step("the card stays open while the pointer moves onto it", async () => { + const card = contextWindowDetails(page); + await moveOntoContextWindowDetails(page, card); + await page.waitForTimeout(300); + await expect(card).toBeVisible(); + }); + + await test.step("Refresh keeps the card open and replaces it with the new report", async () => { + const refreshed = gate(); + usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] }); + const card = contextWindowDetails(page); + await refreshUsageCard(card, "Claude"); + await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled(); + // Past the card's close grace, with the pointer still on the card. + await page.waitForTimeout(500); + await expect(card).toBeVisible(); + refreshed.open(); + await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); + }); + await test.step("after a resume under another login, only that login shows", async () => { + // Refresh can retain focus; return to the composer before testing pointer dismissal. + await composerLocator(page).focus(); // Closed while the old login's report is still on its way. await leaveContextWindowMeter(page); const slowSource = gate(); @@ -208,25 +298,25 @@ for (const theme of ["light", "dark"] as const) { usage.answerNext({ stream: [resumed.promise, onPersonalLogin(claude!), finished.promise], }); - const tooltip = await hoverContextWindowMeter(page); - await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible(); - await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0); + const card = await hoverContextWindowMeter(page); + await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible(); + await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0); // The old login's report lands during the new request, before the new login's. slowSource.open(); resumed.open(); await expect( - usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }), + usageCard(card, "claude:personal").getByText("personal@example.com", { exact: true }), ).toBeVisible(); - await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0); + await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0); finished.open(); }); await test.step("a report with a problem shows it on the card", async () => { usage.answerNext([expiredLogin(onWorkLogin(claude!))]); await reloadAgent(page); - const tooltip = await hoverContextWindowMeter(page); - await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible(); + const card = await hoverContextWindowMeter(page); + await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); await shot("problem"); }); @@ -237,7 +327,7 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot); // One request per open that had usage; this one sends none. - expect(usage.agentRequests()).toHaveLength(5); + expect(usage.agentRequests()).toHaveLength(6); }); }); @@ -256,16 +346,18 @@ for (const theme of ["light", "dark"] as const) { await test.step("reports stream in one card at a time, each with Refresh", async () => { const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot); await expectRefreshButtons(sheet, 2); + // Without hover, each card prints its freshness instead of a Refresh tooltip. + await expect(sheet.getByText("Updated just now", { exact: true })).toHaveCount(2); await shot("ready"); expect(usage.agentRequests()).toEqual([agent.agentId]); }); await test.step("Refresh replaces the card with the source's new report", async () => { usage.answerNext([expiredLogin(onWorkLogin(claude!))]); - const sheet = contextWindowSheet(page); + const sheet = contextWindowDetails(page); await refreshUsageCard(sheet, "Claude"); await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedReports()).toEqual([["claude:work"]]); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); await shot("problem"); }); @@ -279,7 +371,85 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); - expect(usage.agentRequests()).toHaveLength(2); + expect(usage.agentRequests()).toHaveLength(3); }); }); } + +test("context details support keyboard entry and dismiss when the viewport moves", async ({ + page, + agent, +}) => { + const usage = await scriptAgentUsage(page); + await page.setViewportSize(DESKTOP); + await openAgent(page, agent); + await submitMessageWithButton(page, "withhold synthetic user message until interrupted"); + const stop = page.getByRole("button", { name: /stop|cancel/i }).first(); + await expect(stop).toBeVisible(); + const [claude] = claudeAndCodexReports(); + usage.answerNext([onPersonalLogin(claude!)]); + const meter = page.getByTestId("context-window-meter"); + const trigger = page.getByRole("button", { + name: (await meter.getAttribute("aria-label"))!, + exact: true, + }); + await expect(trigger).toHaveAttribute("tabindex", "0"); + await trigger.focus(); + const details = contextWindowDetails(page); + await expect(details).toBeVisible(); + await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeVisible(); + await trigger.press("ArrowDown"); + await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(details).toHaveCount(0); + await expect(stop).toBeVisible(); + await expect(trigger).toBeFocused(); + for (const key of ["Enter", " "]) { + usage.answerNext([onPersonalLogin(claude!)]); + await trigger.press(key); + await expect(details).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(details).toHaveCount(0); + await expect(stop).toBeVisible(); + await expect(trigger).toBeFocused(); + } + usage.answerNext([onPersonalLogin(claude!)]); + await trigger.press("ArrowDown"); + await expect(details).toBeVisible(); + await page.setViewportSize({ width: 1280, height: 850 }); + await expect(details).toHaveCount(0); +}); + +test("pointer hovering context details preserves composer focus", async ({ page, agent }) => { + const usage = await scriptAgentUsage(page); + await page.setViewportSize(DESKTOP); + await openAgent(page, agent); + const [claude] = claudeAndCodexReports(); + usage.answerNext([onPersonalLogin(claude!)]); + const input = composerLocator(page); + await input.focus(); + await hoverContextWindowMeter(page); + await expect(input).toBeFocused(); + await leaveContextWindowMeter(page); + await expect(input).toBeFocused(); +}); + +test("Find remains available after dismissing context details", async ({ page, agent }) => { + const usage = await scriptAgentUsage(page); + await page.setViewportSize(DESKTOP); + await openAgent(page, agent); + const [claude] = claudeAndCodexReports(); + usage.answerNext([onPersonalLogin(claude!)]); + const meter = page.getByTestId("context-window-meter"); + const trigger = page.getByRole("button", { + name: (await meter.getAttribute("aria-label"))!, + exact: true, + }); + await trigger.focus(); + await expect(contextWindowDetails(page)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(contextWindowDetails(page)).toHaveCount(0); + await expect(trigger).toBeFocused(); + await page.keyboard.press("ControlOrMeta+f"); + await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused(); +}); diff --git a/packages/app/e2e/browser/creation-idempotency.spec.ts b/packages/app/e2e/browser/creation-idempotency.spec.ts index 39e73dbded..7658addf72 100644 --- a/packages/app/e2e/browser/creation-idempotency.spec.ts +++ b/packages/app/e2e/browser/creation-idempotency.spec.ts @@ -128,3 +128,46 @@ for (const scenario of [ await creation.expectAgentCount(1); }); } + +for (const entry of [ + { + name: "agent draft", + open: (creation: Awaited>) => + creation.openAgentDraft(), + button: "Send message", + settled: async (_creation: Awaited>) => {}, + }, + { + name: "new workspace", + open: (creation: Awaited>) => + creation.openWorkspaceForm("local"), + button: "Create", + settled: (creation: Awaited>) => + creation.expectOneCreatedWorkspace(), + }, +]) { + test(`${entry.name} becomes an agent tab when the first prompt is rejected`, async ({ + creation, + promptRejection, + page, + }, testInfo) => { + void promptRejection; + const prompt = + "Continue the attached conversation.\n" + "Earlier conversation context.\n".repeat(100); + await entry.open(creation); + await creation.submitPrompt(prompt, entry.button); + await entry.settled(creation); + await creation.expectCreatedAgentError(); + await creation.expectPromptBeforeError(prompt); + await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") }); + await creation.reloadAgent(); + await creation.expectPromptBeforeError(prompt); + await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt."); + await creation.expectPromptVisible( + "emit 1 coalesced agent stream updates for a corrected prompt.", + ); + await creation.expectAssistantReply(); + await creation.expectAgentCount(1); + await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0); + }); +} diff --git a/packages/app/e2e/browser/creation-old-daemon.spec.ts b/packages/app/e2e/browser/creation-old-daemon.spec.ts index ff28d513ef..4b418b304a 100644 --- a/packages/app/e2e/browser/creation-old-daemon.spec.ts +++ b/packages/app/e2e/browser/creation-old-daemon.spec.ts @@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) { ); expect(workspaces).toHaveLength(1); gate.release(); + await gate.waitForCreated(); await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1); const first = (await client.fetchAgents()).entries[0]!.agent; await client.waitForFinish(first.id, 20_000); @@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) { const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates"; await fillComposerDraft(page, secondPrompt); await pressSubmitBeforeTheNextRender(page, "Send message"); + await gate.waitForCreated(); await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2); const second = (await client.fetchAgents()).entries.find( ({ agent }) => agent.id !== first.id, @@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) { async function holdLegacyAgentCreation(page: Page, port: number) { const readers = await loadSessionMessageReaders(); const arrived = Promise.withResolvers(); + let created = Promise.withResolvers(); + let createRequestId: string | undefined; let release = () => {}; let holding = true; await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => { const server = browser.connectToServer(); browser.onMessage((frame) => { - if (holding && readers.client(frame)?.type === "create_agent_request") { + const message = readers.client(frame); + if (message?.type === "create_agent_request") createRequestId = message.requestId; + if (holding && message?.type === "create_agent_request") { holding = false; release = () => server.send(frame); arrived.resolve(); } else server.send(frame); }); - server.onMessage((frame) => browser.send(frame)); + server.onMessage((frame) => { + const message = readers.server(frame); + if ( + message?.type === "status" && + message.payload.status === "agent_created" && + message.payload.requestId === createRequestId + ) { + created.resolve(); + } + browser.send(frame); + }); }); return { waitForRequest: () => arrived.promise, + async waitForCreated() { + // Agent-list membership includes initialization, before the initial prompt starts. + await created.promise; + created = Promise.withResolvers(); + }, release() { const send = release; release = () => {}; diff --git a/packages/app/e2e/browser/custom-model-removal.spec.ts b/packages/app/e2e/browser/custom-model-removal.spec.ts new file mode 100644 index 0000000000..dc6fd84616 --- /dev/null +++ b/packages/app/e2e/browser/custom-model-removal.spec.ts @@ -0,0 +1,29 @@ +import { test } from "../support/fixtures"; +import { + addCustomModel, + expectCustomModelRetained, + openCustomModels, + removeCustomModel, +} from "../support/helpers/custom-models"; + +const longModelId = "custom-model-with-a-long-name-".repeat(6); +const shortModelId = "custom-short"; + +for (const width of [1200, 390]) { + test(`removes a long custom model at ${width}px without removing another model`, async ({ + page, + }) => { + await page.setViewportSize({ width: 1200, height: 1000 }); + await openCustomModels(page); + await addCustomModel(page, shortModelId); + await addCustomModel(page, longModelId); + + await test.step("remove the long model at the target width", async () => { + await page.setViewportSize({ width, height: 1000 }); + await removeCustomModel(page, longModelId); + await expectCustomModelRetained(page, shortModelId); + }); + + await removeCustomModel(page, shortModelId); + }); +} diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 43e1e03863..7726c581f3 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { + expandFolder, openFileExplorer, openFileFromExplorer, expectFileTabOpen, @@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise { + const image = page.getByRole("img", { name, exact: true }); + await expect(image).toBeVisible(); + await expect + .poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth)) + .toBeGreaterThan(0); +} + function watchRequestsTo(page: Page, origin: string): string[] { const requests: string[] = []; page.on("request", (request) => { @@ -96,6 +105,64 @@ async function seedAgentWithFileLink(input: LinkedFile) { } test.describe("CodeMirror workspace file editing", () => { + test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "encoded-markdown-link-", + title: "Non-ASCII file link", + initialPrompt: "Show the report link", + featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" }, + }); + try { + await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true }); + await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n"); + await openAgentRoute(page, session); + await openAssistantMarkdownLink(page, "报告"); + await expectFileTabOpen(page, "docs/reports/开户赠金.md"); + await expect( + page + .getByTestId("workspace-file-pane") + .filter({ visible: true }) + .getByText("Report opened", { exact: true }), + ).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") }); + } finally { + await session.cleanup(); + } + }); + + for (const order of ["raw-first", "href-first"] as const) { + test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "percent-link-cache-", + title: "Distinct file links", + initialPrompt: "Show both filenames", + featureValues: { + mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).", + }, + }); + try { + await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n"); + await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n"); + await openAgentRoute(page, session); + const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"]; + for (const link of links) { + await returnToAssistant(page, session.agentId); + if (link === "raw") { + await openAssistantInlinePath(page, "literal%20.md"); + await expectFileTabOpen(page, "literal%20.md"); + await expectPreviewText(page, "Literal filename"); + } else { + await openAssistantMarkdownLink(page, "Decoded"); + await expectFileTabOpen(page, "literal .md"); + await expectPreviewText(page, "Decoded filename"); + } + } + } finally { + await session.cleanup(); + } + }); + } + test("jumps to each relative reference to the same file", async ({ page }) => { const session = await seedMockAgentWorkspace({ repoPrefix: "relative-reference-qa-", @@ -626,6 +693,40 @@ test.describe("CodeMirror workspace file editing", () => { await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource); }); + test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => { + test.setTimeout(90_000); + const workspace = await withWorkspace({ prefix: "file-md-relative-image-" }); + await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true }); + await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true }); + await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL); + await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL); + await writeFile( + path.join(workspace.repoPath, "docs", "guide.md"), + [ + "# Guide", + "", + "![sibling](./sibling.png)", + "", + "![hero](../data/cache/hero.png?v=2#preview)", + "", + ].join("\n"), + "utf8", + ); + await workspace.navigateTo(); + await openFileExplorer(page); + await expandFolder(page, "docs"); + await openFileFromExplorer(page, "guide.md"); + await expectFileTabOpen(page, "docs/guide.md"); + + const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); + await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible(); + await expectMarkdownImageLoaded(page, "sibling"); + await expectMarkdownImageLoaded(page, "hero"); + await visibleFilePane.screenshot({ + path: test.info().outputPath("markdown-relative-images.png"), + }); + }); + test("previews and refreshes an HTML plan while preserving source access", async ({ page, withWorkspace, @@ -757,3 +858,24 @@ test.describe("CodeMirror workspace file editing", () => { await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible(); }); }); + +async function openAssistantMarkdownLink(page: Page, name: string): Promise { + await page.getByRole("link", { name, exact: true }).first().click(); +} + +async function returnToAssistant(page: Page, agentId: string): Promise { + await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click(); +} + +async function openAssistantInlinePath(page: Page, token: string): Promise { + await page.getByText(token, { exact: true }).click(); +} + +async function expectPreviewText(page: Page, text: string): Promise { + await expect( + page + .getByTestId("workspace-file-pane") + .filter({ visible: true }) + .getByText(text, { exact: true }), + ).toBeVisible(); +} diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts index bbc73b797e..faf7285a62 100644 --- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, type Page } from "@playwright/test"; import { test } from "../support/fixtures"; -import { openFileExplorer } from "../support/helpers/file-explorer"; +import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer"; import { openChangesPanel } from "../support/helpers/workspace-tabs"; import { gotoWorkspace } from "../support/helpers/launcher"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; let workspace: SeededWorkspace; @@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) => await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0); await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0); }); + +test("downloads a file over direct TCP with its original filename and bytes", async ({ + page, +}, testInfo) => { + const fileName = "rapport données.bin"; + const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]); + await writeFile(path.join(workspace.workspaceDirectory, fileName), source); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const downloaded = await downloadFileFromExplorer(page, fileName); + const url = new URL(downloaded.url()); + expect(url.protocol).toBe("http:"); + expect(url.port).toBe(getE2EDaemonPort()); + expect(url.pathname).toBe("/api/files/download"); + expect(url.searchParams.get("token")).toBeTruthy(); + expect(downloaded.suggestedFilename()).toBe(fileName); + const savedFile = testInfo.outputPath(fileName); + await downloaded.saveAs(savedFile); + expect(await downloaded.failure()).toBeNull(); + expect(await readFile(savedFile)).toEqual(source); +}); diff --git a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts index d4444d6f22..22b2b8a6c5 100644 --- a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts +++ b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts @@ -6,6 +6,15 @@ import { selectWorkspaceIsolation, } from "../support/helpers/new-workspace"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + composerLocator, + expectComposerVisible, + fillComposerDraft, + enterComposerFullscreen, + resizeComposerViewport, + expectComposerFullscreenControls, +} from "../support/helpers/composer"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; type WebSocketMessage = string | Buffer; @@ -196,3 +205,52 @@ test.describe("New Workspace dictation submit", () => { } }); }); + +async function startDictationWithShortcut(page: Page): Promise { + await composerLocator(page).press("Control+d"); +} + +test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "fullscreen-dictation-", + title: "Fullscreen dictation shortcut", + }); + await installSyntheticMicrophone(page); + const harness = await installDictationFailureHarness(page); + const draft = "First line\nSecond line\nThird line"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await fillComposerDraft(page, draft); + await enterComposerFullscreen(page); + await resizeComposerViewport(page, "landscape"); + await expectComposerFullscreenControls(page); + await startDictationWithShortcut(page); + await harness.waitForAudio(); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Insert transcription", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Insert transcription and send", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Cancel dictation", exact: true }).click(); + await expect(composerLocator(page)).toBeEditable(); + await expect(composerLocator(page)).toHaveValue(draft); + await resizeComposerViewport(page, "portrait"); + await enterComposerFullscreen(page); + await resizeComposerViewport(page, "landscape"); + await composerLocator(page).press("Control+Shift+d"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await expect( + page.getByText("Realtime voice is disabled in this test.", { exact: true }), + ).toBeVisible(); + await expect(composerLocator(page)).toHaveValue(draft); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts index 16b277b537..d393966ee8 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -1,7 +1,18 @@ -import { expect, test } from "../support/fixtures"; +import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -import { openNewWorkspaceComposer } from "../support/helpers/new-workspace"; +import { + expectNewWorkspaceDraft, + fillNewWorkspaceDraft, + openNewWorkspaceComposer, +} from "../support/helpers/new-workspace"; +import { + composerLocator, + enterComposerFullscreen, + exitComposerFullscreen, + expectComposerFullscreenControls, + resizeComposerViewport, +} from "../support/helpers/composer"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { expectTerminalLaunchSelected, @@ -9,13 +20,12 @@ import { expectTerminalPreviewCommand, expectWorkspaceOpensWithTerminalTab, fillTerminalPrompt, - seedTerminalProfiles, + launchTrigger, selectLaunchOption, submitTerminalLaunch, terminalLaunchSubmit, terminalPromptInput, type TerminalProfile, - type TerminalProfileSeed, } from "../support/helpers/new-workspace-launch"; // Someone who wants a terminal agent instead of a chat: they pick a profile @@ -42,17 +52,18 @@ const BARE_PROFILE: TerminalProfile = { args: ["-c", "echo bare-launch-static-line; exec cat"], }; +test.use({ + e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } }, +}); + test.describe("New workspace: launching a terminal", () => { let workspace: SeededWorkspace; - let profileSeed: TerminalProfileSeed; test.beforeEach(async () => { workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" }); - profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]); }); test.afterEach(async () => { - await profileSeed.restore(); await workspace?.cleanup(); }); @@ -97,4 +108,165 @@ test.describe("New workspace: launching a terminal", () => { await expectTerminalOutputContains(page, "bare-launch-static-line"); }); }); + + test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({ + page, + }) => { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + await page.setViewportSize({ width: 390, height: 844 }); + const prompt = "First replacement line\nSecond replacement line\nThird replacement line"; + const selectProfile = async (profile: TerminalProfile) => { + await launchTrigger(page).click(); + const option = page.getByRole("menuitem").filter({ hasText: profile.name }); + await option.click(); + await expect(option).not.toBeVisible(); + }; + const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true }); + + await test.step("restore a multiline prompt after a read-only profile", async () => { + await selectProfile(PROMPT_PROFILE); + await fillTerminalPrompt(page, prompt); + await expect(fullscreen).toBeVisible(); + await selectProfile(BARE_PROFILE); + await expect(terminalPromptInput(page)).toHaveCount(0); + await selectProfile(PROMPT_PROFILE); + await expect(terminalPromptInput(page)).toHaveValue(prompt); + await expect(fullscreen).toBeVisible(); + }); + + await test.step("restore the locally edited prompt and measure subsequent typing", async () => { + await fillTerminalPrompt(page, "Short draft"); + await expect(fullscreen).toHaveCount(0); + await selectProfile(BARE_PROFILE); + await selectProfile(PROMPT_PROFILE); + await expect(terminalPromptInput(page)).toHaveValue("Short draft"); + await expect(fullscreen).toHaveCount(0); + await terminalPromptInput(page).press("End"); + await terminalPromptInput(page).press("Shift+Enter"); + await terminalPromptInput(page).pressSequentially("Second"); + await terminalPromptInput(page).press("Shift+Enter"); + await terminalPromptInput(page).pressSequentially("Third"); + await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird"); + await expect(fullscreen).toBeVisible(); + }); + }); +}); + +async function expectNewWorkspaceHeaderHidden(page: Page): Promise { + await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0); +} + +async function expectNewWorkspaceHeaderVisible(page: Page): Promise { + await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible(); +} + +/** + * Fullscreen fills the content area top to bottom and out to the right edge (a pinned + * sidebar keeps its column), covers the form, and shows only exit and the mode's submit. + */ +async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise { + const frame = page.getByTestId("composer-fullscreen"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible(); + await expect(frame.getByRole("button")).toHaveCount(2); + await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible(); + await expectNewWorkspaceHeaderHidden(page); + const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!; + await expect + .poll(() => + frame.evaluate( + (element, point) => { + const viewport = window.visualViewport!; + const box = element.getBoundingClientRect(); + const covering = document.elementFromPoint(point.x, point.y); + return { + fillsHeight: + Math.abs(box.top - viewport.offsetTop) < 1 && + Math.abs(box.height - viewport.height) < 1, + reachesRightEdge: + Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 && + box.left >= viewport.offsetLeft, + coversForm: element.contains(covering), + }; + }, + { x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 }, + ), + ) + .toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true }); +} + +test.describe("New workspace: fullscreen composer across the compact breakpoint", () => { + let workspace: SeededWorkspace; + + test.beforeEach(async () => { + workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" }); + }); + + test.afterEach(async () => { + await workspace?.cleanup(); + }); + + test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({ + page, + }) => { + test.setTimeout(90_000); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + const projectPicker = page.getByRole("button", { name: "Workspace project" }); + + await test.step("chat: fullscreen covers the centered form and exits back into it", async () => { + await resizeComposerViewport(page, "portrait"); + const draft = "Chat first line\nChat second line\nChat third line"; + await fillNewWorkspaceDraft(page, draft); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectNewWorkspaceHeaderHidden(page); + await resizeComposerViewport(page, "landscape"); + await expectComposerFullscreenControls(page); + await expectNewWorkspaceFullscreen(page, "Create"); + // The desktop form's own import button is dropped from the fullscreen slot. + await expect( + page.getByTestId("new-workspace-import-session").filter({ visible: true }), + ).toHaveCount(0); + await composerLocator(page).press("End"); + await composerLocator(page).pressSequentially(" wide"); + await exitComposerFullscreen(page); + await expectNewWorkspaceHeaderVisible(page); + await expect(projectPicker).toBeVisible(); + await expect( + page.getByTestId("new-workspace-import-session").filter({ visible: true }), + ).toBeVisible(); + await expectNewWorkspaceDraft(page, `${draft} wide`); + }); + + // Terminal mode and its prompt are screen state, and the shell remounts the screen + // at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone + // layout only. + await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => { + await resizeComposerViewport(page, "portrait"); + // The compact launch control opens a sheet, not the desktop menu. + await launchTrigger(page).click(); + const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name }); + await option.click(); + await expect(option).not.toBeVisible(); + await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); + const prompt = "Prompt first line\nPrompt second line\nPrompt third line"; + await fillTerminalPrompt(page, prompt); + await enterComposerFullscreen(page); + await expect(terminalPromptInput(page)).toBeVisible(); + await expectNewWorkspaceFullscreen(page, "Launch"); + await exitComposerFullscreen(page); + await expectNewWorkspaceHeaderVisible(page); + await expect(projectPicker).toBeVisible(); + await expect(terminalPromptInput(page)).toHaveValue(prompt); + }); + }); }); diff --git a/packages/app/e2e/browser/pair-device-relay.spec.ts b/packages/app/e2e/browser/pair-device-relay.spec.ts index 8c4e306a1e..dbd3814d38 100644 --- a/packages/app/e2e/browser/pair-device-relay.spec.ts +++ b/packages/app/e2e/browser/pair-device-relay.spec.ts @@ -1,6 +1,8 @@ import { test } from "../support/fixtures"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import { + expectPairingOffer, + expectCompletePairingQr, expectRelayConsent, openPairDeviceModal, preparePairingHost, @@ -18,3 +20,20 @@ test("opens relay consent in browser web", async ({ page }) => { await daemon.close(); } }); + +test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => { + const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", { + mutableRelay: { enabled: true }, + }); + try { + await preparePairingHost(page, daemon); + await page.setViewportSize({ width: 320, height: 568 }); + await openPairDeviceModal(page); + await expectPairingOffer(page); + + await expectCompletePairingQr(page); + await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") }); + } finally { + await daemon.close(); + } +}); diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 7e32f61350..84b4cc63a2 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,3 +1,4 @@ +import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows( await installPlugin(page, missingSource); await expect(page.getByTestId("plugin-management-feedback")).toContainText( - "Plugin source is neither an existing directory nor a Git URL", + `Plugin directory does not exist: ${missingSource}`, ); await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource); await capturePluginInstallForm(page, testInfo, "compact-error"); @@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) { await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible(); await expect(page.getByLabel("npm-review running")).toHaveCount(0); } + +test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => { + const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n"); + await installPluginWithUntrustedDescription(pluginEnvironment, description); + await gotoAppShell(page); + await openPluginSettings(page); + const text = page.getByText(description, { exact: true }); + await expect(text).toBeVisible(); + // This field must remain a text node, with no Markdown links, images, or HTML children. + expect(await text.evaluate((element) => element.childElementCount)).toBe(0); +}); + +async function installPluginWithUntrustedDescription( + environment: PluginEnvironment, + description: string, +) { + const directory = await createDirectoryPlugin( + environment.directory, + "plain-text-plugin", + description, + "Plain text plugin", + ); + await environment.client.installPluginSource({ source: directory }); +} diff --git a/packages/app/e2e/browser/project-path-spaces.spec.ts b/packages/app/e2e/browser/project-path-spaces.spec.ts new file mode 100644 index 0000000000..3b3478f5bb --- /dev/null +++ b/packages/app/e2e/browser/project-path-spaces.spec.ts @@ -0,0 +1,64 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test as base } from "../support/fixtures"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { openChangesTreePanel } from "../support/helpers/workspace-tabs"; + +const test = base.extend<{ projectWithTrailingSpace: string }>({ + projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-")); + try { + const repoPath = path.join(root, "repo "); + execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Paseo Test", + "-c", + "user.email=test@getpaseo.local", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repoPath, stdio: "pipe" }, + ); + await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n"); + const added = await client.addProject(repoPath); + expect(added.error).toBeNull(); + const projectId = added.project!.projectId; + try { + const created = await client.createWorkspace({ + source: { kind: "directory", path: repoPath, projectId }, + }); + expect(created.error).toBeNull(); + await provide(created.workspace!.id); + } finally { + await client.removeProject(projectId); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +}); + +test.skip( + process.platform === "win32", + "Windows does not preserve trailing spaces in folder names", +); + +test("Changes shows files in a project whose folder ends in a space", async ({ + page, + projectWithTrailingSpace, +}, testInfo) => { + await gotoWorkspace(page, projectWithTrailingSpace); + await openChangesTreePanel(page); + await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath("changes.png") }); +}); diff --git a/packages/app/e2e/browser/provider-removal.spec.ts b/packages/app/e2e/browser/provider-removal.spec.ts index 7e17a1fbe0..b72fb868d6 100644 --- a/packages/app/e2e/browser/provider-removal.spec.ts +++ b/packages/app/e2e/browser/provider-removal.spec.ts @@ -10,16 +10,29 @@ import { openSettingsHost, openSettingsHostSection, } from "../support/helpers/settings"; +import { copyPluginExample } from "../support/helpers/plugin-fixture"; const CUSTOM_PROVIDER = { id: "junie", name: "Junie", } as const; +const PLUGIN_PROVIDER = { + id: "direct-example", + name: "Direct provider example", + pluginId: "provider-direct-example", +} as const; + interface ProviderRemovalDaemonClient { connect(): Promise; close(): Promise; - patchDaemonConfig(config: { removeProviders?: string[] }): Promise; + getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>; + patchDaemonConfig(config: { + removeProviders?: string[]; + pluginsEnabled?: boolean; + }): Promise; + installDirectoryPlugin(path: string): Promise; + removePlugin(pluginId: string): Promise; getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string; source?: "builtin" | "custom" }>; }>; @@ -93,4 +106,45 @@ test.describe("provider removal", () => { await client.close().catch(() => undefined); } }); + + test("does not offer Remove for a plugin-registered provider", async ({ page }) => { + test.setTimeout(120_000); + const client = await connectDaemonClient({ + clientIdPrefix: "provider-removal-e2e", + }); + + const plugin = await copyPluginExample("provider-direct"); + try { + const previous = await client.getDaemonConfig(); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(plugin.directory); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + + const pluginProviderRow = page.getByRole("button", { + name: `${PLUGIN_PROVIDER.name} provider details`, + exact: true, + }); + await expect(pluginProviderRow).toBeVisible(); + await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0); + + // The provider survives a removeProviders patch pointed at it: the + // plugin, not config.json, owns the definition. + await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] }); + await expect(pluginProviderRow).toBeVisible(); + } finally { + await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined); + await client + .patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }) + .catch(() => undefined); + } + } finally { + await client.close().catch(() => undefined); + await plugin.cleanup(); + } + }); }); diff --git a/packages/app/e2e/browser/question-prompt-pagination.spec.ts b/packages/app/e2e/browser/question-prompt-pagination.spec.ts index b014b2d1cd..1d75ae2839 100644 --- a/packages/app/e2e/browser/question-prompt-pagination.spec.ts +++ b/packages/app/e2e/browser/question-prompt-pagination.spec.ts @@ -7,6 +7,7 @@ import { expectQuestionDismissEnabled, expectQuestionHidden, expectQuestionNavigationEnabled, + expectQuestionOptionPreview, expectQuestionOptionSelected, expectQuestionPrimaryActionDisabled, expectQuestionPrimaryActionEnabled, @@ -61,6 +62,10 @@ test.describe("Question prompt pagination", () => { await expectQuestionOptionSelected(page, "App"); await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS }); + await expectQuestionOptionPreview(page, { + option: "Behind feature flag", + preview: 'flags.enabled("new-surface")', + }); await chooseQuestionOption(page, "Behind feature flag"); await expectCurrentQuestion(page, { index: 3, diff --git a/packages/app/e2e/browser/sidebar-help.spec.ts b/packages/app/e2e/browser/sidebar-help.spec.ts index 6d2ed6e96f..4c0162e8d8 100644 --- a/packages/app/e2e/browser/sidebar-help.spec.ts +++ b/packages/app/e2e/browser/sidebar-help.spec.ts @@ -183,13 +183,19 @@ async function openCompactSidebar(page: Page): Promise { await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible(); } +const MIN_TARGET_SIZE = 44; + async function expectFingerSizedFooterTargets(page: Page): Promise { const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button"); await expect(buttons).toHaveCount(5); for (const button of await buttons.all()) { const bounds = await button.boundingBox(); - expect(bounds?.width).toBeGreaterThanOrEqual(44); - expect(bounds?.height).toBeGreaterThanOrEqual(44); + // Layout can report a 44pt box as 43.99999, so compare at a hundredth of a point. + expect(bounds?.width).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01); + expect(bounds?.height).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01); + // Glyphs stay at the composer toolbar size (some optically smaller); only the target grows. + const glyphWidth = await button.locator("svg").first().getAttribute("width"); + expect(Number(glyphWidth)).toBeLessThanOrEqual(20); } } diff --git a/packages/app/e2e/browser/sidebar-title-width.spec.ts b/packages/app/e2e/browser/sidebar-title-width.spec.ts index 80f96dc68b..77b5a99eb5 100644 --- a/packages/app/e2e/browser/sidebar-title-width.spec.ts +++ b/packages/app/e2e/browser/sidebar-title-width.spec.ts @@ -88,6 +88,7 @@ async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) { await gotoAppShell(page); await openMobileAgentSidebar(page); await expect(workspaceRow(page)).toBeVisible(); + await toggleTrailing(page, "Diff stats", true); await page.mouse.move(0, 0); await page.screenshot({ path: testInfo.outputPath("sidebar.png") }); } @@ -106,6 +107,7 @@ async function openDesktopWorkspaceList(page: Page) { await gotoAppShell(page); const row = workspaceRow(page); await expect(row).toBeVisible(); + await toggleTrailing(page, "Diff stats"); await page.mouse.move(0, 0); await expect(row.getByText("+12.3k", { exact: true })).toBeVisible(); return titleWidth(row); 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/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 700d1e200f..8071b3b4f3 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -6,6 +6,11 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, + expectWorkspaceHoverCardClosed, + expectWorkspaceHoverCardOpen, + expectWorkspaceHoverCardStaysOpen, + focusWorkspaceRowWithKeyboard, + movePointerAwayFromWorkspaceHoverCard, openMobileAgentSidebar, pinWorkspaceFromSidebar, } from "../support/helpers/sidebar"; @@ -404,6 +409,52 @@ test.describe("Sidebar workspace list", () => { } }); + test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + const row = await openWorkspaceFromSidebar(page, workspace.workspaceId); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + + await row.click(); + await expect(row).toBeFocused(); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + } finally { + await workspace.cleanup(); + } + }); + + test("workspace hover card follows keyboard focus on a row", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + await openWorkspaceFromSidebar(page, workspace.workspaceId); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + + await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardStaysOpen(page); + await page.keyboard.press("Escape"); + await expectWorkspaceHoverCardClosed(page); + + await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); + await expectWorkspaceHoverCardOpen(page); + await page.keyboard.press("Tab"); + await expectWorkspaceHoverCardClosed(page); + } finally { + await workspace.cleanup(); + } + }); + test("marks a finished workspace unread until it is opened again", async ({ page }) => { const workspace = await seedMockAgentWorkspace({ repoPrefix: "sidebar-mark-unread-", diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts similarity index 80% rename from packages/app/e2e/browser/usage-screen.spec.ts rename to packages/app/e2e/browser/usage-modal.spec.ts index 8528f4a0ea..6f546078bf 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -18,15 +18,19 @@ import { installLoginUsage, installCodexWindowUsage, expectCodexReportedWindows, - openUsage, refreshLoginUsage, hoverUsageWindow, } from "../support/helpers/usage-login"; import { claudeAndCodexReports, expectPinnedUsage, + openCompactSidebar, + openUsageFromIcon, + openUsageFromItem, pinRow, openUsageOptions, + closeUsageOptions, + expectUsageOptionsFitContent, showUsageAs, refreshAllUsage, togglePin, @@ -96,7 +100,7 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry { }; } -test.describe("usage screen", () => { +test.describe("usage modal", () => { test("opens from the sidebar on the host's reports, with no host filter for one host", async ({ page, }) => { @@ -132,14 +136,13 @@ test.describe("usage screen", () => { }); await gotoAppShell(page); - await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); - await expect(page).toHaveURL(/\/usage$/); + await openUsageFromItem(page); await usage.waitForListRequests(1); const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(hostFilter(page)).toHaveCount(0); - await qaScreenshot(page, "phase7-usage-screen-one-host"); + await qaScreenshot(page, "usage-modal-one-host"); await expect(group.getByText("31%")).toBeVisible(); await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); await expect(group.getByText("Unavailable", { exact: true })).toBeVisible(); @@ -174,7 +177,7 @@ test.describe("usage screen", () => { windows: [{ id: "weekly", label: "Weekly", usedPct }], }, }); - // The sidebar summary and the screen each load reports; only a card's Refresh forces one. + // The sidebar summary and the modal each load reports; only a card's Refresh forces one. const usage = await installUsageReportsFixture(page, { lists: [ (request) => @@ -185,7 +188,7 @@ test.describe("usage screen", () => { }); await gotoAppShell(page); - await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + await openUsageFromItem(page); const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 }); @@ -222,7 +225,9 @@ test.describe("usage screen", () => { }); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/usage"); + await gotoAppShell(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); await usage.waitForListRequests(1); const group = page.getByTestId(`usage-host-${serverId}`); @@ -236,19 +241,19 @@ test.describe("usage screen", () => { const usage = await installUsageReportsFixture(page, { usageSupported: false }); await gotoAppShell(page); - // Without reports the footer has no Usage item; its Usage icon opens the screen. - await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click(); + // Without reports the footer has no Usage item; its Usage icon opens Usage. + await openUsageFromIcon(page); await expect( page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/), ).toBeVisible({ timeout: 10_000 }); await qaScreenshot(page, "phase7-usage-update-host"); - await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0); + await expect(page.getByTestId("usage-options-menu")).toHaveCount(0); await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0); expect(usage.listRequests()).toHaveLength(0); }); - test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({ + test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({ page, }) => { test.setTimeout(420_000); @@ -271,7 +276,7 @@ test.describe("usage screen", () => { // Nothing picked and no workspace open: the first host. await expectPinnedUsage(page, ["31% Weekly"]); - await usageItem(page).click(); + await openUsageFromItem(page); await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible(); await hostFilter(page).click(); @@ -280,7 +285,7 @@ test.describe("usage screen", () => { page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); await expect(hostFilter(page)).toContainText("Secondary box"); - await qaScreenshot(page, "usage-screen-picked-host"); + await qaScreenshot(page, "usage-modal-picked-host"); await expectPinnedUsage(page, ["12% Weekly"]); // The e2e seed resets the host list on every load, so reopening re-adds the second host. @@ -290,6 +295,7 @@ test.describe("usage screen", () => { port: secondary.port, }); await expectPinnedUsage(page, ["12% Weekly"]); + await openUsageFromItem(page); await expect( page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); @@ -318,16 +324,16 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag }); try { await gotoAppShell(page); - await openUsage(page); + await openUsageFromIcon(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toBeVisible(); await expect(page.getByText("Unavailable", { exact: true })).toBeVisible(); await qaScreenshot(page, "usage-expired-login"); await fixture.setReport(loginWindows); await refreshLoginUsage(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toHaveCount(0); await expectPinnedUsage(page, ["31% 5h", "54% wk"]); const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / }); @@ -364,7 +370,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => { try { await page.setViewportSize({ width: 390, height: 844 }); await gotoAppShell(page); - await openUsage(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); const card = page.getByTestId("usage-report-login-journey:account"); await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2); await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1"); @@ -411,7 +418,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) { await page.setViewportSize({ width: 1440, height: 900 }); await gotoAppShell(page); await expect(usageItem(page)).toBeVisible(); - await usageItem(page).click(); + await openUsageFromItem(page); await qaScreenshot(page, `codex-${shape}-card-and-summary`); await expectCodexReportedWindows(page, shape); await qaScreenshot(page, `codex-${shape}-pins`); @@ -426,7 +433,7 @@ for (const theme of ["light", "dark"] as const) { desktop: { width: 1440, height: 900 }, compact: { width: 390, height: 844 }, })) { - test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => { + test(`Usage Settings cog ${size} ${theme}`, async ({ page }, testInfo) => { test.setTimeout(120_000); await page.addInitScript((value) => { const key = "@paseo:app-settings"; @@ -437,45 +444,29 @@ for (const theme of ["light", "dark"] as const) { lists: [() => claudeAndCodexReports()], }); await page.setViewportSize(viewport); - await page.goto("/usage"); + await gotoAppShell(page); + if (size === "compact") await openCompactSidebar(page); + await openUsageFromIcon(page); const screen = page.getByTestId(`usage-host-${getServerId()}`); await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 }); - const settings = screen.getByRole("button", { name: "Settings", exact: true }); - const chevron = settings.locator("svg").locator("../.."); + const settings = page.getByTestId("usage-options-menu"); await expect(settings).toHaveAccessibleName("Settings"); - await expect(settings).toHaveAttribute("aria-expanded", "false"); - await expect(chevron).toHaveCSS("transform", "none"); - await expect(screen.getByTestId("usage-display-used")).toHaveCount(0); - await expect( - pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-pinned"), - ).toHaveCSS("opacity", "1"); - await captureSettingsState(page, testInfo, `${size}-${theme}-collapsed`); + await expect(page.getByTestId("usage-display-used")).toHaveCount(0); + await captureSettingsState(page, testInfo, `${size}-${theme}-cog`); - await togglePin(screen, "Claude", "Weekly"); - await page.mouse.move(0, viewport.height - 1); - await expect( - pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-unpinned"), - ).toHaveCSS("opacity", "1"); await openUsageOptions(page); - await expect(settings).toHaveAttribute("aria-expanded", "true"); - await expect(chevron).toHaveCSS("transform", "matrix(0, 1, -1, 0, 0, 0)"); - await expect(screen.getByText("Pinned windows show in the sidebar footer")).toBeVisible(); - await expect(screen.getByTestId("usage-display-used")).toHaveAttribute( - "aria-selected", - "true", - ); - await settings.blur(); - await page.mouse.move(0, viewport.height - 1); - await captureSettingsState(page, testInfo, `${size}-${theme}-expanded`); + await expectUsageOptionsFitContent(page); + await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible(); + await expect(page.getByTestId("usage-display-used")).toHaveAttribute("aria-selected", "true"); + await captureSettingsState(page, testInfo, `${size}-${theme}-settings`); await showUsageAs(page, "remaining"); await expect(screen.getByText("69% left")).toBeVisible(); await showUsageAs(page, "used"); await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName( /^Pin Claude Session, 31% · resets /, ); - await settings.click(); - await expect(settings).toHaveAttribute("aria-expanded", "false"); - await expect(screen.getByTestId("usage-display-used")).toHaveCount(0); + await openUsageOptions(page); + await closeUsageOptions(page); await refreshAllUsage(page); await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]); }); @@ -490,3 +481,64 @@ async function captureSettingsState( await qaScreenshot(page, name); await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" }); } + +test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({ + page, +}) => { + const fixture = await installLoginUsage([ + { harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } }, + { + harness: "OpenCode", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "opencode" }, + }, + }, + { + harness: "Pi", + report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } }, + }, + { + harness: "OMP", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 403, refreshedBy: "omp" }, + }, + }, + ]); + try { + await gotoAppShell(page); + await openUsageFromIcon(page); + await expectLoginErrors(page, [ + "Codex: Usage API returned 500. Try again.", + "OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.", + "Pi: No active plan", + "OMP: Login rejected (HTTP 403). Run omp to refresh it.", + ]); + await fixture.setReport([ + { + harness: "Codex", + report: { status: "error", error: "Usage API returned 500. Try again." }, + }, + { + harness: "OpenCode", + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }, + }, + ]); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "42%"); + } finally { + await fixture.cleanup(); + } +}); + +async function expectLoginErrors(page: Page, lines: string[]) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n")); +} + +async function expectLoginUsageOnly(page: Page, percentage: string) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByText(percentage, { exact: true })).toBeVisible(); + await expect(card.getByTestId("usage-login-error")).toHaveCount(0); +} diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 04bfe649b1..1b509f5640 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -7,7 +7,11 @@ import { installUsageReportsFixture, type UsageReportsFixture, } from "../support/helpers/usage-reports"; -import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item"; +import { + openUsageFromIcon, + refreshAllUsage, + showUsageAs, +} from "../support/helpers/usage-sidebar-item"; const ICON = ''; @@ -95,10 +99,11 @@ test.describe("usage settings", () => { await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible(); - // Percentages now live on the Usage screen, and still apply to the host section. - await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0); + // The shared percentages setting applies to the host section. + await expect(page.getByTestId("usage-options-menu")).toBeVisible(); const hostUsageUrl = page.url(); - await page.goto("/usage"); + await gotoAppShell(page); + await openUsageFromIcon(page); await showUsageAs(page, "remaining"); await page.goto(hostUsageUrl); await expect(card.getByText("30% left")).toBeVisible(); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index c2da27b401..fb738b3cb5 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -2,6 +2,11 @@ import path from "node:path"; import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { gotoWorkspace, waitForTabBar } from "../support/helpers/launcher"; +import { + connectNewWorkspaceDaemonClient, + openProjectViaDaemon, +} from "../support/helpers/new-workspace"; import { getServerId } from "../support/helpers/server-id"; import { openSettingsHostSection } from "../support/helpers/settings"; import { @@ -14,25 +19,29 @@ import { import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, + closeUsage, + closeUsageFromBackdrop, + closeUsageOptions, expectNoUsageItem, - expectOnUsageScreen, expectPinnedUsage, expectSummaryInSidebar, expectUnpinnableRows, - leaveUsageScreen, openCompactSidebar, - openUsageScreenFromIcon, + openUsageFromIcon, + openUsageFromItem, pinRow, + scrollUsage, setSummaryInSidebar, openUsageOptions, showUsageAs, togglePin, usageItem, - usageSheet, + usageModal, installTallUsageSource, } from "../support/helpers/usage-sidebar-item"; import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { waitForSettledPosition } from "../support/helpers/sheet-layout"; +import { createTempGitRepo } from "../support/helpers/workspace"; const WIDE = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; @@ -62,32 +71,106 @@ async function footerClip(page: Page) { } test.describe("Usage item", () => { - test("long usage reports stay in an 80% sheet and scroll to the final window", async ({ + test("both compact footer entry points open Usage as a sheet", async ({ page }) => { + await page.setViewportSize(COMPACT); + await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: false }]); + await gotoAppShell(page); + await openCompactSidebar(page); + const startingUrl = page.url(); + await expectNoUsageItem(page); + + await test.step("the icon opens a sheet without leaving the current screen", async () => { + await openUsageFromIcon(page); + await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible(); + await expect(page).toHaveURL(startingUrl); + await qaScreenshot(page, "compact-usage-icon-sheet"); + await setSummaryInSidebar(page, true); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + await closeUsageFromBackdrop(page); + }); + + await test.step("the summary opens the same sheet", async () => { + await openUsageFromItem(page); + await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible(); + await expect(page).toHaveURL(startingUrl); + await qaScreenshot(page, "compact-usage-summary-sheet"); + await closeUsageFromBackdrop(page); + }); + }); + + test("on desktop Usage opens as a dialog over the workspace and closes back to it", async ({ page, }) => { - test.setTimeout(120_000); - const source = await installTallUsageSource(); + const repo = await createTempGitRepo("usage-modal-"); + const client = await connectNewWorkspaceDaemonClient(); try { - await page.setViewportSize(COMPACT); + const project = await openProjectViaDaemon(client, repo.path); + await page.setViewportSize(WIDE); + await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); - await gotoAppShell(page); - await openCompactSidebar(page); - await expect(usageItem(page)).toBeInViewport(); - await usageItem(page).click(); - const content = page.getByTestId("sidebar-usage-sheet-content"); - await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); - await waitForSettledPosition(content); - const bounds = (await content.boundingBox())!; - expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); - await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); - await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded(); - await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); - await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded(); - await expect(content.getByText("Window 1", { exact: true })).toBeInViewport(); + await gotoWorkspace(page, project.workspaceId); + const workspaceUrl = page.url(); + + await test.step("the summary opens a dialog over the workspace", async () => { + await openUsageFromItem(page); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText("Claude", { exact: true })).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Refresh all", exact: true }), + ).toBeVisible(); + await expect(page).toHaveURL(workspaceUrl); + await qaScreenshot(page, "desktop-usage-dialog"); + }); + + await test.step("Close returns to the same workspace", async () => { + await closeUsage(page); + await expect(page).toHaveURL(workspaceUrl); + await waitForTabBar(page); + }); + + await test.step("the icon opens it again, and Escape closes it", async () => { + await openUsageFromIcon(page); + await page.keyboard.press("Escape"); + await expect(usageModal(page)).toHaveCount(0); + await expect(page).toHaveURL(workspaceUrl); + }); } finally { - await source.cleanup(); + await client.close().catch(() => undefined); + await repo.cleanup().catch(() => undefined); } }); + + const layouts = { + desktop: { viewport: WIDE, showSidebar: async (_page: Page) => {} }, + compact: { viewport: COMPACT, showSidebar: openCompactSidebar }, + }; + for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) { + test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => { + test.setTimeout(120_000); + const source = await installTallUsageSource(); + try { + await page.setViewportSize(viewport); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await showSidebar(page); + await expect(usageItem(page)).toBeInViewport(); + await openUsageFromItem(page); + const content = usageModal(page); + await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); + await waitForSettledPosition(content); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + await qaScreenshot(page, `${size}-usage-scroll-top`); + await scrollUsage(page, 5_000); + await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); + await qaScreenshot(page, `${size}-usage-scroll-bottom`); + await scrollUsage(page, -5_000); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + } finally { + await source.cleanup(); + } + }); + } test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({ page, }) => { @@ -116,8 +199,7 @@ test.describe("Usage item", () => { await gotoAppShell(page); await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); - await usageItem(page).click(); - await expectOnUsageScreen(page); + await openUsageFromItem(page); await qaScreenshot(page, "default-pins-card"); await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); @@ -142,17 +224,13 @@ test.describe("Usage item", () => { await expect(usageItem(page)).not.toHaveText("Usage"); await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" }); await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible(); - await qaScreenshot(page, "usage-screen-header", { - kind: "element", - locator: page.getByTestId("page-title").locator(".."), - }); await qaScreenshot(page, "usage-card-refresh", { kind: "element", locator: screen.getByTestId("usage-report-claude:default"), }); }); - await test.step("remaining flips the Usage item and the Usage screen", async () => { + await test.step("remaining flips the Usage item and the Usage modal", async () => { await showUsageAs(page, "remaining"); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/); @@ -164,23 +242,15 @@ test.describe("Usage item", () => { ).toBeVisible(); }); - await test.step("on desktop the Usage item opens the Usage screen", async () => { - await gotoAppShell(page); - await expect(page).not.toHaveURL(/\/usage$/); - await usageItem(page).click(); - await expectOnUsageScreen(page); - await expect(page.getByTestId("usage-expanded")).toHaveCount(0); - }); - await test.step("on a phone the Usage item opens a bottom sheet", async () => { + await closeUsage(page); await page.setViewportSize(COMPACT); - await leaveUsageScreen(page); await openCompactSidebar(page); await expect(usageItem(page)).toBeInViewport(); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await qaScreenshot(page, "compact-footer"); - await usageItem(page).click(); - const sheet = usageSheet(page); + await openUsageFromItem(page); + const sheet = usageModal(page); await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport(); await expect(sheet.getByText("88% left")).toBeVisible(); await expect(pinRow(sheet, "Claude", "Session")).toBeChecked(); @@ -189,57 +259,51 @@ test.describe("Usage item", () => { await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName( /^Pin Claude Session, \d+% left( · .+)?$/, ); - // The sheet carries the same inline Settings row and title-row Refresh all. - await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute( - "aria-expanded", - "false", - ); + // Settings opens a second sheet; closing it restores the Usage sheet underneath. + await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible(); await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible(); await openUsageOptions(page); - await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute( + await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( "aria-selected", "true", ); await qaScreenshot(page, "compact-settings-expanded"); - await sheet.getByTestId("usage-options-toggle").click(); + await closeUsageOptions(page); await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0); await expect(sheet).toBeVisible(); - await expect(page).not.toHaveURL(/\/usage$/); await waitForSettledPosition(sheet); const sheetBox = (await sheet.boundingBox())!; - // Settings adds a row; the sheet still reserves the top 20% as its backdrop. + // The sheet leaves the screen above it as its backdrop. expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8); await qaScreenshot(page, "compact-sheet"); - await qaScreenshot(page, "phase7-compact-sheet"); - // Tap the backdrop above the sheet. - await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2); - await expect(sheet).toHaveCount(0); + await closeUsageFromBackdrop(page); await page.setViewportSize(WIDE); - await usageItem(page).click(); - await expectOnUsageScreen(page); }); await test.step("a reload keeps the pins and the toggle", async () => { await page.reload(); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); + await openUsageFromItem(page); await openUsageOptions(page); await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( "aria-selected", "true", ); await qaScreenshot(page, "usage-settings-expanded"); - await page.getByTestId("usage-options-toggle").click(); + await closeUsageOptions(page); }); await test.step("the Settings usage section shares the pins and the toggle", async () => { - const usageUrl = page.url(); + const workspaceUrl = page.url(); + await closeUsage(page); await openSettings(page); await openSettingsHostSection(page, serverId, "usage"); const section = page.getByTestId("usage-card"); await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 }); await expect(pinRow(section, "Codex", "Weekly")).toBeChecked(); - await page.goto(usageUrl); + await page.goto(workspaceUrl); + await openUsageFromItem(page); await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 }); }); @@ -248,6 +312,7 @@ test.describe("Usage item", () => { await togglePin(screen, "Codex", "Weekly"); await expectNoUsageItem(page); await page.reload(); + await openUsageFromIcon(page); await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 }); await expectNoUsageItem(page); await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked(); @@ -255,6 +320,7 @@ test.describe("Usage item", () => { await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked(); await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked(); await qaScreenshot(page, "explicit-empty-pins-after-reload"); + await closeUsage(page); }); await test.step("Settings > Sidebar lists the Usage item", async () => { @@ -298,8 +364,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i await expectNoUsageItem(page); await expectFooterSeparator(page, false); await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" }); - await page.locator('[data-testid="sidebar-usage-icon"]:visible').click(); - await expectOnUsageScreen(page); + await openUsageFromIcon(page); }); test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({ @@ -313,13 +378,13 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i const claude = screen.getByTestId("usage-report-claude:default"); await test.step("off by default: no Usage item, and rows that do not pin", async () => { - await openUsageScreenFromIcon(page); + await openUsageFromIcon(page); await expectNoUsageItem(page); await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 }); await expectUnpinnableRows(claude); await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default")); await expectSummaryInSidebar(page, false); - await qaScreenshot(page, "usage-screen-summary-off"); + await qaScreenshot(page, "usage-summary-off"); }); await test.step("on: the Usage item and the pins appear", async () => { @@ -328,7 +393,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); await togglePin(screen, "Claude", "Weekly"); await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]); - await qaScreenshot(page, "usage-screen-summary-on"); + await qaScreenshot(page, "usage-summary-on"); }); await test.step("off then on again, after a reload, brings back the saved pins", async () => { @@ -336,7 +401,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i await expectNoUsageItem(page); await expectUnpinnableRows(claude); await page.reload(); - await expectOnUsageScreen(page); + await openUsageFromIcon(page); await expectSummaryInSidebar(page, false); await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 }); await expectUnpinnableRows(claude); @@ -364,8 +429,7 @@ test("released hosts supply source logos through the client conversion", async ( await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath); await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath); await qaScreenshot(page, "released-host-footer", { kind: "footer" }); - await usageItem(page).click(); - await expectOnUsageScreen(page); + await openUsageFromItem(page); const screen = page.getByTestId(`usage-host-${getServerId()}`); await expect( screen.getByTestId("usage-report-claude").locator("svg path").first(), @@ -373,5 +437,5 @@ test("released hosts supply source logos through the client conversion", async ( await expect( screen.getByTestId("usage-report-codex").locator("svg path").first(), ).toHaveAttribute("d", codexPath); - await qaScreenshot(page, "released-host-usage-screen"); + await qaScreenshot(page, "released-host-usage-modal"); }); diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts new file mode 100644 index 0000000000..4853e9daa4 --- /dev/null +++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts @@ -0,0 +1,74 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { + expectSidebarWorkspaceSelected, + switchWorkspaceViaSidebar, +} from "../support/helpers/workspace-ui"; + +async function readVisibleWorkspaceOrder(page: Page): Promise { + const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ }); + await expect(rows).toHaveCount(12); + return rows.evaluateAll( + (elements, prefix) => + elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)), + `sidebar-workspace-row-${getServerId()}:`, + ); +} + +async function navigateWorkspace( + page: Page, + direction: "next" | "previous", + workspaceId: string, +): Promise { + await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+["); + await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId }); + await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`)); +} + +test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({ + page, +}) => { + test.setTimeout(120_000); + const project = await seedWorkspace({ + repoPrefix: "relative-navigation-", + title: "Workspace 01", + }); + try { + for (let index = 2; index <= 12; index += 1) { + const created = await project.client.createWorkspace({ + source: { kind: "directory", path: project.repoPath }, + title: `Workspace ${String(index).padStart(2, "0")}`, + }); + expect(created.error).toBeNull(); + } + await page.setViewportSize({ width: 1440, height: 1100 }); + await gotoAppShell(page); + const order = await readVisibleWorkspaceOrder(page); + + await test.step("next crosses the ninth workspace and previous returns to it", async () => { + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] }); + await navigateWorkspace(page, "next", order[9]); + await navigateWorkspace(page, "previous", order[8]); + }); + + await test.step("wrapping uses the true first and last visible workspace", async () => { + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] }); + await navigateWorkspace(page, "previous", order[11]); + await navigateWorkspace(page, "next", order[0]); + }); + + await test.step("the ninth digit shortcut still selects the ninth workspace", async () => { + await page.keyboard.press("Alt+9"); + await expectSidebarWorkspaceSelected({ + page, + serverId: getServerId(), + workspaceId: order[8], + }); + }); + } finally { + await project.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts index 07e62131b0..72b5908446 100644 --- a/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts +++ b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts @@ -1,3 +1,4 @@ +import type { Page, TestInfo } from "@playwright/test"; import { randomUUID } from "node:crypto"; import { test, expect } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client"; const MOBILE_VIEWPORT = { width: 390, height: 844 }; +test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 }); + +async function openTabSwitcher(page: Page) { + await page.getByRole("button", { name: /Switch tabs/ }).click(); + const footer = page.getByRole("button", { name: "New terminal", exact: true }); + await expect(footer).toBeVisible(); + // Visibility alone passes while the sheet is still travelling up from below the viewport. + await expect + .poll(async () => { + const bounds = await footer.boundingBox(); + return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY; + }) + .toBeLessThanOrEqual(MOBILE_VIEWPORT.height); +} + +async function captureScreen(page: Page, testInfo: TestInfo, name: string) { + await page.screenshot({ path: testInfo.outputPath(name + ".png") }); +} + +async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") { + await openTabSwitcher(page); + await page.getByRole("button", { name: kind, exact: true }).click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); +} + async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise { const result = await client.fetchAgents({ scope: "active" }); return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null; @@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi test.describe("Workspace session rename (compact tab switcher)", () => { test("renames an agent session from the switcher's per-session actions sheet", async ({ page, - }) => { + }, testInfo) => { test.setTimeout(120_000); await page.setViewportSize(MOBILE_VIEWPORT); @@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => { timeout: 30_000, }); + await captureScreen(page, testInfo, "compact-chat-header"); + // Open the sessions dropdown (a bottom sheet on compact). - await page.getByRole("button", { name: /Switch tabs/ }).click(); + await openTabSwitcher(page); await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible( { timeout: 10_000, @@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => { await renameModalSubmit(page, modalPrefix).click(); await expect(input).toHaveCount(0, { timeout: 15_000 }); - await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, { + await expect(page.getByText(renamed, { exact: true })).toBeVisible({ timeout: 15_000, }); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1"); await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1); + await expect(page.getByText("Rename agent", { exact: true })).toBeHidden(); + await captureScreen(page, testInfo, "renamed-tab-in-switcher"); } finally { await session.cleanup(); } }); }); + +test("switcher footer creates tabs and returns to the existing chat", async ({ + page, +}, testInfo) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "compact-header-actions-", + title: "Existing chat", + }); + try { + await openAgentRoute(page, session); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1"); + + await createTabFromSwitcher(page, "New terminal"); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2"); + await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1); + await captureScreen(page, testInfo, "compact-terminal-header"); + + await createTabFromSwitcher(page, "New agent"); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3"); + await expect( + page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }), + ).toHaveCount(1); + await captureScreen(page, testInfo, "compact-draft-header"); + + await openTabSwitcher(page); + await expect(page.getByText("Existing chat", { exact: true })).toBeVisible(); + await captureScreen(page, testInfo, "switcher-three-tabs"); + await page.getByText("Existing chat", { exact: true }).click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3"); + + await openTabSwitcher(page); + await expect(page.getByText("Existing chat", { exact: true })).toBeVisible(); + await page + .getByRole("button", { name: "Bottom sheet backdrop" }) + .click({ position: { x: 10, y: 10 } }); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + await captureScreen(page, testInfo, "returned-chat-header"); + } finally { + await session.cleanup(); + } +}); diff --git a/packages/app/e2e/mobile/agent-device/native-question-option-preview-preconditions.md b/packages/app/e2e/mobile/agent-device/native-question-option-preview-preconditions.md new file mode 100644 index 0000000000..ac920aa0e7 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-question-option-preview-preconditions.md @@ -0,0 +1,7 @@ +# Native question option preview + +`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible. + +```sh +agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554 +``` diff --git a/packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad b/packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad new file mode 100644 index 0000000000..f6cd42bbab --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad @@ -0,0 +1,10 @@ +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "label=\"rollout\"" 20000 +press "label=\"rollout\"" +wait "id=\"question-form-option-preview\"" 10000 +gesture pan 570 1120 0 -440 600 +is visible "text=\"// LAST PREVIEW LINE\"" +close diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad index 63f5fb0728..335f269b13 100644 --- a/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad @@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1 env APP_ID=sh.paseo.debug open "${APP_ID}" -wait "id=\"workspace-header-menu-trigger\"" 20000 -press "id=\"workspace-header-menu-trigger\"" -press "id=\"workspace-header-new-terminal\"" +wait "id=\"workspace-tab-switcher-trigger\"" 20000 +press "id=\"workspace-tab-switcher-trigger\"" +press "id=\"workspace-tab-switcher-new-terminal\"" wait "id=\"terminal-keyboard-toggle\"" 10000 press "id=\"terminal-keyboard-toggle\"" type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0 diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad index 4988c9afdd..2b5e5fcfdb 100644 --- a/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad @@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1 env APP_ID=sh.paseo.debug open "${APP_ID}" -wait "id=\"workspace-header-menu-trigger\"" 20000 -press "id=\"workspace-header-menu-trigger\"" -press "id=\"workspace-header-new-terminal\"" +wait "id=\"workspace-tab-switcher-trigger\"" 20000 +press "id=\"workspace-tab-switcher-trigger\"" +press "id=\"workspace-tab-switcher-new-terminal\"" wait "id=\"terminal-surface\"" 10000 press "id=\"terminal-surface\"" type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0 diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad index 9ef099b5a7..11f75b62b3 100644 --- a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad @@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1 env APP_ID=sh.paseo.debug open "${APP_ID}" -wait "id=\"workspace-header-menu-trigger\"" 20000 -press "id=\"workspace-header-menu-trigger\"" -press "id=\"workspace-header-new-terminal\"" +wait "id=\"workspace-tab-switcher-trigger\"" 20000 +press "id=\"workspace-tab-switcher-trigger\"" +press "id=\"workspace-tab-switcher-new-terminal\"" wait "id=\"terminal-keyboard-toggle\"" 10000 press "id=\"terminal-keyboard-toggle\"" type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0 diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index ba86400132..49c907dba9 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen ad wait "text=\"${AGENT_TITLE}\"" 10000 run_host_scenario chat ad keyboard dismiss || true -ad press 'id="workspace-header-menu-trigger"' --settle -ad wait 'id="workspace-header-new-agent"' 10000 -ad press 'id="workspace-header-new-agent"' --settle +ad press 'id="workspace-tab-switcher-trigger"' --settle +ad wait 'id="workspace-tab-switcher-new-agent"' 10000 +ad press 'id="workspace-tab-switcher-new-agent"' --settle ad wait 'text="New Agent"' 10000 run_host_scenario workspace-draft ad keyboard dismiss || true diff --git a/packages/app/e2e/mobile/native-text-reflow/README.md b/packages/app/e2e/mobile/native-text-reflow/README.md deleted file mode 100644 index b92666cc58..0000000000 --- a/packages/app/e2e/mobile/native-text-reflow/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Native iOS text geometry regression - -Run on the Namespace Mac with a fresh simulator development build. Use the PID -returned by `xcrun simctl launch sh.paseo.debug`. -Attach only to your task's app, while no device replay is running: - -```sh -PASEO_NATIVE_TEXT_REFLOW_PID=12345 \ -PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \ -bash packages/app/e2e/mobile/native-text-reflow/ios.sh -``` - -The probe constructs the installed `RNUITextView`, fills its real UIKit text view, -and resizes its content container from 500 to 240 and back to 500 points. It -asserts the inner frame follows both widths and the same selection survives. -It uses public UIView/UITextView operations, without mocking React Native. -LLDB detaches after the probe. An assertion failure or evaluation error fails the -shell command; the log distinguishes them. - -This covers native container resizing and selection retention. It does not cover -Fabric event delivery, chat row height, reader anchoring, or the on-screen copy -menu. Those require the real chat journey: read an older multiline response, -open/close Explorer, resize/rotate, inspect complete paragraphs and bullets, -retain expanded/collapsed details, and check a stationary reader's position. -Accessibility exposes full text even when glyphs are clipped, so a text wait -alone does not verify that journey. diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.py b/packages/app/e2e/mobile/native-text-reflow/ios.py deleted file mode 100644 index 7a4cbd5125..0000000000 --- a/packages/app/e2e/mobile/native-text-reflow/ios.py +++ /dev/null @@ -1,55 +0,0 @@ -import lldb - - -def __lldb_init_module(debugger, _internal_dict): - debugger.HandleCommand("expression -l objc++ -- @import UIKit") - frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame() - options = lldb.SBExpressionOptions() - options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus) - result = frame.EvaluateExpression( - r'''({ - UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init]; - auto hostFrame = host.frame; - hostFrame.size.width = 500; - hostFrame.size.height = 200; - host.frame = hostFrame; - UIView *container = (UIView *)[host performSelector:@selector(contentView)]; - container.frame = host.bounds; - UITextView *text = nil; - for (UIView *child in host.subviews) { - if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child; - for (UIView *nested in child.subviews) { - if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested; - } - } - text.frame = container.bounds; - text.font = [UIFont systemFontOfSize:16]; - text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection."; - auto range = text.selectedRange; - range.location = 12; - range.length = 9; - text.selectedRange = range; - auto frame = container.frame; - frame.size.width = 240; - container.frame = frame; - [container layoutIfNeeded]; - CGFloat narrow = text.frame.size.width; - auto selection = text.selectedRange; - frame.size.width = 500; - container.frame = frame; - [container layoutIfNeeded]; - CGFloat wide = text.frame.size.width; - BOOL ok = text != nil && narrow == 240 && wide == 500 && - selection.location == text.selectedRange.location && - selection.length == text.selectedRange.length && - selection.location == 12 && selection.length == 9; - [NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu", - ok ? @"PASS" : @"FAIL", narrow, wide, - (unsigned long)text.selectedRange.location, - (unsigned long)text.selectedRange.length]; - })''', - options, - ) - if result.GetError().Fail(): - raise RuntimeError(result.GetError().GetCString()) - print(result.GetObjectDescription()) diff --git a/packages/app/e2e/mobile/native-text-reflow/ios.sh b/packages/app/e2e/mobile/native-text-reflow/ios.sh deleted file mode 100644 index b4e9a07a22..0000000000 --- a/packages/app/e2e/mobile/native-text-reflow/ios.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}" -: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}" - -if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then - echo "Expected a running PaseoDebug simulator app" >&2 - exit 1 -fi - -probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \ - -o "command script import $probe_dir/ios.py" \ - -o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1 - -grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG" -grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG" diff --git a/packages/app/e2e/mobile/pair-device/android.mjs b/packages/app/e2e/mobile/pair-device/android.mjs new file mode 100644 index 0000000000..7ab660c2b0 --- /dev/null +++ b/packages/app/e2e/mobile/pair-device/android.mjs @@ -0,0 +1,43 @@ +// Start on Settings > Host > Pair device with relay enabled in the Android app. +// node packages/app/e2e/mobile/pair-device/android.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; + +const [, , session] = process.argv; +assert(session, "Pass the agent-device session for the connected Android app"); + +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + encoding: "utf8", + timeout: 60_000, + }); +} + +ad( + "press", + 'label="Pair a device, Scan a QR code or copy a link to connect your phone to this host"', + "--settle", +); +ad("wait", "text", "Pairing QR code", "10000"); +const { data } = JSON.parse(ad("snapshot", "-i", "--json")); +const sheet = data.nodes.find((node) => node.label === "Bottom Sheet"); +const qr = data.nodes.find((node) => node.label === "Pairing QR code"); +assert(sheet?.rect, "Pairing sheet must be visible"); +assert(qr?.rect, "Pairing QR code must be visible"); +assert(qr.rect.x > sheet.rect.x, "QR must have an unclipped left margin inside the sheet"); +assert( + qr.rect.x + qr.rect.width < sheet.rect.x + sheet.rect.width, + "QR must have an unclipped right margin inside the sheet", +); +assert(Math.abs(qr.rect.width - qr.rect.height) <= 1, "Complete QR must remain square"); +assert(qr.rect.y >= sheet.rect.y, "QR top must be visible inside the sheet"); +assert( + qr.rect.y + qr.rect.height <= sheet.rect.y + sheet.rect.height, + "QR bottom must be visible inside the sheet", +); +assert( + data.nodes.some((node) => node.label === "Copy" && node.enabled), + "Copy must remain available", +); +console.log("PASS: complete square QR fits inside the pairing sheet", qr.rect); +ad("press", 'label="Close"', "--settle"); diff --git a/packages/app/e2e/mobile/system-bars/android.mjs b/packages/app/e2e/mobile/system-bars/android.mjs new file mode 100644 index 0000000000..8201f3dc12 --- /dev/null +++ b/packages/app/e2e/mobile/system-bars/android.mjs @@ -0,0 +1,62 @@ +// Open Settings > Appearance in a native Android app loaded from this checkout. +// AGENT_DEVICE_STATE_DIR= node packages/app/e2e/mobile/system-bars/android.mjs +// Requires English UI and a dedicated emulator. Leaves System theme and light OS appearance. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; + +const [, , session, serial, appId] = process.argv; +assert(session && serial && appId, "Pass the agent-device session, adb serial, and app ID"); + +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + encoding: "utf8", + timeout: 60_000, + }); +} + +function windowState() { + const state = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], { + encoding: "utf8", + timeout: 10_000, + }); + assert(state.match(/mCurrentFocus=.*\n/)?.[0].includes(`${appId}/`), "App must be focused"); + const regions = state.match(/mLastStatusBarAppearanceRegions=\s*\n([^\n]+)/)?.[1]; + assert(regions, "Android must report the status-bar appearance region"); + return regions.includes("LIGHT_STATUS_BARS"); +} + +async function expectDarkIcons(expected, context) { + const deadline = Date.now() + 5_000; + do { + if (windowState() === expected) { + console.log(`PASS: ${context}`); + return; + } + await delay(100); + } while (Date.now() < deadline); + assert.equal(windowState(), expected, context); +} + +function selectTheme(theme) { + const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1]; + assert(current, "Start on Settings > Appearance"); + ad("press", `label="Theme: ${current}"`, "--settle"); + ad("press", `label="${theme}"`, "--settle"); + ad("wait", "text", `Theme: ${theme}`, "5000"); +} + +ad("settings", "appearance", "light"); +selectTheme("Light"); +await expectDarkIcons(true, "Light app theme uses dark status icons"); +selectTheme("Dark"); +await expectDarkIcons(false, "Dark app theme uses light status icons"); +selectTheme("System"); +await expectDarkIcons(true, "System theme starts with dark icons on light OS appearance"); +ad("settings", "appearance", "dark"); +await expectDarkIcons( + false, + "System theme follows an OS change to dark without reopening settings", +); +ad("settings", "appearance", "light"); +await expectDarkIcons(true, "System theme follows an OS change back to light"); diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts index 33308ee386..9608a95a0c 100644 --- a/packages/app/e2e/support/creation-fixtures.ts +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -13,6 +13,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; export const test = base.extend<{ startup: { release(): Promise; fail(): Promise }; + promptRejection: void; creation: Awaited>; promptRetry: ReturnType; agentRetries: Awaited>; @@ -65,6 +66,37 @@ export default function contribute(server) { await client.close(); } }, + promptRejection: async ({ page }, provide) => { + void page; + const client = await connectNewWorkspaceDaemonClient(); + const plugin = await copyPluginExample("lifecycle-logger"); + const previous = await client.getDaemonConfig(); + try { + await writeFile( + path.join(plugin.directory, "index.server.ts"), + ` +export default function contribute(server) { + return server.before("agent.create", ({ request }) => ({ + ...request, + config: { + ...request.config, + model: "e2e-fast-stream", + featureValues: { ...request.config.featureValues, mockPromptRejections: 1 }, + }, + })); +} +`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(plugin.directory); + await provide(); + } finally { + await client.removePlugin("lifecycle-logger"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await plugin.cleanup(); + await client.close(); + } + }, creation: async ({ page }, provide) => { const creation = await createCreationScenario(page); try { diff --git a/packages/app/e2e/support/helpers/agent-message-gate.ts b/packages/app/e2e/support/helpers/agent-message-gate.ts index 7444fee796..9f5735d5ac 100644 --- a/packages/app/e2e/support/helpers/agent-message-gate.ts +++ b/packages/app/e2e/support/helpers/agent-message-gate.ts @@ -6,6 +6,8 @@ type WebSocketMessage = string | Buffer; interface SendAgentMessageRequest { type: "send_agent_message_request"; requestId: string; + text: unknown; + attachments: unknown; agentId: string; /** Absent when the client sends into an idle agent. */ activeTurnBehavior?: string; @@ -29,6 +31,8 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n return { type: "send_agent_message_request", requestId: request.requestId, + text: request.text, + attachments: request.attachments, agentId: request.agentId, activeTurnBehavior: typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined, @@ -74,6 +78,7 @@ export async function gateNextAgentMessage(page: Page) { return { waitForRequest, + requestCount: () => requests.length, accept(index = 0) { const heldMessage = heldMessages[index]; if (!serverSocket || !heldMessage) { diff --git a/packages/app/e2e/support/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts index 63bc182eaf..c4200c7c75 100644 --- a/packages/app/e2e/support/helpers/assistant-images.ts +++ b/packages/app/e2e/support/helpers/assistant-images.ts @@ -87,10 +87,11 @@ export async function emitSettledAssistantImage( client: SeedDaemonClient, agent: ArchiveTabAgent, image: AssistantImageFixture, + destination: string = image.relativePath, ): Promise { await client.sendAgentMessage( agent.id, - `Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`, + `Emit settled assistant image Markdown: ![${image.alt}](${destination})`, ); const result = await client.waitForFinish(agent.id, 30_000); if (result.status !== "idle" || result.final?.lastError) { diff --git a/packages/app/e2e/support/helpers/codex-workspace-restore.ts b/packages/app/e2e/support/helpers/codex-workspace-restore.ts index 7595b4eb20..45bdc1aded 100644 --- a/packages/app/e2e/support/helpers/codex-workspace-restore.ts +++ b/packages/app/e2e/support/helpers/codex-workspace-restore.ts @@ -98,7 +98,6 @@ async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, i settings: { modeId: "full-access", thinkingOptionId: "low" }, title: "Codex worktree restore reproduction", initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`, - background: true, }), ); agentId = agent.agentId; diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index ab53705477..d4ef8fc2f4 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise { await expect(composerInput(page)).not.toBeFocused(); } +/** Blur the editor without anything else taking focus. */ +export async function blurComposerInput(page: Page): Promise { + await composerInput(page).blur(); + await expectComposerNotFocused(page); +} + export async function submitMessage(page: Page, text: string): Promise { const input = composerInput(page); await expect(input).toBeEditable({ timeout: 30_000 }); @@ -63,6 +69,12 @@ export async function submitMessage(page: Page, text: string): Promise { await input.press("Enter"); } +/** The Send button works on compact screens, where Enter inserts a newline. */ +export async function submitMessageWithButton(page: Page, text: string): Promise { + await fillComposerDraft(page, text); + await page.getByRole("button", { name: "Send message", exact: true }).click(); +} + export async function fillComposerDraft(page: Page, text: string): Promise { await composerInput(page).fill(text); } @@ -310,3 +322,182 @@ export async function openGithubWorkspace( }, }; } + +const COMPOSER_TEST_IMAGE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + +export async function attachComposerTestImage(page: Page): Promise { + await attachImageFromMenu(page, { + name: "fullscreen.png", + mimeType: "image/png", + buffer: Buffer.from(COMPOSER_TEST_IMAGE_BASE64, "base64"), + }); +} + +export async function pasteComposerTestImage(page: Page): Promise { + return composerInput(page).evaluate((input, base64) => { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + const clipboardData = new DataTransfer(); + clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" })); + const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }); + input.dispatchEvent(event); + return event.defaultPrevented; + }, COMPOSER_TEST_IMAGE_BASE64); +} + +export async function enterComposerFullscreen(page: Page): Promise { + await page.getByRole("button", { name: "Fullscreen", exact: true }).click(); +} +export async function exitComposerFullscreen(page: Page): Promise { + await page.getByRole("button", { name: "Exit fullscreen", exact: true }).click(); +} +export async function resizeComposerViewport( + page: Page, + orientation: "portrait" | "landscape" | "short", +): Promise { + const sizes = { + portrait: { width: 390, height: 844 }, + landscape: { width: 844, height: 390 }, + short: { width: 390, height: 390 }, + }; + await page.setViewportSize(sizes[orientation]); + // Before the app has loaded there is no layout to wait for. + if (page.url() === "about:blank") return; + // Crossing the compact breakpoint remounts the shell and the editor. Anything that + // follows (a shortcut, a locator) belongs to the layout the new size implies, so + // wait for that layout before returning. + await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1); +} +export async function selectComposerDraftRange( + page: Page, + start: number, + end: number, +): Promise { + await composerInput(page).focus(); + await composerInput(page).evaluate( + (element, selection) => { + const input = element as HTMLTextAreaElement; + input.setSelectionRange(selection.start, selection.end); + input.dispatchEvent(new Event("select", { bubbles: true })); + }, + { start, end }, + ); +} +export async function expectComposerDraftSelection( + page: Page, + start: number, + end: number, +): Promise { + await expect + .poll(() => + composerInput(page).evaluate((element) => { + const input = element as HTMLTextAreaElement; + return [input.selectionStart, input.selectionEnd]; + }), + ) + .toEqual([start, end]); +} +export async function expectComposerFullscreenControls(page: Page): Promise { + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible(); + await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2); + await expect(composerInput(page)).toHaveCount(1); +} +/** The fullscreen frame and its submit button sit inside the browser's visible viewport. */ +export async function expectComposerFullscreenFitsVisibleViewport( + page: Page, + submitLabel = "Send message", +): Promise { + await expect + .poll(() => + page.getByTestId("composer-fullscreen").evaluate((element, label) => { + const viewport = window.visualViewport!; + const frame = element.getBoundingClientRect(); + const send = element.querySelector(`[aria-label="${label}"]`)!.getBoundingClientRect(); + return { + frameFits: + Math.abs(frame.top - viewport.offsetTop) < 1 && + Math.abs(frame.left - viewport.offsetLeft) < 1 && + Math.abs(frame.height - viewport.height) < 1 && + Math.abs(frame.width - viewport.width) < 1, + sendFits: + send.top >= viewport.offsetTop && + send.bottom <= viewport.offsetTop + viewport.height && + send.left >= viewport.offsetLeft && + send.right <= viewport.offsetLeft + viewport.width, + }; + }, submitLabel), + ) + .toEqual({ frameFits: true, sendFits: true }); +} +export async function sendComposerWithButton(page: Page): Promise { + await page.getByRole("button", { name: "Send message", exact: true }).click(); +} +export async function queueComposerWithButton(page: Page): Promise { + await page.getByRole("button", { name: "Queue message", exact: true }).click(); +} +export async function configureComposerQueueSend(page: Page): Promise { + await page.addInitScript(() => + localStorage.setItem("@paseo:app-settings", JSON.stringify({ sendBehavior: "queue" })), + ); +} + +/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface. + * This exercises retained activity changes, not a claim that covered chrome is clickable. + */ +/** Needs a desktop-width viewport: compact layouts have no keyboard shortcuts. */ +export async function switchComposerTabWithKeyboard( + page: Page, + direction: "previous" | "next", +): Promise { + await page.keyboard.press( + direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight", + ); +} +export async function expectWorkspaceHeaderHidden(page: Page): Promise { + await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0); +} +export async function expectWorkspaceHeaderVisible(page: Page): Promise { + await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible(); +} + +export async function activateRetainedComposerTab(page: Page, title: string): Promise { + await page + .getByTestId("workspace-tab-switcher-trigger") + .evaluate((element) => (element as HTMLElement).click()); + const option = page.getByText(title, { exact: true }).filter({ visible: true }).last(); + await expect(option).toBeVisible(); + await option.evaluate((element) => (element as HTMLElement).click()); +} + +export async function recordVisibleComposerGeometry(page: Page): Promise { + await page.evaluate(() => { + const frames: { input: number; root: number }[] = []; + Reflect.set(window, "__composerGeometryFrames", frames); + let running = true; + const tick = () => { + if (!running) return; + for (const root of document.querySelectorAll('[data-testid="message-input-root"]')) { + if (root.getClientRects().length === 0) continue; + const input = root.querySelector("textarea"); + if (input) + frames.push({ + input: input.getBoundingClientRect().height, + root: root.getBoundingClientRect().height, + }); + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + Reflect.set(window, "__stopComposerGeometry", () => { + running = false; + }); + }); +} +export async function expectNoCollapsedVisibleComposer(page: Page): Promise { + const frames = await page.evaluate(() => { + Reflect.get(window, "__stopComposerGeometry")?.(); + return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[]; + }); + expect(frames.length).toBeGreaterThan(0); + expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]); +} diff --git a/packages/app/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts index 2b76a78e85..21341c7766 100644 --- a/packages/app/e2e/support/helpers/context-window.ts +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -26,38 +26,50 @@ export async function openAgent(page: Page, session: MockAgentSession): Promise< } export async function reloadAgent(page: Page): Promise { - await page.reload({ waitUntil: "commit" }); + // Reload must not reopen the card under a stationary pointer. + await page.mouse.move(0, 0); + await page.reload({ waitUntil: "domcontentloaded" }); await expectComposerVisible(page); } -/** Wide screens show the meter's details in a tooltip while the pointer is on it. */ -export async function hoverContextWindowMeter(page: Page): Promise { - await page.getByRole("img", { name: METER_NAME }).hover({ timeout: 30_000 }); - const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" }); - await expect(tooltip).toBeVisible(); - return tooltip; +/** Wide screens show the meter's details in a hover card while the pointer is on it. */ +export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Promise { + await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 }); + const card = contextWindowDetails(page); + await expect(card).toBeVisible(); + await expect(card).toBeInViewport(); + return card; +} + +/** Walks the pointer from the meter up onto its hover card, across the gap between them. */ +export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise { + const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox(); + const box = await card.boundingBox(); + if (!meter || !box) throw new Error("The meter and its hover card must be on screen."); + await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 }); } export async function leaveContextWindowMeter(page: Page): Promise { await page.mouse.move(0, 0); - await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0); + await expect(contextWindowDetails(page)).toHaveCount(0); } /** Compact screens open the meter's details in a sheet. */ -export async function pressContextWindowMeter(page: Page): Promise { - await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 }); - const sheet = contextWindowSheet(page); +export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise { + await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 }); + const sheet = contextWindowDetails(page); await expect(sheet).toBeVisible(); return sheet; } export async function closeContextWindowSheet(page: Page): Promise { - const sheet = contextWindowSheet(page); + const sheet = contextWindowDetails(page); await sheet.getByRole("button", { name: "Close", exact: true }).click(); await expect(sheet).toHaveCount(0); } -export function contextWindowSheet(page: Page): Locator { +/** The hover card on wide screens and the sheet on compact ones are both this dialog. */ +export function contextWindowDetails(page: Page): Locator { return page.getByRole("dialog", { name: "Context window" }); } @@ -122,8 +134,8 @@ export interface AgentUsageScript { stopReportingUsage(): void; /** The agent of each agent usage request, in order. */ agentRequests(): string[]; - /** Each report Refresh, by report ID. */ - refreshedReports(): string[][]; + /** Each scoped agent Refresh, by agent ID. */ + refreshedAgents(): string[]; } /** @@ -150,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise { supported = false; }, agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []), - refreshedReports: () => - usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])), + refreshedAgents: () => + usage + .listRequests() + .flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])), }; } @@ -159,7 +173,7 @@ export async function scriptAgentUsage(page: Page): Promise { export async function qaScreenshot(page: Page, name: string): Promise { const directory = process.env.PASEO_QA_SCREENSHOT_DIR; if (!directory) return; - // Let the sheet and tooltip animations settle. + // Let the sheet and hover card animations settle. await page.waitForTimeout(600); await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); await page.screenshot({ path: path.join(directory, `${name}.png`) }); diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts index 09cd2ea290..9098068698 100644 --- a/packages/app/e2e/support/helpers/creation.ts +++ b/packages/app/e2e/support/helpers/creation.ts @@ -182,6 +182,47 @@ export async function createCreationScenario(page: Page) { await this.expectWorkspaceReadyBeforeAgentCompletion(); expect((await project.client.fetchAgents()).entries).toHaveLength(0); }, + async expectCreatedAgentError() { + await requests.settled(); + await expect( + page + .getByTestId("assistant-message") + .filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }), + ).toBeVisible(); + await expect( + page + .locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]') + .filter({ visible: true }), + ).toBeVisible(); + await expect( + page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }), + ).toHaveCount(0); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEditable(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + }, + async expectPromptBeforeError(prompt: string) { + const messages = page + .getByTestId("user-message") + .or(page.getByTestId("assistant-message")) + .filter({ visible: true }); + await expect(messages).toHaveCount(2); + await expect(messages.nth(0)).toContainText(prompt); + await expect(messages.nth(1)).toContainText("[System Error]"); + await expect(messages.nth(1)).toBeInViewport(); + }, + async reloadAgent() { + await page.reload(); + await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0); + await this.expectCreatedAgentError(); + }, + async expectAssistantReply() { + await expect( + page + .getByTestId("assistant-message") + .filter({ visible: true, hasNotText: "[System Error]" }) + .first(), + ).toBeVisible(); + }, async expectStartupFailure() { await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible(); }, diff --git a/packages/app/e2e/support/helpers/custom-models.ts b/packages/app/e2e/support/helpers/custom-models.ts new file mode 100644 index 0000000000..11933a0dfa --- /dev/null +++ b/packages/app/e2e/support/helpers/custom-models.ts @@ -0,0 +1,37 @@ +import { expect, type Page } from "@playwright/test"; +import { gotoAppShell, openSettings } from "./app"; +import { openSettingsHostSection } from "./settings"; +import { getServerId } from "./server-id"; + +export async function openCustomModels(page: Page): Promise { + await gotoAppShell(page); + await openSettings(page); + await openSettingsHostSection(page, getServerId(), "providers"); + await page.getByRole("button", { name: "Claude provider details", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible(); +} + +export async function addCustomModel(page: Page, id: string): Promise { + await page.getByRole("button", { name: "Add model", exact: true }).click(); + await page.getByPlaceholder("e.g. openai/gpt-5").fill(id); + await page + .getByTestId("add-custom-model-sheet") + .getByRole("button", { name: "Add", exact: true }) + .click(); + await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1); +} + +export async function removeCustomModel(page: Page, id: string): Promise { + await page.getByRole("textbox", { name: "Search models" }).fill(id); + const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true }); + await expect(remove).toBeInViewport({ ratio: 1 }); + await remove.click(); + await expect(remove).toHaveCount(0); +} + +export async function expectCustomModelRetained(page: Page, id: string): Promise { + await page.getByRole("textbox", { name: "Search models" }).fill(id); + await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({ + ratio: 1, + }); +} diff --git a/packages/app/e2e/support/helpers/file-explorer.ts b/packages/app/e2e/support/helpers/file-explorer.ts index 519f955536..a40adc5ad2 100644 --- a/packages/app/e2e/support/helpers/file-explorer.ts +++ b/packages/app/e2e/support/helpers/file-explorer.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Download, type Page } from "@playwright/test"; import { openFilesPanel } from "./workspace-tabs"; function fileExplorerTree(page: Page) { @@ -39,3 +39,10 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise { + await fileExplorerEntry(page, fileName).click({ button: "right" }); + const downloaded = page.waitForEvent("download"); + await page.getByRole("menuitem", { name: "Download", exact: true }).click(); + return downloaded; +} diff --git a/packages/app/e2e/support/helpers/pair-device.ts b/packages/app/e2e/support/helpers/pair-device.ts index d8908015b8..46cdda5ed9 100644 --- a/packages/app/e2e/support/helpers/pair-device.ts +++ b/packages/app/e2e/support/helpers/pair-device.ts @@ -220,3 +220,24 @@ export function expectDaemonPidUnchanged( expect(before).toBeDefined(); expect(after).toBe(before); } + +export async function expectCompletePairingQr(page: Page): Promise { + const qr = page.getByRole("img", { name: "Pairing QR code" }); + await qr.scrollIntoViewIfNeeded(); + // IntersectionObserver rounds fractional sheet/scroll coordinates. + await expect(qr).toBeInViewport({ ratio: 0.999 }); + const bounds = await qr.evaluate((element) => { + const tile = element.parentElement; + if (!tile) throw new Error("Pairing QR tile is missing"); + const qrRect = element.getBoundingClientRect(); + const tileRect = tile.getBoundingClientRect(); + return { + qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom }, + tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom }, + }; + }); + expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x); + expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y); + expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right); + expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom); +} diff --git a/packages/app/e2e/support/helpers/plugin-button-example.ts b/packages/app/e2e/support/helpers/plugin-button-example.ts index 94018c4415..ebe868ee2c 100644 --- a/packages/app/e2e/support/helpers/plugin-button-example.ts +++ b/packages/app/e2e/support/helpers/plugin-button-example.ts @@ -30,7 +30,6 @@ async function capture( name: string, subject: Locator = headerButtons(page), ) { - await expect(headerButtons(page)).toHaveCount(1); await expect(subject).toBeInViewport(); await waitForSettledPosition(subject); const file = info.outputPath(`${name}.png`); @@ -80,6 +79,7 @@ export async function withButtonAuthorExample( refreshFromHeaderAndComposer: () => test.step("icon-only header action and labeled composer action update in place", async () => { await exampleCommand(page, "action"); + await expect(headerButtons(page)).toHaveCount(1); await headerButtons(page).click(); await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)"); await page.getByRole("button", { name: "Refresh context", exact: true }).click(); @@ -88,7 +88,13 @@ export async function withButtonAuthorExample( ).toContainText("Refreshed · 2"); await capture(page, info, "01-actions"); await page.setViewportSize(COMPACT); - await capture(page, info, "02-compact-actions"); + await expect(headerButtons(page)).toHaveCount(0); + await capture( + page, + info, + "02-compact-actions", + page.getByRole("button", { name: "Refresh context", exact: true }), + ); }), useMenusAndToggleButtons: () => test.step("named menus include separators, nested actions, disabled items, and custom content", async () => { @@ -128,7 +134,8 @@ export async function withButtonAuthorExample( await capture(page, info, "05-disabled"); await exampleCommand(page, "enable"); await page.setViewportSize(COMPACT); - await headerButtons(page).click(); + await expect(headerButtons(page)).toHaveCount(0); + await page.getByRole("button", { name: "Composer tools", exact: true }).click(); await expect( page.getByRole("menuitem", { name: "Refresh workspace", exact: true }), ).toBeInViewport(); @@ -143,6 +150,7 @@ export async function withButtonAuthorExample( inspectLiveWorkspaceDetails: () => test.step("custom icons and content react to workspace changes and adapt to sheets", async () => { await exampleCommand(page, "popover"); + await expect(headerButtons(page)).toHaveCount(1); await headerButtons(page).click(); await expect( page.getByRole("button", { name: "Close workspace details", exact: true }), diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index 7404173fc8..f3279ce7ef 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) { await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12); } -async function holdButton(page: Page, button: Locator) { - await button.hover(); - await page.mouse.down(); -} - -async function openCompactOverflowWithHeaderChrome(page: Page) { - const hamburger = page.getByRole("button", { name: "Open menu", exact: true }); - const action = page.getByRole("button", { name: "Deploy application", exact: true }); - const overflow = page - .getByRole("toolbar", { name: "Workspace actions", exact: true }) - .getByRole("button", { name: "More actions", exact: true }); - await expect(action).toHaveCSS("border-top-width", "0px"); - await expect(overflow).toHaveCSS("border-top-width", "0px"); - await hamburger.hover(); - const highlight = await hamburger.evaluate( - (element) => getComputedStyle(element).backgroundColor, - ); - await action.hover(); - await expect(action).toHaveCSS("background-color", highlight); - await holdButton(page, overflow); - await expect(overflow).toHaveCSS("background-color", highlight); - await expectSurfaceVisible(page); - await page.mouse.up(); -} - export async function withButtonShowcase( page: Page, run: (buttons: { @@ -408,7 +383,7 @@ export async function withButtonShowcase( ); }), openCompactSheets: () => - test.step("compact triggers and overflow open sheets with working plugin context", async () => { + test.step("compact header omits plugin actions while composer triggers open sheets", async () => { await page.setViewportSize(COMPACT); await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0); await expect( @@ -426,39 +401,12 @@ export async function withButtonShowcase( "Button showcase with a long workspace title", ); await expectHeaderTextTruncated(page); - await expectSurfaceVisible(page); - await openCompactOverflowWithHeaderChrome(page); await expect( - page.getByRole("menuitem", { name: "Production locked", exact: true }), - ).toBeDisabled(); - await expectSurfaceVisible( - page, - page.getByRole("menuitem", { name: "Production locked", exact: true }), - ); - await choose(page, "Header checks"); - await expectSurfaceVisible( - page, - page.getByRole("menuitem", { name: "Run checks", exact: true }), - ); - await choose(page, "Deployment details"); - await expectDetails(page); - await expectSurfaceVisible( - page, - page.getByRole("button", { name: "Close deployment details", exact: true }), - ); - await press(page, "Close deployment details"); - await showcase.setTitle("Compact checks"); - await press(page, "Header checks"); - await expect( - page - .getByRole("button", { name: "Header checks", exact: true }) - .getByTestId("plugin-button-chevron"), + page.getByRole("toolbar", { name: "Workspace actions", exact: true }), ).toHaveCount(0); - await expectSurfaceVisible( - page, - page.getByRole("menuitem", { name: "Run checks", exact: true }), - ); - await choose(page, "Run checks"); + await expect( + page.getByRole("button", { name: "Open menu", exact: true }), + ).toBeInViewport(); await showcase.setTitle("Button showcase"); await press(page, "Composer status"); await expectDetails(page); diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts index d6656f3d3a..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/pr-pane.ts b/packages/app/e2e/support/helpers/pr-pane.ts index 56f0ef444c..37fe719e0f 100644 --- a/packages/app/e2e/support/helpers/pr-pane.ts +++ b/packages/app/e2e/support/helpers/pr-pane.ts @@ -1,5 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { getStateLabel } from "@/git/pull-request-panel/data"; +import { en } from "@/i18n/resources/en"; import { openPullRequestPanel } from "./workspace-tabs"; export async function openPrPane(page: Page): Promise { @@ -14,7 +14,7 @@ export async function expectPrPaneState( page: Page, state: "open" | "merged" | "closed" | "draft", ): Promise { - await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), { + await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], { timeout: 15_000, }); } diff --git a/packages/app/e2e/support/helpers/questions.ts b/packages/app/e2e/support/helpers/questions.ts index ebe13aead5..a26bd9a975 100644 --- a/packages/app/e2e/support/helpers/questions.ts +++ b/packages/app/e2e/support/helpers/questions.ts @@ -43,6 +43,15 @@ export async function expectQuestionOptionSelected(page: Page, option: string): await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true"); } +export async function expectQuestionOptionPreview( + page: Page, + input: { option: string; preview: string }, +): Promise { + await expect( + questionOption(page, input.option).getByTestId("question-form-option-preview"), + ).toContainText(input.preview); +} + export async function openQuestion( page: Page, input: { index: number; total: number }, diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 869e96b294..4aec3fff01 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -66,6 +66,7 @@ export interface SeedDaemonClient { worktreeSlug?: string; }; title?: string; + background?: boolean; }): Promise<{ workspace: SeedWorkspaceDescriptor | null; error: string | null; @@ -223,6 +224,7 @@ export interface SeededWorkspace { export async function seedWorkspace(options: { repoPrefix: string; title?: string; + background?: boolean; port?: number; /** Repo fixture options; only applies to git projects (the default). */ repo?: Parameters[1]; @@ -238,6 +240,7 @@ export async function seedWorkspace(options: { const created = await client.createWorkspace({ source: { kind: "directory", path: project.path }, title: options.title, + background: options.background, }); if (!created.workspace) { throw new Error(created.error ?? `Failed to create workspace ${project.path}`); diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index b707abdae8..e3eff916e7 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01; * Settings together at the end. */ export async function expectFooterIconRow(page: Page): Promise { - const boxes = ( - await Promise.all( - FOOTER_ICON_TEST_IDS.map((testID) => - page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(), - ), - ) - ).map((box) => box!); + // Read one browser-frame snapshot: the compact drawer can translate between separate RPCs. + const boxes = await page + .locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(",")) + .evaluateAll((elements) => + elements.map((element) => { + const { x, y, width, height } = element.getBoundingClientRect(); + return { testID: element.getAttribute("data-testid"), x, y, width, height }; + }), + ); + expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS); const [first] = boxes; for (const box of boxes) { expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2); diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index c2afc34ef8..500ce8bd41 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -53,6 +53,37 @@ export async function showWorkspaceHoverCard(page: Page, workspaceId: string): P await expect(page.getByTestId("workspace-hover-card")).toBeVisible(); } +export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise { + // Two moves, so the pointer always moves even when it is already parked here. + await page.mouse.move(1200, 200, { steps: 5 }); + await page.mouse.move(1200, 400, { steps: 5 }); +} + +export async function focusWorkspaceRowWithKeyboard( + page: Page, + workspaceId: string, +): Promise { + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); + await row.focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(row).toBeFocused(); +} + +export async function expectWorkspaceHoverCardOpen(page: Page): Promise { + await expect(page.getByTestId("workspace-hover-card")).toBeVisible(); +} + +export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise { + // Longer than the hover card's close grace period. + await page.waitForTimeout(300); + await expectWorkspaceHoverCardOpen(page); +} + +export async function expectWorkspaceHoverCardClosed(page: Page): Promise { + await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0); +} + export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise { const workspaceKey = `${getServerId()}:${workspaceId}`; await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click(); @@ -219,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise export async function expectMobileAgentSidebarHidden(page: Page): Promise { await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 }); } + +/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */ +export async function swipeOpenMobileAgentSidebar(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(8, y); + await page.mouse.down(); + await page.mouse.move(40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); + await page.mouse.up(); +} + +/** Drag from the right edge to open the compact Explorer over the agent. */ +export async function swipeOpenMobileExplorer(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(viewport.width - 8, y); + await page.mouse.down(); + await page.mouse.move(viewport.width - 40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 }); + await page.mouse.up(); +} + +export async function closeMobileExplorer(page: Page): Promise { + const closeButton = page.getByTestId("explorer-close"); + await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 }); + await closeButton.click(); +} + +export async function expectMobileExplorerVisible(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 }); +} + +export async function expectMobileExplorerHidden(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 }); +} diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts index d9c59e702e..f3a5ca7c71 100644 --- a/packages/app/e2e/support/helpers/subagents.ts +++ b/packages/app/e2e/support/helpers/subagents.ts @@ -49,31 +49,37 @@ export async function seedParentWithSubagent( modeId: "load-test", model: "ten-second-stream", }); - const child = await workspace.client.createAgent({ - provider: "mock", - cwd: workspace.repoPath, - workspaceId: workspace.workspaceId, - title: input.childTitle, - modeId: "load-test", - model: "ten-second-stream", - labels: { - [PARENT_AGENT_ID_LABEL]: parent.id, - }, - }); + const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle }); return { parent: { id: parent.id, title: input.parentTitle, }, - child: { - id: child.id, - title: input.childTitle, - }, + child, workspaceId: workspace.workspaceId, }; } +/** Seeds an idle Paseo subagent of an existing agent in the same workspace. */ +export async function seedSubagent( + workspace: Pick, + input: { parentId: string; title: string }, +): Promise<{ id: string; title: string }> { + const child = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: input.title, + modeId: "load-test", + model: "ten-second-stream", + labels: { + [PARENT_AGENT_ID_LABEL]: input.parentId, + }, + }); + return { id: child.id, title: input.title }; +} + export async function seedParentWithCrossWorkspaceSubagent( workspace: Pick, input: { parentTitle: string; childTitle: string }, @@ -141,6 +147,12 @@ export async function openSubagentsTrack(page: Page): Promise { await expect(panel).toBeVisible({ timeout: 30_000 }); } +/** On compact the track opens as a sheet, which closes once a row is chosen. */ +export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise { + await page.getByTestId("subagents-track-header").click(); + await page.getByTestId(`subagents-track-row-${childId}`).click(); +} + export async function expectSubagentRowVisible(page: Page, childId: string): Promise { await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({ timeout: 30_000, diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 234ec2bf1b..6129f0259e 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -8,12 +8,15 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; /** A real store-backed plugin on the worker's isolated daemon. */ -export async function installLoginUsage(initialReport: UsageReport) { +type LoginReports = Array<{ harness: string; report: UsageReport }>; + +export async function installLoginUsage(initialReport: UsageReport | LoginReports) { const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); const store = path.join(directory, "report.json"); - const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report)); + const setReport = (report: UsageReport | LoginReports) => + writeFile(store, JSON.stringify(report)); const cleanup = async () => { try { await client.removePlugin("login-journey"); @@ -35,9 +38,13 @@ export async function installLoginUsage(initialReport: UsageReport) { import {readFile} from "node:fs/promises"; import {z} from "zod"; export default function contribute(server) { - server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}), - discover: async () => [{key: "account", input: {}}], - fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")), + const read = async () => { + const value = JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")); + return Array.isArray(value) ? value : [{harness: "Claude", report: value}]; + }; + server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({index: z.number()}), + discover: async () => (await read()).map((login, index) => ({key: "account", harness: login.harness, input: {index}})), + fetch: async ({index}) => (await read())[index].report, }); return () => {}; }`, @@ -51,10 +58,6 @@ export default function contribute(server) { } } -export async function openUsage(page: Page) { - await page.goto("/usage"); -} - export async function refreshLoginUsage(page: Page) { await page.getByRole("button", { name: "Refresh Claude", exact: true }).click(); } diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 7b012c171b..8cc3c07a00 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -6,8 +6,9 @@ import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expect, type Locator, type Page } from "@playwright/test"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; +import { waitForSettledPosition } from "./sheet-layout"; -/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */ +/** Real usage-source plugin; its long report makes the Usage modal scroll. */ export async function installTallUsageSource() { const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); @@ -141,13 +142,9 @@ export function usageItem(page: Page): Locator { return visible(page, "sidebar-usage"); } -/** The compact usage sheet the Usage item opens. */ -export function usageSheet(page: Page): Locator { - return visible(page, "usage-expanded"); -} - -export async function expectOnUsageScreen(page: Page): Promise { - await expect(page).toHaveURL(/\/usage$/); +/** The Usage modal's body: a dialog on wide layouts, a bottom sheet on compact ones. */ +export function usageModal(page: Page): Locator { + return visible(page, "usage-modal-body"); } /** Each pinned window as it reads: its percent and short label, "31% 5h". */ @@ -188,17 +185,54 @@ export async function togglePin(scope: Locator, source: string, window: string) await expect(row).toBeChecked({ checked: !pinned }); } -/** The footer's Usage icon, which is there whether or not the Usage item is on. */ -export async function openUsageScreenFromIcon(page: Page): Promise { +/** + * The footer's Usage icon, which is there whether or not the Usage item is on. On a phone the + * sidebar drawer has to be open. + */ +export async function openUsageFromIcon(page: Page): Promise { await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 }); - await expectOnUsageScreen(page); + await expect(usageModal(page)).toBeVisible(); +} + +/** The footer's Usage item, its summary of pinned windows. */ +export async function openUsageFromItem(page: Page): Promise { + await usageItem(page).click(); + await expect(usageModal(page)).toBeVisible(); +} + +/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */ +export async function scrollUsage(page: Page, deltaY: number): Promise { + const body = await usageModal(page).boundingBox(); + const viewport = page.viewportSize(); + if (!body || !viewport) throw new Error("Usage must be open before scrolling it."); + // The middle of the part of the body on screen; the body itself runs past the viewport. + const top = Math.max(body.y, 0); + const bottom = Math.min(body.y + body.height, viewport.height); + await page.mouse.move(body.x + body.width / 2, (top + bottom) / 2); + await page.mouse.wheel(0, deltaY); +} + +/** The modal's own Close button. */ +export async function closeUsage(page: Page): Promise { + await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click(); + await expect(usageModal(page)).toHaveCount(0); +} + +/** A tap on the backdrop above the bottom sheet. */ +export async function closeUsageFromBackdrop(page: Page): Promise { + const sheet = usageModal(page); + await waitForSettledPosition(sheet); + const bounds = await visible(page, "usage-modal").boundingBox(); + if (!bounds) throw new Error("Usage must be open before closing it."); + await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2); + await expect(sheet).toHaveCount(0); } function summaryInSidebarSwitch(page: Page): Locator { return page.getByRole("switch", { name: "Summary in sidebar", exact: true }); } -/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */ +/** Turns the sidebar Usage summary on or off from the Usage modal's Settings. */ export async function setSummaryInSidebar(page: Page, on: boolean): Promise { await openUsageOptions(page); await summaryInSidebarSwitch(page).click(); @@ -208,15 +242,39 @@ export async function setSummaryInSidebar(page: Page, on: boolean): Promise { await openUsageOptions(page); await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on }); + await closeUsageOptions(page); } -/** Expand the inline Settings row when its controls are folded. */ +/** Open the cog's settings popover or compact sheet. */ export async function openUsageOptions(page: Page): Promise { - const toggle = visible(page, "usage-options-toggle"); - if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click(); + if (!(await visible(page, "usage-display-used").isVisible())) { + await visible(page, "usage-options-menu").click(); + } await expect(visible(page, "usage-display-used")).toBeVisible(); } +export function usageOptionsSurface(page: Page): Locator { + return page + .locator( + '[data-testid="usage-options-surface"]:visible, [data-testid="usage-options-surface-content"]:visible', + ) + .first(); +} + +export async function expectUsageOptionsFitContent(page: Page): Promise { + await waitForSettledPosition(usageOptionsSurface(page)); + const surface = await usageOptionsSurface(page).boundingBox(); + const fields = await page.getByTestId("usage-options-fields").boundingBox(); + if (!surface || !fields) throw new Error("Settings must be visible before measuring its fit."); + expect(surface.y + surface.height - (fields.y + fields.height)).toBeLessThanOrEqual(10); +} + +export async function closeUsageOptions(page: Page): Promise { + await waitForSettledPosition(usageOptionsSurface(page)); + await page.mouse.click(0, 0); + await expect(page.getByTestId("usage-display-used")).toHaveCount(0); +} + export async function showUsageAs(page: Page, displayAs: "used" | "remaining") { await openUsageOptions(page); await visible(page, `usage-display-${displayAs}`).click(); @@ -224,6 +282,7 @@ export async function showUsageAs(page: Page, displayAs: "used" | "remaining") { "aria-selected", "true", ); + await closeUsageOptions(page); } export async function refreshAllUsage(page: Page): Promise { @@ -234,9 +293,3 @@ export async function refreshAllUsage(page: Page): Promise { export async function openCompactSidebar(page: Page): Promise { await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); } - -/** On a phone the Usage screen has a back header instead of the sidebar menu. */ -export async function leaveUsageScreen(page: Page): Promise { - await page.getByRole("button", { name: "Back", exact: true }).first().click(); - await expect(page).not.toHaveURL(/\/usage$/); -} diff --git a/packages/app/e2e/support/helpers/workspace-sheet.ts b/packages/app/e2e/support/helpers/workspace-sheet.ts new file mode 100644 index 0000000000..b0544f7df6 --- /dev/null +++ b/packages/app/e2e/support/helpers/workspace-sheet.ts @@ -0,0 +1,98 @@ +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 header starts below the sheet's drag indicator, inside the handle. */ +const SHEET_INDICATOR_MAX_HEIGHT = 32; + +/** + * The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it + * covers the workspace header; a sheet sunk below it hides content. + */ +export async function 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); + const header = sheet.getByTestId("workspace-sheet-header"); + const body = sheet.getByTestId("workspace-sheet-body"); + await expect + .poll(async () => { + const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]); + if (!headerBox || !bodyBox) return null; + const top = Math.round(headerBox.y); + return { + dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight, + topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT, + }; + }) + .toEqual({ dockedToBottom: true, topAtSnap: true }); +} + +export async function expectOpenTabCount(page: Page, count: number): Promise { + await expect( + page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }), + ).toBeVisible(); +} + +export async function 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); +} + +/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */ +export async function tapWorkspaceSheetBackdrop(page: Page): Promise { + await page + .getByRole("button", { name: "Bottom sheet backdrop" }) + .last() + .click({ position: { x: 10, y: 10 } }); +} + +/** Runs a close that hits the unsaved-changes guard, and declines the discard. */ +export async function expectDeclinedDiscardPrompt( + page: Page, + close: () => Promise, +): Promise { + const prompt = page.waitForEvent("dialog").then(async (dialog) => { + const message = dialog.message(); + await dialog.dismiss(); + return message; + }); + await close(); + expect(await prompt).toContain("discard the draft"); +} diff --git a/packages/app/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 a44656bc18..84af04dee4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.11.0-beta.3", + "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/public/index.html b/packages/app/public/index.html index f7116fc86b..a95e7b8c9e 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -70,6 +70,15 @@ -webkit-app-region: no-drag !important; } + /* Electron does not clip app-region rectangles to scroll viewports. + Content-only viewports emit no window regions, including from their + controls. Visible controls here use normal DOM hit testing. + Use initial: Blink parses the explicit value none as no-drag. */ + [data-window-content], + [data-window-content] * { + -webkit-app-region: initial !important; + } + /* Suppress the browser's default focus outline — it appears on mouse clicks and looks out of place against our themed surfaces. Keep a themed ring for keyboard users via :focus-visible. */ diff --git a/packages/app/src/agent-controls/labels.test.ts b/packages/app/src/agent-controls/labels.test.ts index 4f1302fd1b..7310464896 100644 --- a/packages/app/src/agent-controls/labels.test.ts +++ b/packages/app/src/agent-controls/labels.test.ts @@ -29,4 +29,15 @@ describe("formatThinkingOptionLabel", () => { expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard"); expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high"); }); + + it("preserves provider-supplied xhigh labels with extra detail", () => { + expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (56 | 8m)" })).toBe( + "X high (56 | 8m)", + ); + expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (45 | 6m)" })).toBe( + "X high (45 | 6m)", + ); + expect(formatThinkingOptionLabel({ id: "xhigh" })).toBe("Extra high"); + expect(formatThinkingOptionLabel({ id: "xhigh", label: "Extra high" })).toBe("Extra high"); + }); }); diff --git a/packages/app/src/agent-controls/labels.ts b/packages/app/src/agent-controls/labels.ts index ff32aaf4dc..704298f78c 100644 --- a/packages/app/src/agent-controls/labels.ts +++ b/packages/app/src/agent-controls/labels.ts @@ -34,10 +34,9 @@ export function formatAgentModeLabel(mode: ControlLabelInput): string { export function formatThinkingOptionLabel(option: ControlLabelInput): string { const rawLabel = (option.label ?? option.id).trim(); - const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase(); const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase(); - if (compactId === "xhigh" || compactLabel === "xhigh") { + if (compactLabel === "xhigh" || compactLabel === "extrahigh") { return i18n.t("agentControls.thinking.extraHigh"); } diff --git a/packages/app/src/agent-controls/policy.ts b/packages/app/src/agent-controls/policy.ts index 827dfadf57..89a4a86daa 100644 --- a/packages/app/src/agent-controls/policy.ts +++ b/packages/app/src/agent-controls/policy.ts @@ -2,6 +2,7 @@ import type { AgentMode } from "@getpaseo/protocol/agent-types"; export const PLAN_MODE_FEATURE_ID = "plan_mode"; export const FAST_MODE_FEATURE_ID = "fast_mode"; +export const SPEED_FEATURE_ID = "service_tier"; export function isPlanningAgentMode(mode: Pick): boolean { return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan"); diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 54160ebcb9..70ed8862ae 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -11,7 +11,7 @@ import { type UserMessageItem, } from "@/types/stream"; import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model"; -import { createStreamPresentation } from "./presentation"; +import { createStreamPresentation, getStreamItemMessageId } from "./presentation"; import { buildAgentStreamRenderModel } from "./model"; const runtime = { @@ -29,7 +29,7 @@ const runtime = { } as unknown as PluginClientRuntime; function installProbe( - itemType: "assistant_message" | "tool_call" | "reasoning", + itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning", condition = "true", ): InstalledPlugin { const clientBundle = `(function() { @@ -160,6 +160,23 @@ describe("stream presentation through installed plugins", () => { }, ); + it("addresses a prompt a plugin replaced by the prompt's own id", () => { + const harness = streamHarness(installedTransform(installProbe("user_message"))); + const rendered = rows( + harness.send({ + type: "timeline", + provider: "claude", + item: { type: "user_message", messageId: "prompt-1", text: "Earlier prompt" }, + }), + ); + const source = rows(harness.source()); + + expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]); + expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]); + // The chat outline jumps to the source id; the row it lands on is the plugin's. + expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]); + }); + it("finishes one message before streaming a different message ID", () => { const harness = streamHarness(installedTransform(installProbe("assistant_message"))); harness.send(assistant("First\n\nBody", "first")); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 8300996348..b89f91f6c0 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -26,10 +26,14 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] { /** * The source message a display row belongs to. Every assistant message is split into * Markdown blocks, so an assistant row id never equals its message id; anything that - * addresses a message — find, scroll-to-message, history reveal — asks for this. + * addresses a message — find, scroll-to-message, history reveal — asks for this. A + * plugin row a timeline transformer projected answers for the item it replaced, whose + * id is what the chat outline and find hold. */ export function getStreamItemMessageId(item: StreamItem): string { - return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id; + if (item.kind === "assistant_message") return item.blockGroupId ?? item.id; + if (item.kind === "plugin") return item.sourceId ?? item.id; + return item.id; } /** diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 47f2766a30..65f535db11 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -42,6 +42,7 @@ import { type HistoryStartPaginationInput, type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { useStreamTopOverlayInset } from "./top-overlay"; import { createHistoryStartSettleScheduler, type HistoryStartSettleScheduler, @@ -274,12 +275,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom" ? undefined : DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION; + const topOverlayInset = useStreamTopOverlayInset(); + // The list is inverted, so its content's bottom padding is the visual top. const listContentContainerStyle = useMemo( () => [ baseListContentContainerStyle, - { paddingBottom: streamKeyboardInset.contentContainerPaddingBottom }, + { + paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset, + }, + ], + [ + baseListContentContainerStyle, + streamKeyboardInset.contentContainerPaddingBottom, + topOverlayInset, ], - [baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom], ); const listInsetProps = useMemo( () => diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 9078b0f1c3..1f61d0a14c 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -32,7 +32,7 @@ vi.mock("react-native-unistyles", () => ({ withUnistyles: (Component: React.ComponentType) => Component, })); -function userMessage(index: number): StreamItem { +function userMessage(index: number): Extract { return { kind: "user_message", id: `message-${index}`, @@ -55,6 +55,30 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers { }; } +function MeasuredTestRow({ item }: { item: StreamItem }) { + const [loaded, setLoaded] = React.useState(false); + const load = React.useCallback(() => setLoaded(true), []); + const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24; + const measure = React.useCallback( + (node: HTMLDivElement | null) => { + if (!node?.parentElement) return; + Object.defineProperty(node.parentElement, "offsetHeight", { + configurable: true, + value: height, + }); + }, + [height], + ); + return ( +
+ + {item.id} +
+ ); +} + describe("createWebStreamStrategy", () => { let root: Root | null = null; let container: HTMLDivElement | null = null; @@ -155,6 +179,75 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); + it("retains a row's DOM and local state when recent history becomes virtualized", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const item = userMessage(0); + const renderRow = (rowItem: StreamItem) => ; + const input: StreamRenderInput = { + agentId: "agent", + segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] }, + boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false }, + renderers: { + renderHistoryVirtualizedRow: renderRow, + renderHistoryMountedRow: renderRow, + renderLiveHeadRow: renderRow, + renderLiveAuxiliary: () => null, + }, + listEmptyComponent: null, + viewportRef: React.createRef(), + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: () => {}, + onNearHistoryStart: () => true, + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }; + act(() => root?.render(strategy.render(input))); + const row = container.querySelector('[data-history-row-id="message-0"]')!; + act(() => row.querySelector("button")!.click()); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!; + Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 }); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] }, + boundary: { ...input.boundary, hasVirtualizedHistory: true }, + }), + ), + ); + expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row); + expect(row.isConnected).toBe(true); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { + historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)], + historyMounted: [userMessage(2)], + liveHead: [], + }, + boundary: { ...input.boundary, hasVirtualizedHistory: true }, + }), + ), + ); + expect( + (container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform, + ).toBe("translateY(120px)"); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + }); + it("keeps the timeline width stable with an overlay scrollbar", () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); container = document.createElement("div"); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 25e7a313d2..226b7c1e2a 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; +import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; @@ -76,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = { flexShrink: 0, }; +const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" }; + const streamRowStyle: CSSProperties = { display: "flex", flexDirection: "column", @@ -268,6 +271,23 @@ function isScrollContainerOverscrolledPastBottom( return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX; } +// Position updates do not change a row's content. Keep renderer work separate +// from virtual measurements while retaining the same component across lanes. +const StreamRowContent = React.memo(function StreamRowContent({ + item, + index, + items, + render, +}: { + revision?: StreamRenderInput["liveHeadRowRevision"]; + item: StreamItem; + index: number; + items: StreamItem[]; + render: StreamRenderInput["renderers"]["renderHistoryVirtualizedRow"]; +}) { + return render(item, index, items); +}); + function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { segments: inputSegments, @@ -425,6 +445,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, enabled: shouldUseVirtualizer, + initialRect: { + width: scrollContainerRef.current?.clientWidth ?? 0, + height: scrollContainerRef.current?.clientHeight ?? 0, + }, getScrollElement: () => scrollContainerRef.current, getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { @@ -536,25 +560,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduler.schedule(); }); - // ResizeObserver runs before paint. Batch measurements and commit React's new - // positions here so a resized image cannot overlap rows for an extra frame. - // Use resizeItem exclusively; measureElement would install a second observer. + const measureVirtualizedRows = useStableEvent(() => { + let resized = false; + for (const element of contentRef.current?.querySelectorAll( + "[data-history-row-id][data-index]", + ) ?? []) { + const index = Number(element.dataset.index); + const height = measureVirtualElement(element, undefined, rowVirtualizer); + if (rowVirtualizer.measurementsCache[index]?.size === height) continue; + rowVirtualizer.resizeItem(index, height); + resized = true; + } + return resized; + }); + + // Measure the committed DOM rather than queued observer entries: flushSync can + // also commit an image's pending intrinsic dimensions, invalidating those entries. const virtualRowObserver = useMemo( () => - new ResizeObserver((entries) => { + new ResizeObserver(() => { if (!isActiveRef.current) return; flushSync(() => { - for (const entry of entries) { - const element = entry.target as HTMLElement; - if (!element.isConnected) continue; - rowVirtualizer.resizeItem( - Number(element.dataset.index), - measureVirtualElement(element, entry, rowVirtualizer), - ); - } + measureVirtualizedRows(); }); }), - [rowVirtualizer], + [measureVirtualizedRows], ); const measureVirtualizedRowElement = useCallback( (node: HTMLDivElement | null) => { @@ -731,8 +761,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool // Apply row positions and their scroll correction in the same paint, including // the ordinary-to-virtualized handoff and measurements of newly mounted rows. useLayoutEffect(() => { + // Commit positions for every resized row before accepting reading geometry. + // Child layout can change during this commit, ahead of ResizeObserver delivery. + if (!isActive || measureVirtualizedRows()) return; reconcileReadingPosition(); - }, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]); + }, [ + contentWidth, + isActive, + segments, + virtualRows, + measureVirtualizedRows, + reconcileReadingPosition, + ]); const stopFollowingOutputFromUserIntent = useStableEvent(() => { cancelPendingStickToBottom(); @@ -915,6 +955,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool evaluateHistoryStart(); } const observer = new ResizeObserver(() => { + flushSync(() => { + measureVirtualizedRows(); + }); const nextGeometry = getObservedViewportGeometry(scrollContainer); if (pendingResumeGeometryCheckRef.current) { pendingResumeGeometryCheckRef.current = false; @@ -953,6 +996,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduleHistoryStartPrependSettle, scheduleStickToBottom, updateScrollMetrics, + measureVirtualizedRows, ]); useLayoutEffect(() => { @@ -1171,13 +1215,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }), [], ); - const virtualRowsContainerStyle = useMemo((): CSSProperties => { - return { - position: "relative", - width: "100%", + const virtualRowsContainerStyle = useMemo( + (): CSSProperties => ({ height: virtualTotalSize, - }; - }, [virtualTotalSize]); + flexShrink: 0, + }), + [virtualTotalSize], + ); const renderVirtualRowStyle = useCallback( (start: number): CSSProperties => ({ position: "absolute", @@ -1198,12 +1242,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool data-message-id={getStreamItemMessageId(item)} style={streamRowStyle} > - {renderHistoryMountedRow(item, index, segments.historyMounted)} + )); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { - void liveHeadRowRevision; return segments.liveHead.map((item, index) => (
- {renderLiveHeadRow(item, index, segments.liveHead)} +
)); }, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]); @@ -1241,7 +1295,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !liveAuxiliary; return ( -
+
{historyStartSlot} - {shouldUseVirtualizer ? ( -
- {virtualRows.map((virtualRow) => { +
+
+ {[ + ...virtualRows.map((virtualRow) => { const item = segments.historyVirtualized[virtualRow.index]; - if (!item) { - return null; - } + if (!item) return null; return (
- {renderHistoryVirtualizedRow( - item, - virtualRow.index, - segments.historyVirtualized, - )} +
); - })} -
- ) : null} - {mountedRows} + }), + ...mountedRows, + ]} +
{liveAuxiliary} {shouldRenderEmpty ? listEmptyComponent : null}
diff --git a/packages/app/src/agent-stream/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({ @@ -660,7 +665,7 @@ function MobileGestureWrapper({ return ( - + {children} @@ -863,7 +868,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) { pathname === "/new" || pathname === "/sessions" || pathname === "/schedules" || - pathname === "/usage" || routeHasKnownHost); return {children}; @@ -896,7 +900,6 @@ function RootStack() { - diff --git a/packages/app/src/app/new.tsx b/packages/app/src/app/new.tsx index 281ec33555..67db3c2c96 100644 --- a/packages/app/src/app/new.tsx +++ b/packages/app/src/app/new.tsx @@ -1,6 +1,15 @@ import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { + ComposerEditingScope, + WorkspaceComposerEditingSessionsProvider, +} from "@/composer/editing-session"; import { NewWorkspaceScreen } from "@/screens/new-workspace-screen"; +import { buildNewWorkspaceDraftKey } from "@/stores/draft-keys"; + +/** New workspace drafts share one editing-session home, keyed like a tab by draft. */ +const NEW_WORKSPACE_SESSIONS_KEY = "new-workspace"; export default function NewWorkspaceRoute() { const params = useLocalSearchParams<{ @@ -23,16 +32,28 @@ export default function NewWorkspaceRoute() { draftId ?? null, ]); + // The shell remounts this route when the viewport crosses the compact breakpoint; + // the draft's editing session (fullscreen, retained caret) lives above the shell. + const draftKey = buildNewWorkspaceDraftKey(draftId); + const sessionTabIds = useMemo(() => [draftKey], [draftKey]); + return ( - + + + + + ); } diff --git a/packages/app/src/app/usage.tsx b/packages/app/src/app/usage.tsx deleted file mode 100644 index 2928cca3f3..0000000000 --- a/packages/app/src/app/usage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { UsageScreen } from "@/usage"; - -export default function UsageRoute() { - return ( - - - - ); -} diff --git a/packages/app/src/appearance/provider.tsx b/packages/app/src/appearance/provider.tsx index d52864e435..77addad9a3 100644 --- a/packages/app/src/appearance/provider.tsx +++ b/packages/app/src/appearance/provider.tsx @@ -21,6 +21,7 @@ import { } from "@/plugins/themes"; import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme"; import { applyAppearance } from "./apply"; +import { AppearanceSystemBars } from "./system-bars"; interface ContributedThemes { options: PluginThemeOption[]; @@ -107,7 +108,10 @@ export function AppearanceProvider({ children }: { children: ReactNode }) { if (!hasAppliedAppearance) return null; return ( - {children} + + + {children} + ); } diff --git a/packages/app/src/appearance/system-bars.android.tsx b/packages/app/src/appearance/system-bars.android.tsx new file mode 100644 index 0000000000..f36fb60599 --- /dev/null +++ b/packages/app/src/appearance/system-bars.android.tsx @@ -0,0 +1,11 @@ +import { SystemBars } from "react-native-edge-to-edge"; +import { withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; + +function ThemeSystemBars({ colorScheme }: Pick) { + return ; +} + +export const AppearanceSystemBars = withUnistyles(ThemeSystemBars, (theme) => ({ + colorScheme: theme.colorScheme, +})); diff --git a/packages/app/src/appearance/system-bars.tsx b/packages/app/src/appearance/system-bars.tsx new file mode 100644 index 0000000000..930579bc4a --- /dev/null +++ b/packages/app/src/appearance/system-bars.tsx @@ -0,0 +1,3 @@ +export function AppearanceSystemBars() { + return null; +} diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index cebb4ca76d..af0d07fed1 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -48,8 +48,9 @@ export const ACP_PROVIDER_ICON_SVGS = { goose: '\n \n\n', grok: '\n \n \n\n', + // Official compact mark (including ©), https://hoplite.sh/branding, retrieved 2026-10-15. hoplite: - '\n', + '\n', junie: '\n \n \n \n\n', kilo: '\n \n\n', diff --git a/packages/app/src/assets/acp-provider-icons/hoplite.svg b/packages/app/src/assets/acp-provider-icons/hoplite.svg index 4d9c443429..c7224ffa77 100644 --- a/packages/app/src/assets/acp-provider-icons/hoplite.svg +++ b/packages/app/src/assets/acp-provider-icons/hoplite.svg @@ -1 +1 @@ - + diff --git a/packages/app/src/assistant-file-links/parse.test.ts b/packages/app/src/assistant-file-links/parse.test.ts index 12921b855c..3390d0cfa9 100644 --- a/packages/app/src/assistant-file-links/parse.test.ts +++ b/packages/app/src/assistant-file-links/parse.test.ts @@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => { } expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow(); }); + + it("decodes percent-encoded relative markdown hrefs", () => { + // markdown-it percent-encodes non-ASCII characters in hrefs, so a link + // written as [x](docs/reports/开户赠金.md) arrives here percent-encoded. + expect( + parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", + path: "/Users/test/project/docs/reports/开户赠金.md", + lineStart: undefined, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded spaces in relative markdown hrefs", () => { + expect( + parseAssistantFileLink("docs/my%20file.md#L5", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "docs/my%20file.md#L5", + path: "/Users/test/project/docs/my file.md", + lineStart: 5, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded absolute hrefs with line suffixes", () => { + expect( + parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "/tmp/%E5%BC%80%E6%88%B7.md:3", + path: "/tmp/开户.md", + lineStart: 3, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded home-relative hrefs", () => { + expect( + parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "~/notes/%E5%BC%80%E6%88%B7.md", + path: "~/notes/开户.md", + lineStart: undefined, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded Windows hrefs", () => { + expect( + parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", { + workspaceRoot: "C:/repo", + decodeHref: true, + }), + ).toEqual({ + raw: "C:/repo/my%20dir/app.tsx#L8", + path: "C:/repo/my dir/app.tsx", + lineStart: 8, + lineEnd: undefined, + }); + }); + + it("keeps literal percent signs in raw paths that are not hrefs", () => { + // Inline-code and linkified tokens are raw file paths: a real file named + // "my%20file.md" must not be decoded into "my file.md". + expect( + parseAssistantFileLink("docs/my%20file.md", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "docs/my%20file.md", + path: "/Users/test/project/docs/my%20file.md", + lineStart: undefined, + lineEnd: undefined, + }); + expect( + parseAssistantFileLink("/tmp/my%20file.md:3", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "/tmp/my%20file.md:3", + path: "/tmp/my%20file.md", + lineStart: 3, + lineEnd: undefined, + }); + }); + + it("keeps non-ASCII characters in raw absolute paths", () => { + // `new URL` percent-encodes non-ASCII, so raw paths must not be routed + // through URL parsing — `/tmp/开户.md` must round-trip unchanged. + expect( + parseAssistantFileLink("/tmp/开户.md", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "/tmp/开户.md", + path: "/tmp/开户.md", + lineStart: undefined, + lineEnd: undefined, + }); + expect( + parseAssistantFileLink("/tmp/开户.md#L4", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "/tmp/开户.md#L4", + path: "/tmp/开户.md", + lineStart: 4, + lineEnd: undefined, + }); + }); }); describe("normalizeInlinePathTarget", () => { @@ -469,3 +591,23 @@ describe("normalizeInlinePathTarget", () => { }); }); }); + +describe("encoded filename line markers", () => { + it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])( + "keeps encoded line syntax in the filename %s", + (href) => { + expect( + parseAssistantFileLink(href, { + workspaceRoot: "/workspace", + decodeHref: true, + }), + ).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` }); + expect( + parseAssistantFileLink(href, { + workspaceRoot: "/workspace", + decodeHref: true, + })?.lineStart, + ).toBeUndefined(); + }, + ); +}); diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index 092f3b4dcd..0a8089acca 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([ export interface AssistantHrefParseOptions { workspaceRoot?: string; + /** + * True only when `value` is a markdown href — the renderer percent-encodes + * non-ASCII and space characters, so the path must be decoded. Raw path + * text (inline code, linkified tokens) is not encoded; decoding it would + * send a real file named `my%20file.md` to `my file.md`. + */ + decodeHref?: boolean; } export type AssistantFileLinkClassification = @@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null { }; } -function parseAssistantInlinePathLink(value: string): InlinePathTarget | null { +function parseAssistantInlinePathLink( + value: string, + options: Pick = {}, +): InlinePathTarget | null { const inlinePathTarget = parseInlinePathToken(value); if (!inlinePathTarget) { return null; } - const normalizedPath = normalizePathToken(inlinePathTarget.path); + let pathPart = inlinePathTarget.path; + if (options.decodeHref) { + pathPart = safeDecodeURIComponent(pathPart); + } + const normalizedPath = normalizePathToken(pathPart); if (!normalizedPath || !isAbsolutePath(normalizedPath)) { return null; } @@ -277,7 +291,7 @@ export function classifyAssistantFileLink( return null; } - if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) { + if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) { return { kind: "ambiguousFileCandidate", target, @@ -308,32 +322,19 @@ export function parseAssistantFileLink( return null; } - const inlinePathTarget = parseAssistantInlinePathLink(trimmed); + const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options); if (inlinePathTarget) { return inlinePathTarget; } - const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/); - if (windowsPathMatch) { - const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? ""); - if (!normalizedPath) { - return null; - } - - const lines = parseLineFragment(windowsPathMatch[2] ?? ""); - if (!lines) { - return null; - } - - return { - raw: value, - path: normalizedPath, - ...lines, - }; + const windowsTarget = parseWindowsFileLink(trimmed, value, options); + if (windowsTarget) { + return windowsTarget; } const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, { workspaceRoot: options.workspaceRoot, + decodeHref: options.decodeHref, }); if (relativeTarget) { return relativeTarget; @@ -343,19 +344,33 @@ export function parseAssistantFileLink( return null; } - let parsedUrl: URL; - try { - parsedUrl = new URL(trimmed, "http://paseo.invalid"); - } catch { - return null; + // Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments + // normalize before decode. Raw paths must not: `new URL` percent-encodes + // non-ASCII characters, which would corrupt `/tmp/开户.md` into the + // still-encoded name. + let pathPart: string; + let hashPart: string; + if (options.decodeHref) { + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmed, "http://paseo.invalid"); + } catch { + return null; + } + pathPart = safeDecodeURIComponent(parsedUrl.pathname); + hashPart = parsedUrl.hash; + } else { + const hashIndex = trimmed.indexOf("#"); + pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed; + hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : ""; } - const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname)); + const normalizedPath = normalizePathToken(pathPart); if (!normalizedPath || !isAbsolutePath(normalizedPath)) { return null; } - const lines = parseLineFragment(parsedUrl.hash); + const lines = parseLineFragment(hashPart); if (!lines) { return null; } @@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean { return isPlausibleAssistantLocalPath(path); } +function parseWindowsFileLink( + trimmed: string, + raw: string, + options: Pick = {}, +): InlinePathTarget | null { + const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/); + if (!windowsPathMatch) { + return null; + } + + let pathPart = windowsPathMatch[1] ?? ""; + if (options.decodeHref) { + pathPart = safeDecodeURIComponent(pathPart); + } + const normalizedPath = normalizePathToken(pathPart); + if (!normalizedPath) { + return null; + } + + const lines = parseLineFragment(windowsPathMatch[2] ?? ""); + if (!lines) { + return null; + } + + return { + raw, + path: normalizedPath, + ...lines, + }; +} + function parseWorkspaceRelativeFileLink( value: string, options: AssistantHrefParseOptions, ): InlinePathTarget | null { - const parsed = parseLocalPathParts(value); + const parsed = parseLocalPathParts(value, options); if (!parsed || isAbsolutePath(parsed.path)) { return null; } @@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink( function parseLocalPathParts( value: string, -): { path: string; lines: Pick } | null { + options: Pick = {}, +): { + path: string; + lines: Pick; +} | null { const normalized = normalizePathToken(value); if (!normalized || normalized.includes("?")) { return null; @@ -436,14 +486,19 @@ function parseLocalPathParts( return null; } + // Split literal line markers before decoding; encoded punctuation belongs + // to the filename. Raw inline-code/path text stays untouched. const inlinePathTarget = parseInlinePathToken(beforeHash); if (inlinePathTarget) { - if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) { + let pathPart = inlinePathTarget.path; + if (options.decodeHref) { + pathPart = safeDecodeURIComponent(pathPart); + } + if (!isPlausibleAssistantLocalPath(pathPart)) { return null; } - return { - path: inlinePathTarget.path, + path: pathPart, lines: { lineStart: inlinePathTarget.lineStart, lineEnd: inlinePathTarget.lineEnd, @@ -455,12 +510,17 @@ function parseLocalPathParts( return null; } - if (!isPlausibleAssistantLocalPath(beforeHash)) { + let decodedBeforeHash = beforeHash; + if (options.decodeHref) { + decodedBeforeHash = safeDecodeURIComponent(beforeHash); + } + const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash; + if (!isPlausibleAssistantLocalPath(plausiblePath)) { return null; } return { - path: beforeHash, + path: decodedBeforeHash, lines: fragmentLines, }; } @@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean { function isAmbiguousWorkspaceCandidate( value: string, target: InlinePathTarget, - workspaceRoot?: string, + options: AssistantHrefParseOptions = {}, ): boolean { - const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot); + const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot); if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) { return false; } - const parsed = parseLocalPathParts(value); + const parsed = parseLocalPathParts(value, options); if (!parsed || isAbsolutePath(parsed.path)) { return false; } diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 312bfff1fd..384c3b6857 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import MarkdownIt from "markdown-it"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { classifyForResolution, fetchDaemonResolution, @@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = { workspaceRoot: "/Users/test/project", }; +it("opens the existing non-ASCII file named by a rendered markdown link", async () => { + const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-")); + try { + await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content"); + const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {}); + const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href"); + expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md"); + const resolution = classifyForResolution({ href: href! }, { workspaceRoot }); + expect(resolution.kind).toBe("resolved"); + if (resolution.kind !== "resolved" || resolution.value.kind !== "file") { + throw new Error("Expected a directly resolved file link"); + } + expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content"); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } +}); + function resolvedSuggestions( entries: DirectorySuggestionResult["entries"], ): DirectorySuggestionResult { @@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => { }; describe("classifyForResolution", () => { + it.each([ + ["C:/repo/my%20file.md", undefined], + ["C:\\repo\\my%20file.md", undefined], + ["C:/repo/my%20file.md:12", 12], + ["C:\\repo\\my%20file.md:12", 12], + ])("keeps the literal filename in Windows inline code %s", (href, lineStart) => { + for (const text of [href, undefined]) { + const result = classifyForResolution( + { href, text, sourceType: "inline-code" }, + { workspaceRoot: "C:/repo" }, + ); + expect(result).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } }, + }); + } + expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } }, + }); + }); it("returns the directFile target synchronously", () => { const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT); @@ -165,6 +209,46 @@ describe("classifyForResolution", () => { expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } }); }); + + it("decodes percent-encoded markdown hrefs", () => { + const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT); + + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "docs/my%20file.md", + path: "/Users/test/project/docs/my file.md", + lineStart: undefined, + lineEnd: undefined, + }, + }, + }); + }); + + it("keeps literal percent signs in inline-code paths", () => { + const result = classifyForResolution( + { + href: "docs/my%20file.md", + text: "docs/my%20file.md", + sourceType: "inline-code", + }, + CONTEXT, + ); + + expect(result).toEqual({ + kind: "needsLookup", + ambiguousQuery: "docs/my%20file.md", + token: "docs/my%20file.md", + target: { + raw: "docs/my%20file.md", + path: "/Users/test/project/docs/my%20file.md", + lineStart: undefined, + lineEnd: undefined, + }, + }); + }); }); describe("fetchDaemonResolution", () => { diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index 5fa4849fc8..774d39278b 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -108,13 +108,15 @@ export function classifyForResolution( source: AssistantFileLinkSource, context: AssistantFileLinkContext, ): AssistantFileLinkResolution { - const token = getAssistantFileLinkToken(source).trim(); + const tokenInfo = getAssistantFileLinkTokenInfo(source); + const token = tokenInfo.token.trim(); if (!token) { return { kind: "resolved", value: { kind: "ignored" } }; } const classification = classifyAssistantFileLink(token, { workspaceRoot: context.workspaceRoot, + decodeHref: tokenInfo.fromHref, }); if (!classification) { return { kind: "resolved", value: { kind: "ignored" } }; @@ -148,14 +150,26 @@ export function classifyForResolution( } export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string { - if (isLinkifiedSource(source) || source.sourceType === "inline-code") { + return getAssistantFileLinkTokenInfo(source).token; +} + +// Markdown link hrefs are percent-encoded by the renderer; inline-code and +// linkified text are raw file paths that must not be decoded. +function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): { + token: string; + fromHref: boolean; +} { + if (source.sourceType === "inline-code") { + return { token: source.text?.trim() || source.href, fromHref: false }; + } + if (isLinkifiedSource(source)) { const text = source.text?.trim(); if (text && isFileLookingAssistantToken(text)) { - return text; + return { token: text, fromHref: false }; } } - return source.href; + return { token: source.href, fromHref: true }; } export function getAmbiguousSuggestionQuery( diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts index 194405d2ec..603645f0fe 100644 --- a/packages/app/src/assistant-file-links/use-file-link.ts +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -29,14 +29,21 @@ export interface AssistantFileLinkActions { canResolveFile(source: AssistantFileLinkSource): boolean; } +interface AssistantFileLinkLookupKey { + query: string; + path: string; + lineStart: number | null; + lineEnd: number | null; +} + type AssistantFileLinkQueryKey = readonly [ "assistantFileLink", string | null, string | null, - string, + AssistantFileLinkLookupKey | null, ]; -const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const; +const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const; export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult { const { t } = useTranslation(); @@ -59,7 +66,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult ? assistantFileLinkQueryKey({ serverId, workspaceRoot, - token: resolution.token, + resolution, }) : DISABLED_QUERY_KEY, [resolution, serverId, workspaceRoot], @@ -179,7 +186,7 @@ function openAssistantFileLink(input: { const capturedQueryKey = assistantFileLinkQueryKey({ serverId: capturedConfig.serverId, workspaceRoot: capturedConfig.workspaceRoot, - token: capturedResolution.token, + resolution: capturedResolution, }); const run = async () => { @@ -245,9 +252,19 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour function assistantFileLinkQueryKey(input: { serverId?: string; workspaceRoot?: string; - token: string; + resolution: Extract; }): AssistantFileLinkQueryKey { - return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token]; + return [ + "assistantFileLink", + input.serverId ?? null, + input.workspaceRoot ?? null, + { + query: input.resolution.ambiguousQuery, + path: input.resolution.target.path, + lineStart: input.resolution.target.lineStart ?? null, + lineEnd: input.resolution.target.lineEnd ?? null, + }, + ]; } async function dispatchResolvedLink(input: { diff --git a/packages/app/src/assistant-selection-copy/content.browser.test.ts b/packages/app/src/assistant-selection-copy/content.browser.test.ts index 04aa28bd99..aecb4ba5b4 100644 --- a/packages/app/src/assistant-selection-copy/content.browser.test.ts +++ b/packages/app/src/assistant-selection-copy/content.browser.test.ts @@ -143,6 +143,52 @@ describe("assistant selection copy ranges", () => { expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond"); }); + // Chrome anchors a drag that starts in the gap below an image at the start of the + // image's rendered internals: the background-image layer, ahead of the hidden `img`. + // Only the text after it is highlighted. + it("leaves out an image when the selection starts in the gap below it", () => { + const transcript = mountTranscript([ + { + messageId: "message-1", + blocks: [ + "Before the image.", + '
', + "After the image.", + ], + }, + ]); + const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]'); + const range = document.createRange(); + range.setStart(frame.firstElementChild!.firstElementChild!, 0); + range.setEnd(textNode(fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 2)), 9); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + + expect(copiedMarkdown(selection)).toBe("After the"); + }); + + it("keeps a failed image when the selection starts in its error text", () => { + const transcript = mountTranscript([ + { + messageId: "message-1", + blocks: [ + '
Image failed to load
', + "After the image.", + ], + }, + ]); + const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]'); + const selection = selectRange( + frame.firstElementChild!, + 6, + fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 1), + 9, + ); + + expect(copiedMarkdown(selection)).toBe("![chart](https://example.test/chart.png)\n\nAfter the"); + }); + it("does not replace the browser clipboard for a selection reaching a second message", () => { const transcript = mountTranscript([ { messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] }, @@ -320,6 +366,32 @@ describe("assistant selection copy ranges", () => { ).toBe("6. Second bullet text"); }); + it("keeps the next number when a drag crosses a code block nested in an item", () => { + const message = mountFixture(); + const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]'); + list.outerHTML = [ + '
', + '
', + '
1.
', + "
Install it:
", + '
', + '
brew install foo
', + '', + "
", + "
", + '
', + '
2.
', + "
Done.
", + "
", + "
", + ].join(""); + + const content = copyAcross(message, "Install it:", "Done."); + + expect(content?.plainText).toBe("Install it:\n\n```sh\nbrew install foo\n```\n\n2. Done."); + expect(content?.html).toContain("
2. Done.
"); + }); + it("retains nested markers when a drag includes the complete outer item", () => { const message = mountFixture(); const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]'); @@ -385,6 +457,20 @@ describe("assistant selection copy ranges", () => { expect(content?.html).not.toContain("
");
   });
 
+  it("copies an image as its Markdown when the selection stays inside the rendered image", () => {
+    const message = mountFixture();
+    const frame = document.createElement("div");
+    frame.setAttribute("data-paseo-markdown-tag", "img");
+    frame.setAttribute("data-paseo-markdown-src", "https://example.test/chart.png");
+    frame.setAttribute("data-paseo-markdown-alt", "chart");
+    frame.innerHTML = '
'; + message.prepend(frame); + + expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe( + "![chart](https://example.test/chart.png)", + ); + }); + it("copies code without a fence when every character is selected inside the block", () => { const message = mountFixture(); const blockCode = fixtureElement( @@ -405,10 +491,19 @@ describe("assistant selection copy ranges", () => { * Turndown, so those cases need a fixture shaped like the real thing. */ function highlightedFixture(language: string | null): string { + return [ + '
', + '
Before the block.
', + highlightedCodeBlock(language), + '
After the block.
', + "
", + ].join(""); +} + +function highlightedCodeBlock(language: string | null): string { const languageAttribute = language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`; return [ - '
', `
`, '', "const answer = 1;", @@ -419,8 +514,6 @@ function highlightedFixture(language: string | null): string { "", '
Copy
', "
", - '
After the block.
', - "
", ].join(""); } @@ -598,6 +691,65 @@ describe("assistant selection copy inside highlighted code", () => { ); }); + // A drag that starts or ends in the gap beside a block anchors the selection at the + // edge of the neighbouring block. Nothing outside the code is highlighted or selected. + it("copies code without a fence when the selection only touches the end of the block before it", () => { + const message = mountHighlighted(); + const before = tokenText(message, "Before the block."); + + const content = copyBetween( + [before, before.length], + [tokenText(message, " doThing();"), 14], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("copies code without a fence when the selection only touches the start of the block after it", () => { + const message = mountHighlighted(); + + const content = copyBetween( + [tokenText(message, "const"), 0], + [tokenText(message, "After the block."), 0], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("copies code without a fence when the selection only touches the row before the block", () => { + const transcript = mountTranscript([{ messageId: "message-1", blocks: ["Run this:", "code"] }]); + const codeRow = transcript.querySelectorAll('[data-testid="assistant-message"]')[1]!; + codeRow.innerHTML = highlightedCodeBlock("typescript"); + const before = tokenText(transcript, "Run this:"); + + const content = copyBetween( + [before, before.length], + [tokenText(transcript, " doThing();"), 14], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("keeps an image selected beside the code in the copy", () => { + const message = mountHighlighted(); + const image = document.createElement("div"); + image.setAttribute("data-paseo-markdown-tag", "p"); + // The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown. + image.innerHTML = + '
'; + message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image); + const before = tokenText(message, "Before the block."); + + const content = copyBetween( + [before, before.length], + [tokenText(message, " doThing();"), 14], + ); + + expect(content?.plainText).toBe( + "![chart](https://example.test/chart.png)\n\n```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```", + ); + }); + it("retains the fence when a complete code block is selected across its boundary", () => { const message = mountHighlighted(); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index d019a23012..e59a71aeca 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -7,10 +7,12 @@ import { } from "@/utils/rich-clipboard"; import { MARKDOWN_COPY_ALIGN_ATTRIBUTE, + MARKDOWN_COPY_ALT_ATTRIBUTE, MARKDOWN_COPY_IGNORE_ATTRIBUTE, MARKDOWN_COPY_LANGUAGE_ATTRIBUTE, MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE, MARKDOWN_COPY_LIST_START_ATTRIBUTE, + MARKDOWN_COPY_SRC_ATTRIBUTE, MARKDOWN_COPY_TAG_ATTRIBUTE, MARKDOWN_COPY_UNWRAP_ATTRIBUTE, TRAILING_CODE_LINE_BREAKS, @@ -22,6 +24,7 @@ const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]'; const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`; const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`; const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`; +const IMAGE_FRAME_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`; const turndown = new TurndownService({ bulletListMarker: "-", @@ -53,9 +56,7 @@ turndown.addRule("compactListItem", { if (parent?.nodeName !== "OL") { return `${options.bulletListMarker} ${item}\n`; } - const start = Number(parent.getAttribute("start") ?? 1); - const index = Array.from(parent.children).indexOf(node); - return `${start + index}. ${item}\n`; + return `${node.getAttribute("value")}. ${item}\n`; }, }); @@ -66,9 +67,9 @@ export function createAssistantSelectionClipboardContent( return null; } - const range = selection.getRangeAt(0); - const parts = selectedMessageParts(range); - if (!parts) { + const range = startAfterImageFrame(selection.getRangeAt(0)); + const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range)); + if (!parts?.length) { return null; } @@ -93,6 +94,33 @@ export function createAssistantSelectionClipboardContent( return { ...content, html: flattenClipboardListMarkup(content.html) }; } +/** + * A drag that starts in the gap below an image anchors at the start of the image's + * rendered internals, ahead of the hidden `img`, while only the text after it is + * highlighted. Pressing on the image itself opens it instead of starting a selection, + * so a selection that starts inside an image, before any of its text, and ends past it + * starts after the image. A failed image shows its error as text, and a selection + * starting in that text keeps the image. + */ +function startAfterImageFrame(range: Range): Range { + const start = + range.startContainer instanceof Element + ? range.startContainer + : range.startContainer.parentElement; + const frame = start?.closest(IMAGE_FRAME_SELECTOR); + if (!frame || frame.contains(range.endContainer)) { + return range; + } + const insideFrame = range.cloneRange(); + insideFrame.setEnd(frame, frame.childNodes.length); + if (insideFrame.toString()) { + return range; + } + const afterFrame = range.cloneRange(); + afterFrame.setStartAfter(frame); + return afterFrame; +} + /** * Partly-selected code copies as the code itself, never through Turndown. * @@ -103,10 +131,12 @@ export function createAssistantSelectionClipboardContent( * * A selection contained inside code always copies as code, even when it contains every * character. A selection that crosses the code boundary stays on the Markdown path so - * a complete block retains its fence. + * a complete block retains its fence. Crossing means selecting content outside the code: + * a drag that starts or ends in the gap beside a block anchors at the edge of the + * neighbouring block, which selects nothing there. */ function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null { - const region = closestCodeRegion(range.commonAncestorContainer, message); + const region = selectedCodeRegion(range, message); if (!region) { return null; } @@ -130,6 +160,18 @@ function createPartialCodeContent(range: Range, message: Element): MarkdownClipb }); } +function selectedCodeRegion(range: Range, message: Element): Element | null { + const edgeRegions = [range.startContainer, range.endContainer].map((node) => + closestCodeRegion(node, message), + ); + return ( + edgeRegions.find( + // Measure against the whole block, so its own hover Copy button is not outside it. + (edge) => edge && selectsNothingOutside(range, edge.closest(CODE_BLOCK_SELECTOR) ?? edge), + ) ?? null + ); +} + function closestCodeRegion(node: Node, message: Element): Element | null { const element = node instanceof Element ? node : node.parentElement; const region = element?.closest(CODE_REGION_SELECTOR) ?? null; @@ -281,7 +323,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean return false; } const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE); - if (tag === "p" || isTableStructure(tag)) { + // An image has no part to select, so touching it selects all of it. + if (tag === "p" || tag === "img" || isTableStructure(tag)) { return true; } const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul"; @@ -384,6 +427,31 @@ function hasSelectedAllContents(range: Range, element: Element, includeIgnored = return true; } +function selectsContent(range: Range): boolean { + return hasMarkdownContent(range.cloneContents(), true); +} + +function selectsNothingOutside(range: Range, element: Element): boolean { + const contents = document.createRange(); + contents.selectNodeContents(element); + + if (range.compareBoundaryPoints(Range.START_TO_START, contents) < 0) { + const before = range.cloneRange(); + before.setEnd(contents.startContainer, contents.startOffset); + if (selectsContent(before)) { + return false; + } + } + if (range.compareBoundaryPoints(Range.END_TO_END, contents) > 0) { + const after = range.cloneRange(); + after.setStart(contents.endContainer, contents.endOffset); + if (selectsContent(after)) { + return false; + } + } + return true; +} + function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean { if (!includeIgnored) { for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) { @@ -393,8 +461,9 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean) if (fragment.textContent) { return true; } - const visibleVoidSelector = ["br", "hr"] + const visibleVoidSelector = ["br", "hr", "img"] .map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`) + .concat("img") .join(","); return Boolean(fragment.querySelector(visibleVoidSelector)); } @@ -488,14 +557,19 @@ function restoreMarkdownElements(container: HTMLElement): void { continue; } const semanticElement = document.createElement(tagName); - if (tagName !== "br") { + if (tagName !== "br" && tagName !== "img") { semanticElement.append(...element.childNodes); } + if (tagName === "img") { + semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? ""); + semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? ""); + } if (tagName === "ol") { const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE); if (start) { semanticElement.setAttribute("start", start); } + numberOrderedListItems(semanticElement, Number(start ?? 1)); } if (tagName === "pre") { const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE); @@ -528,6 +602,20 @@ function restoreMarkdownElements(container: HTMLElement): void { } } +/** + * Each item carries its number before Turndown re-parses the HTML. Until then every + * child of the list is one item, including a partly selected item demoted to a `p`. + * The parser splits a `p` that holds a code block into several siblings, so counting + * siblings afterwards numbers the next item too high. + */ +function numberOrderedListItems(list: Element, start: number): void { + Array.from(list.children).forEach((child, index) => { + if (child.tagName === "LI") { + child.setAttribute("value", String(start + index)); + } + }); +} + function unwrapIncompleteTables(container: HTMLElement): void { for (const table of container.querySelectorAll("table")) { if (hasUsableTableColumnContract(table)) { diff --git a/packages/app/src/assistant-selection-copy/markup.ts b/packages/app/src/assistant-selection-copy/markup.ts index 8f220043ce..91c6c1618a 100644 --- a/packages/app/src/assistant-selection-copy/markup.ts +++ b/packages/app/src/assistant-selection-copy/markup.ts @@ -5,6 +5,8 @@ export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap"; export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start"; export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language"; export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align"; +export const MARKDOWN_COPY_SRC_ATTRIBUTE = "data-paseo-markdown-src"; +export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt"; /** * Trailing line breaks, with any indentation that followed the last one. @@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined } as const; } +/** + * An image copies as the Markdown it came from. The rendered `img` points at a preview + * URL for data and workspace images, and carries no alt text of its own. + */ +export function markdownCopyImageDataSet(source: string, alt: string | undefined) { + return { + paseoMarkdownTag: "img", + paseoMarkdownSrc: source, + ...(alt ? { paseoMarkdownAlt: alt } : {}), + } as const; +} + export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) { const alignment = typeof style === "string" diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index d6fce078f2..9bf982c6e8 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -42,6 +42,18 @@ describe("fileUriToPath", () => { }); describe("localFileSourceToPath", () => { + it.each([ + "https://example.com/image%20name.png", + "data:image/png;base64,abc%20", + "blob:https://example.com/image%20name", + ])("preserves a direct Markdown image URI: %s", (source) => { + expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source); + }); + + it("preserves malformed percent escapes rather than throwing", () => { + expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png"); + }); + it("decodes markdown-encoded Windows drive-letter paths", () => { expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt"); }); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index 5fa0334359..46483bf0e7 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string { return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1")); } -export function localFileSourceToPath(source: string): string { +export function localFileSourceToPath( + source: string, + // Markdown destinations are URI-encoded; plain filesystem paths are not. + { decodeHref = false }: { decodeHref?: boolean } = {}, +): string { + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } let path = source; if (source.startsWith("file://")) { path = fileUriToPath(source); - } else if (isMarkdownEncodedWindowsDrivePath(source)) { + } else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) { path = decodeFilePathSource(source); } return normalizeWindowsDrivePath(path); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts index fbd37c15e7..cc8ad8fc11 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - getBottomSheetVisibleContentHeight, - getCompactSheetSafeAreaPadding, -} from "@/components/adaptive-modal-sheet-layout"; +import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; describe("getCompactSheetSafeAreaPadding", () => { it("assigns safe clearance to the footer independently of decorative padding", () => { @@ -49,17 +46,3 @@ describe("getCompactSheetSafeAreaPadding", () => { ).toEqual({}); }); }); - -describe("getBottomSheetVisibleContentHeight", () => { - it("stops subtracting the retained keyboard height after the keyboard hides", () => { - const layout = { - containerHeight: 874, - contentPosition: 88, - handleHeight: 24, - keyboardHeight: 344, - }; - - expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418); - expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762); - }); -}); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.ts b/packages/app/src/components/adaptive-modal-sheet-layout.ts index 91fc62274e..b38db5750c 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.ts @@ -10,28 +10,6 @@ export interface CompactSheetSafeAreaPadding { footerPaddingBottom?: number; } -interface BottomSheetVisibleContentHeightInput { - containerHeight: number; - contentPosition: number; - handleHeight: number; - keyboardHeight: number; - isKeyboardVisible: boolean; -} - -export function getBottomSheetVisibleContentHeight({ - containerHeight, - contentPosition, - handleHeight, - keyboardHeight, - isKeyboardVisible, -}: BottomSheetVisibleContentHeightInput): number { - "worklet"; - return Math.max( - 0, - containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0), - ); -} - export function getCompactSheetSafeAreaPadding({ isCompact, isKeyboardVisible, diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 4ceb43d917..f7b4d4056c 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Keyboard, Pressable, Text, View } from "react-native"; import type { DimensionValue, StyleProp, ViewStyle } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, @@ -12,22 +12,16 @@ import { useGlobalWebOverlayLayer, useWebOverlayRegistration, } from "../lib/overlay-root"; -import { - KEYBOARD_STATUS, - useBottomSheetInternal, - type BottomSheetBackgroundProps, -} from "@gorhom/bottom-sheet"; -import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { BottomSheetHandle, 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, @@ -233,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({ const WEB_EXIT_DURATION_MS = 160; -function SheetBackground({ style }: BottomSheetBackgroundProps) { +/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */ +export function SheetBackground({ style }: BottomSheetBackgroundProps) { const { theme } = useUnistyles(); const combinedStyle = useMemo( () => [ @@ -249,6 +240,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) { return ; } +const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({ + indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] }, +})); + +const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 }; + +/** + * The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children + * join the handle, so a header passed here drags the sheet like the indicator does. That is how a + * sheet whose content scrolls on its own keeps its header draggable without content panning. + */ +export function SheetHandle({ + children, + // Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator. + indicatorStyle: _indicatorStyle, + ...props +}: ComponentProps) { + if (!children) return ; + // The children carry their own semantics; the handle's slider role would swallow them on web. + return ( + + {children} + + ); +} + /** * The sheet body, indented to the sheet's content inset. * @@ -263,40 +287,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 +461,6 @@ export interface AdaptiveModalSheetProps { bodyStyle?: StyleProp; /** Layout intent for the sheet body, composed over the sheet's own content inset. */ contentStyle?: StyleProp; - /** Size compact sheet content to the live snap height instead of its largest snap point. */ - sizeContentToCurrentSnapPoint?: boolean; /** Re-establishes caller-owned contexts inside the native or compact sheet portal. */ contextBridge?: ContextBridge | null; } @@ -493,10 +481,8 @@ export function AdaptiveModalSheet({ presentation, contentStyle, bodyStyle, - sizeContentToCurrentSnapPoint = true, contextBridge = null, }: AdaptiveModalSheetProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); @@ -519,14 +505,14 @@ export function AdaptiveModalSheet({ () => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }), [compactSafeAreaPadding.footerPaddingBottom], ); - const footerView = footer ? ( - - {footer} - - ) : null; - const handleIndicatorStyle = useMemo( - () => ({ backgroundColor: theme.colors.palette.zinc[600] }), - [theme.colors.palette.zinc], + const footerView = useMemo( + () => + footer ? ( + + {footer} + + ) : null, + [footer, footerClearanceStyle, footerContainerStyle], ); useEffect(() => { if (!isWeb && visible) { @@ -628,7 +614,6 @@ export function AdaptiveModalSheet({ )} - {footerView} ); @@ -644,17 +629,13 @@ export function AdaptiveModalSheet({ backdropOpacity={0.45} enablePanDownToClose backgroundComponent={SheetBackground} - handleIndicatorStyle={handleIndicatorStyle} + handleComponent={SheetHandle} keyboardBehavior="extend" keyboardBlurBehavior="restore" accessible={false} presentation={presentation} > - {sizeContentToCurrentSnapPoint ? ( - {sheetContent} - ) : ( - sheetContent - )} + {sheetContent} ); } diff --git a/packages/app/src/components/add-remote-ssh-host-modal.tsx b/packages/app/src/components/add-remote-ssh-host-modal.tsx index 2e3e369f39..c7e25bce64 100644 --- a/packages/app/src/components/add-remote-ssh-host-modal.tsx +++ b/packages/app/src/components/add-remote-ssh-host-modal.tsx @@ -1,13 +1,25 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Text, View } from "react-native"; +import { + Pressable, + Text, + View, + type PressableStateCallbackType, + type StyleProp, + type ViewStyle, +} from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { Terminal } from "lucide-react-native"; +import { Eye, EyeOff, Terminal } from "lucide-react-native"; import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport"; import type { HostProfile } from "@/types/host-connection"; import { useHostMutations, useHosts } from "@/runtime/host-runtime"; import { Button } from "@/components/ui/button"; import { Field, FormTextInput } from "@/components/ui/form-field"; +import { + CONTROL_HEIGHTS, + createControlGeometry, + resolveControlInteractionStyles, +} from "@/components/ui/control-geometry"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { useIsCompactFormFactor } from "@/constants/layout"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; @@ -15,18 +27,64 @@ import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; const FLEX_ONE_STYLE = { flex: 1 } as const; const ThemedTerminal = withUnistyles(Terminal); +const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted })); -const styles = StyleSheet.create((theme) => ({ - helper: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - actions: { - flexDirection: "row", - gap: theme.spacing[3], - marginTop: theme.spacing[2], - }, -})); +const styles = StyleSheet.create((theme) => { + const geometry = createControlGeometry(theme); + return { + helper: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + passwordRow: { + flexDirection: "row", + alignItems: "center", + alignSelf: "stretch", + width: "100%", + gap: theme.spacing[2], + }, + passwordInputWrap: { + flex: 1, + minWidth: 0, + }, + // Boxed adornment next to the field: same height and radius as the field + // chrome of the active form size, and the shared interaction phases. + iconButtonSm: { + minHeight: CONTROL_HEIGHTS.compact, + width: CONTROL_HEIGHTS.compact, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + iconButtonMd: { + minHeight: CONTROL_HEIGHTS.field, + width: CONTROL_HEIGHTS.field, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface2, + }, + controlRest: { + ...geometry.controlRest, + }, + controlHover: { + ...geometry.controlHover, + }, + controlActive: { + ...geometry.controlActive, + }, + controlDisabled: { + ...geometry.controlDisabled, + }, + actions: { + flexDirection: "row", + gap: theme.spacing[3], + marginTop: theme.spacing[2], + }, + }; +}); export interface AddRemoteSshHostModalProps { visible: boolean; @@ -51,14 +109,20 @@ export function AddRemoteSshHostModal({ const isCompact = useIsCompactFormFactor(); const { probeAndUpsertRemoteSshConnection } = useHostMutations(); const targetRef = useRef(""); + const passwordRef = useRef(""); const inputRef = useRef(null); + const passwordInputRef = useRef(null); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const header = useMemo(() => ({ title: t("pairing.remoteSsh.title") }), [t]); const clear = useCallback(() => { targetRef.current = ""; + passwordRef.current = ""; inputRef.current?.replaceText(""); + passwordInputRef.current?.replaceText(""); + setIsPasswordVisible(false); setErrorMessage(""); }, []); @@ -94,7 +158,10 @@ export function AddRemoteSshHostModal({ try { setIsSaving(true); setErrorMessage(""); - result = await probeAndUpsertRemoteSshConnection(target); + result = await probeAndUpsertRemoteSshConnection({ + ...target, + password: passwordRef.current === "" ? undefined : passwordRef.current, + }); } catch (error) { const message = error instanceof DaemonConnectionTestError @@ -116,6 +183,34 @@ export function AddRemoteSshHostModal({ const handleTargetChange = useCallback((value: string) => { targetRef.current = value; }, []); + const handlePasswordChange = useCallback((value: string) => { + passwordRef.current = value; + }, []); + const handleTogglePasswordVisibility = useCallback(() => { + setIsPasswordVisible((currentlyVisible) => !currentlyVisible); + }, []); + const iconButtonStyle = useCallback( + ( + state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }, + ): StyleProp => [ + isCompact ? styles.iconButtonMd : styles.iconButtonSm, + resolveControlInteractionStyles( + { + controlRest: styles.controlRest, + controlHover: styles.controlHover, + controlActive: styles.controlActive, + controlDisabled: styles.controlDisabled, + }, + { + hovered: state.hovered, + pressed: state.pressed, + focused: state.focused, + disabled: isSaving, + }, + ), + ], + [isCompact, isSaving], + ); const handleSubmit = useCallback(() => void handleSave(), [handleSave]); return ( @@ -146,6 +241,41 @@ export function AddRemoteSshHostModal({ onSubmitEditing={handleSubmit} /> + + + + + + + {isPasswordVisible ? : } + + + + + ) : null} + + + {fullscreen ? null : attachmentSlot} + + {surface} + {showFullscreenButton && !fullscreen ? ( + + ) : null} + + {fullscreen ? null : toolbar} + + + {fullscreen ? ( + + {error} + {send} + + ) : null} + {overlay} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + inline: { flexShrink: 1, position: "relative" }, + inlineTextSurface: { flexShrink: 1, position: "relative" }, + iconButton: { + width: CONTROL_SIZE, + height: CONTROL_SIZE, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + enterButton: { + position: "absolute", + top: 0, + width: CONTROL_SIZE, + height: CONTROL_SIZE, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + fullscreen: { + flex: 1, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[4], + gap: theme.spacing[3], + }, + fullscreenInputWrapper: { flex: 1, minHeight: 0 }, + fullscreenTextSurface: { flex: 1, minHeight: 0 }, + exitRow: { alignItems: "flex-end", flexShrink: 0 }, + // The send error rides the keyboard with Send; below the body it would sit behind it. + footer: { flexShrink: 0, gap: theme.spacing[3] }, + sendRow: { alignItems: "flex-end" }, +})); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 5503ea1512..6f2cdef74d 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -4,6 +4,9 @@ import { computeCanStartDictation, resolveActiveSendBehavior, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, + resolveGlyphRailOffset, + resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -316,3 +319,46 @@ describe("stopRealtimeVoice", () => { expect(calls).toEqual(["cancel agent", "stop voice"]); }); }); + +describe("composer surface text", () => { + it("measures a new nonempty replacement before it reaches the editing handle", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Short draft", replacement, "draft:1")).toBe( + replacement.text, + ); + }); + + it("keeps live typing once the replacement is applied", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Edited live draft", replacement, "draft:2")).toBe( + "Edited live draft", + ); + }); + + it("keeps the live buffer when the initial publication is already applied", () => { + const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" }; + expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe( + "Live edited draft", + ); + }); +}); + +describe("retained selection", () => { + it("keeps a caret that fits and clamps one that no longer does", () => { + expect(resolveRetainedSelection({ start: 4, end: 17 }, 40)).toEqual({ start: 4, end: 17 }); + expect(resolveRetainedSelection({ start: 4, end: 17 }, 10)).toEqual({ start: 4, end: 10 }); + expect(resolveRetainedSelection({ start: 12, end: 17 }, 10)).toEqual({ start: 10, end: 10 }); + expect(resolveRetainedSelection({ start: -1, end: 3 }, 10)).toEqual({ start: 0, end: 3 }); + }); +}); + +describe("fullscreen glyph rail", () => { + it("nudges 16px fullscreen glyphs onto the Send arrow's ink edge", () => { + expect( + resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 16, glyphIconSize: 16 }), + ).toBeCloseTo(4 / 3); + expect( + resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 20, glyphIconSize: 16 }), + ).toBeCloseTo(1 / 6); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index a63aeb02dc..698965b5ac 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,6 +1,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; -import type { MessagePayload } from "@/composer/types"; +import type { MessagePayload, TextReplacement } from "@/composer/types"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; export type SendBehavior = ActiveTurnBehavior | "queue"; @@ -192,3 +192,39 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise< await ctx.voice.stopVoice(); } + +/** + * Offset that aligns a glyph's visible ink with the Send arrow's ink when both sit in + * equal controls on the same right rail. In Lucide's 24px viewBox ArrowUp ends at + * x19 while Maximize2/Minimize2 end at x21, so equal boxes leave the fullscreen + * glyphs visibly past the arrow. + */ +export function resolveGlyphRailOffset(input: { + controlSize: number; + sendIconSize: number; + glyphIconSize: number; +}): number { + const arrowInkInset = + (input.controlSize - input.sendIconSize) / 2 + (5 * input.sendIconSize) / 24; + const glyphInkInset = + (input.controlSize - input.glyphIconSize) / 2 + (3 * input.glyphIconSize) / 24; + return arrowInkInset - glyphInkInset; +} + +export function resolveComposerSurfaceText( + liveText: string, + replacement: TextReplacement, + appliedReplacementKey: string, +): string { + return replacement.key === appliedReplacementKey ? liveText : replacement.text; +} + +/** A retained caret must fit the text the remounted editor shows. */ +export function resolveRetainedSelection( + retained: { start: number; end: number }, + textLength: number, +): { start: number; end: number } { + const end = Math.min(Math.max(retained.end, 0), textLength); + const start = Math.min(Math.max(retained.start, 0), end); + return { start, end }; +} diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 6e70fa3edf..29df20012c 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -96,6 +96,35 @@ describe("ComposerTextInput web IME composition", () => { expect(document.activeElement).toBe(mounted.textarea); }); + it("keeps its editing handle and selection across a rerender during composition", () => { + const recorder = createTextRecorder(); + const mounted = mountInput(recorder.onChangeText); + const handle = mounted.inputRef.current; + act(() => { + handle?.focus(); + dispatchComposition(mounted.textarea, "compositionstart"); + typeFromIme(mounted.textarea, "你好"); + mounted.textarea.setSelectionRange(1, 2); + mounted.root.render( + , + ); + }); + expect(mounted.inputRef.current).toBe(handle); + expect(handle?.getNativeRef()).toBe(mounted.textarea); + expect([mounted.textarea.selectionStart, mounted.textarea.selectionEnd]).toEqual([1, 2]); + expect(document.activeElement).toBe(mounted.textarea); + expect(recorder.changes).toEqual([]); + act(() => dispatchComposition(mounted.textarea, "compositionend")); + expect(recorder.changes).toEqual(["你好"]); + expect(handle?.getText()).toBe("你好"); + }); + it("keeps locally typed text when its parent rerenders with a stale value", () => { const recorder = createTextRecorder(); const mounted = mountInput(recorder.onChangeText); diff --git a/packages/app/src/composer/keyboard-scope.tsx b/packages/app/src/composer/keyboard-scope.tsx index b254c53a51..20b57dde28 100644 --- a/packages/app/src/composer/keyboard-scope.tsx +++ b/packages/app/src/composer/keyboard-scope.tsx @@ -7,18 +7,35 @@ import { } from "react"; interface ComposerKeyboardScopeValue { + /** + * The pane this composer sits in is the user's pane. Focus restoration follows it: + * the editor keeps its focus intent across blurs that happen while the pane is not + * the user's (a retained tab hidden, a sheet over it) and brings focus back when + * the pane is theirs again. + */ + isPaneFocused: boolean; + /** + * The composer takes input right now: keyboard actions, shortcuts, autocomplete, + * and the fullscreen exit action. False while a compact overlay panel covers the + * pane, which does not take pane focus. + */ isActiveComposer: boolean; } const ComposerKeyboardScopeContext = createContext({ + isPaneFocused: false, isActiveComposer: false, }); export function ComposerKeyboardScopeProvider({ + isPaneFocused, isActiveComposer, children, }: PropsWithChildren): ReactElement { - const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]); + const value = useMemo( + () => ({ isPaneFocused, isActiveComposer }), + [isPaneFocused, isActiveComposer], + ); return ( {children} diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index 2f03b7e89a..cb46412de0 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -9,7 +9,12 @@ export interface MessagePayload { forceSend?: boolean; } +/** + * Text the draft owner hands the editor. `hydrated` is the owner's answer to a mount: + * the draft's text as persisted or still pending from the editor that unmounted. + */ export interface TextReplacement { + kind: "initial" | "hydrated" | "replace"; key: string; text: string; } diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 721708715c..9e9a15f6b4 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -33,13 +33,18 @@ export { getIsElectronMac as getIsElectronRuntimeMac, } from "./platform"; +/** Compact layouts are the breakpoints below `md`. */ +export function isCompactBreakpoint(breakpoint: string | undefined): boolean { + return breakpoint === "xs" || breakpoint === "sm"; +} + /** * Reactive hook — re-renders the component when the breakpoint changes. * Always use this instead of reading UnistylesRuntime.breakpoint directly. */ export function useIsCompactFormFactor(): boolean { const { rt } = useUnistyles(); - return rt.breakpoint === "xs" || rt.breakpoint === "sm"; + return isCompactBreakpoint(rt.breakpoint); } // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 3e8165146c..430e4ade74 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -50,7 +50,7 @@ import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; -import { useProviderSubagentStore } from "@/subagents/provider-store"; +import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -532,9 +532,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }); - const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => { - if (message.type !== "agent.provider_subagents.update") return; - useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + const unsubProviderSubagentUpdate = feeds.subscribe({ + snapshot: () => { + void resyncProviderSubagents(client, serverId); + }, + update: (message) => { + if (message.type !== "agent.provider_subagents.update") return; + useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + }, }); const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => { diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index c154c2fa78..02521881ea 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -241,7 +241,7 @@ const CATALOG_DATA = [ { id: "hoplite", title: "Hoplite", - description: "Hoplite's coding agent through the local hoplite acp bridge", + description: "Cloud coding agent through the local hoplite acp bridge", version: "manual", iconId: "hoplite", installLink: "https://hoplite.sh/docs/cli/acp", diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index 01fba418a9..3bf9e99f9b 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -199,6 +199,71 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage { } describe("server data push router", () => { + it("keeps a visible diff subscribed when a hidden panel switches to the same comparison", () => { + const queryClient = new QueryClient(); + const fake = createFakeClient(); + const serverId = "server-1"; + const cwd = "/repo"; + const options = (mode: "base" | "uncommitted", enabled: boolean) => { + const queryKey = checkoutDiffQueryKey( + serverId, + cwd, + mode, + mode === "base" ? "main" : undefined, + ); + return { + queryKey, + queryFn: skipToken as typeof skipToken, + gcTime: Infinity, + meta: checkoutDiffPushRoute({ + enabled, + serverId, + cwd, + subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`, + compare: { mode, ...(mode === "base" ? { baseRef: "main" } : {}) }, + }), + }; + }; + const visible = new QueryObserver(queryClient, options("base", true)); + const hidden = new QueryObserver(queryClient, options("base", false)); + const unsubscribeVisible = visible.subscribe(() => {}); + const unsubscribeHidden = hidden.subscribe(() => {}); + const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId }); + try { + visible.setOptions(options("uncommitted", true)); + hidden.setOptions(options("uncommitted", false)); + const subscription = fake.subscribeCheckoutDiffCalls.findLast( + (call) => call.compare.mode === "uncommitted", + )!; + const files = [ + { + path: "README.md", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 0, + hunks: [], + }, + ]; + fake.emit({ + type: "checkout_diff_update", + payload: { subscriptionId: subscription.subscriptionId, cwd, files, error: null }, + }); + expect(queryClient.getQueryData(options("uncommitted", true).queryKey)).toMatchObject({ + files, + }); + expect(fake.unsubscribeCheckoutDiffCalls).not.toContain(subscription.subscriptionId); + + unsubscribeVisible(); + expect(fake.unsubscribeCheckoutDiffCalls).toContain(subscription.subscriptionId); + } finally { + unsubscribeVisible(); + unsubscribeHidden(); + unmount(); + queryClient.clear(); + } + }); + it("retains equal diff files without reconstructing changed file bodies", () => { const queryClient = new QueryClient(); const fake = createFakeClient(); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index fc6d51ce4f..5ab3069bd0 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -494,6 +494,14 @@ function getActiveServerDataRoute( if (query.getObserversCount() === 0) { return null; } + // Query metadata belongs to the last observer that updated its options. A + // retained hidden panel must not cancel demand from another visible panel. + const observerRoutes = query.observers + .map((observer) => getServerDataRoute({ meta: observer.options.meta })) + .filter((route): route is ServerDataRoute => route !== null); + if (observerRoutes.length > 0) { + return observerRoutes.find((route) => route.enabled && route.serverId === serverId) ?? null; + } const route = getServerDataRoute(query); if (route) { return route.enabled && route.serverId === serverId ? route : null; @@ -590,7 +598,7 @@ function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"]) ); } -function getServerDataRoute(query: Query): ServerDataRoute | null { +function getServerDataRoute(query: Pick): ServerDataRoute | null { const meta = query.meta; if (!isRecord(meta) || !isRecord(meta.serverData)) { return null; diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index 9209f6fcbf..982469008a 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -80,7 +80,6 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps) type: "svg", errorCorrectionLevel: "M", margin: 1, - width: 480, }), enabled: Boolean(pairingQuery.data?.url), dataShape: "value", @@ -275,7 +274,8 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) { return ( @@ -365,10 +365,6 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.border, backgroundColor: theme.colors.palette.white, }, - qrImage: { - width: "100%", - height: "100%", - }, linkRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index d396697c41..8b04590d28 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -165,4 +165,7 @@ const FRAME_STYLE = { minHeight: 0, minWidth: 0, } as const; -const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const; +// CodeMirror's scrollIntoView scrolls every overflowing ancestor and stops at a fixed or sticky +// one. Sticky (with no offsets it lays out like relative) keeps line reveals inside the editor, so +// they don't shift the host around it, such as the compact workspace sheet. +const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden", position: "sticky" } as const; diff --git a/packages/app/src/file-pane/markdown-preview/image-source.test.ts b/packages/app/src/file-pane/markdown-preview/image-source.test.ts new file mode 100644 index 0000000000..8277e57492 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source"; + +describe("resolveMarkdownFileDirectory", () => { + it("uses the parent of an absolute markdown path", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "/Users/test/project/docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs"); + }); + + it("joins a workspace-relative markdown path to the workspace root", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "docs/星谷云/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/星谷云"); + }); + + it("preserves the host home directory for home-relative markdown paths", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans"); + }); +}); + +describe("resolveMarkdownPreviewImageSource", () => { + it("passes through remote and data URIs", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "https://example.com/image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("https://example.com/image.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "data:image/png;base64,abc", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("data:image/png;base64,abc"); + }); + + it("resolves a sibling image against the markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/images/hero.png"); + }); + + it("ignores local URL query strings and fragments", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/images/hero.png"); + }); + + it("preserves encoded filename characters when stripping URL suffixes", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./hero%23draft%3Fv.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/hero#draft?v.png"); + }); + + it("preserves remote URL query strings and fragments", () => { + const source = "https://example.com/hero.png?v=2#preview"; + expect(resolveMarkdownPreviewImageSource({ source, markdownPath: "docs/guide.md" })).toBe( + source, + ); + }); + + it("resolves parent-relative images that stay inside the workspace", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../../data/cache/xinggu_docs/12-linkedin/token.png", + markdownPath: "docs/星谷云/12-LinkedIn.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png"); + }); + + it("keeps absolute and home-relative image paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "/tmp/screenshot.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/screenshot.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "~/.paseo/screenshots/output.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/screenshots/output.png"); + }); + + it("normalizes file URIs into host paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/paseo-preview.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/paseo-preview.png"); + }); + + it("resolves images beside a home-relative document without using the workspace root", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "screenshots/output.png", + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans/screenshots/output.png"); + }); + + it("decodes a file URI exactly once", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/hero%2520image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/tmp/hero%20image.png"); + }); + + it("resolves parent segments from a home-relative document", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../images/hero.png", + markdownPath: "~/docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("~/images/hero.png"); + }); + + it("resolves Windows relative images against a drive-rooted markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "..\\images\\hero.png", + markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md", + workspaceRoot: "C:\\Users\\test\\repo", + }), + ).toBe("C:/Users/test/repo/images/hero.png"); + }); +}); diff --git a/packages/app/src/file-pane/markdown-preview/image-source.ts b/packages/app/src/file-pane/markdown-preview/image-source.ts new file mode 100644 index 0000000000..5316d94279 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.ts @@ -0,0 +1,150 @@ +import { localFileSourceToPath } from "@/attachments/utils"; +import { isAbsolutePath } from "@/utils/path"; + +function trimTrailingSeparators(value: string): string { + if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) { + return value.replace(/\\/g, "/"); + } + return value.replace(/[\\/]+$/, ""); +} + +function normalizePathSegments(value: string): string { + const segments: string[] = []; + for (const segment of value.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length > 0) { + segments.pop(); + } + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + +function normalizeAbsolutePath(value: string): string | null { + const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/")); + if (isHomeRelativePath(normalizedInput)) { + return `~/${normalizePathSegments(normalizedInput.slice(2))}`; + } + if (!isAbsolutePath(normalizedInput)) { + return null; + } + + const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput); + if (drivePath) { + return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`); + } + + const prefix = normalizedInput.startsWith("//") ? "//" : "/"; + return trimTrailingSeparators( + `${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`, + ); +} + +function parentDirectory(path: string): string | null { + const normalized = trimTrailingSeparators(path.replace(/\\/g, "/")); + if (!normalized) { + return null; + } + if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) { + return normalized.endsWith("/") ? normalized : `${normalized}/`; + } + + const index = normalized.lastIndexOf("/"); + if (index < 0) { + return null; + } + if (index === 0) { + return "/"; + } + const parent = normalized.slice(0, index); + if (/^[A-Za-z]:$/.test(parent)) { + return `${parent}/`; + } + return parent; +} + +function joinAbsolute(baseDirectory: string, relativePath: string): string | null { + const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/")); + const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); + if (!directory) { + return null; + } + return normalizeAbsolutePath(`${directory}/${relative}`); +} + +function isHomeRelativePath(value: string): boolean { + return value === "~" || value.startsWith("~/") || value.startsWith("~\\"); +} + +function decodeRelativeSource(source: string): string { + try { + return decodeURIComponent(source); + } catch { + return source; + } +} + +export function resolveMarkdownFileDirectory(input: { + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const markdownPath = input.markdownPath.trim().replace(/\\/g, "/"); + if (!markdownPath) { + return null; + } + + if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) { + const absolute = normalizeAbsolutePath(markdownPath); + return absolute ? parentDirectory(absolute) : null; + } + + const workspaceRoot = input.workspaceRoot?.trim(); + if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) { + return null; + } + + const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath); + return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null; +} + +export function resolveMarkdownPreviewImageSource(input: { + source: string; + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const source = input.source.trim(); + if (!source) { + return null; + } + + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } + + const pathSource = source.replace(/[?#].*$/, ""); + const path = localFileSourceToPath( + pathSource.startsWith("file://") ? pathSource : decodeRelativeSource(pathSource), + ); + if (!path) { + return null; + } + + if (isHomeRelativePath(path) || isAbsolutePath(path)) { + return path; + } + + const directory = resolveMarkdownFileDirectory({ + markdownPath: input.markdownPath, + workspaceRoot: input.workspaceRoot, + }); + if (!directory) { + return path; + } + + return joinAbsolute(directory, path); +} diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx index e8d172c61b..a79705323f 100644 --- a/packages/app/src/file-pane/markdown-preview/index.tsx +++ b/packages/app/src/file-pane/markdown-preview/index.tsx @@ -1,11 +1,59 @@ import { useMemo } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ASTNode, RenderRules } from "react-native-markdown-display"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { MarkdownRenderer } from "@/components/markdown/renderer"; +import { + createSharedMarkdownRules, + MarkdownRenderer, + type MarkdownStyles, +} from "@/components/markdown/renderer"; import { parseMarkdownPreviewDocument } from "./document"; +import { resolveMarkdownPreviewImageSource } from "./image-source"; +import { FileMarkdownPreviewImage } from "./preview-image"; -export function FileMarkdownPreview({ source }: { source: string }) { +export function FileMarkdownPreview({ + source, + filePath, + workspaceRoot, + client, + serverId, +}: { + source: string; + filePath: string; + workspaceRoot?: string; + client?: DaemonClient | null; + serverId?: string; +}) { const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]); + const rules = useMemo( + () => ({ + ...createSharedMarkdownRules(), + image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => { + const resolved = resolveMarkdownPreviewImageSource({ + source: String(node.attributes?.src ?? ""), + markdownPath: filePath, + workspaceRoot, + }); + if (!resolved) { + return null; + } + + return ( + + ); + }, + }), + [client, filePath, serverId, workspaceRoot], + ); return ( @@ -31,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) { ))} ) : null} - + ); diff --git a/packages/app/src/file-pane/markdown-preview/preview-image.tsx b/packages/app/src/file-pane/markdown-preview/preview-image.tsx new file mode 100644 index 0000000000..ef27c14978 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/preview-image.tsx @@ -0,0 +1,151 @@ +import { useCallback, useMemo, useState } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useTranslation } from "react-i18next"; +import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import type { Theme } from "@/styles/theme"; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const PREVIEW_IMAGE_MIN_HEIGHT = 160; + +export function FileMarkdownPreviewImage({ + source, + occurrenceKey, + alt, + client, + workspaceRoot, + serverId, +}: { + source: string; + occurrenceKey: string; + alt?: string; + client?: DaemonClient | null; + workspaceRoot?: string; + serverId?: string; +}) { + const { t } = useTranslation(); + const [viewerOpen, setViewerOpen] = useState(false); + const openViewer = useCallback(() => setViewerOpen(true), []); + const closeViewer = useCallback(() => setViewerOpen(false), []); + const image = useAssistantImage({ + source, + occurrenceKey, + client, + workspaceRoot, + serverId, + }); + const binding = image.status === "failed" ? null : image.binding; + const aspectRatio = image.status === "failed" ? null : image.aspectRatio; + const imageUri = binding?.uri ?? ""; + const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]); + const imageSizeStyle = useMemo(() => { + if (aspectRatio) { + return { aspectRatio }; + } + return { height: PREVIEW_IMAGE_MIN_HEIGHT }; + }, [aspectRatio]); + const surfaceStyle = useMemo>( + () => [styles.surface, imageSizeStyle], + [imageSizeStyle], + ); + const lightboxSource = useMemo(() => { + if (!viewerOpen || !imageUri) return null; + return { + type: "uri", + uri: imageUri, + contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined, + }; + }, [aspectRatio, imageUri, viewerOpen]); + + if (image.status === "failed") { + return ( + + {image.message} + + ); + } + + if (!binding) { + return ( + + + + ); + } + + return ( + + + + + {image.status === "loading" ? ( + + + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + frame: { + width: "100%", + marginBottom: theme.spacing[4], + }, + surface: { + width: "100%", + overflow: "hidden", + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + image: { + width: "100%", + height: "100%", + }, + state: { + minHeight: PREVIEW_IMAGE_MIN_HEIGHT, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[6], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + }, + loadingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: "center", + justifyContent: "center", + }, + errorText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + textAlign: "center", + }, +})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index a5630ed82f..ea3c5b1edb 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -54,6 +54,9 @@ interface FilePreviewBodyProps { location: WorkspaceFileLocation; navigationRevision: number; imagePreviewUri: string | null; + workspaceRoot: string; + client: DaemonClient | null; + serverId: string; } type TextExplorerFile = ExplorerFile & { kind: "text" }; @@ -135,6 +138,9 @@ function FilePreviewBody({ location, navigationRevision, imagePreviewUri, + workspaceRoot, + client, + serverId, }: FilePreviewBodyProps) { const { t } = useTranslation(); const filePath = location.path; @@ -182,7 +188,13 @@ function FilePreviewBody({ style={styles.previewContent} showsVerticalScrollIndicator > - + ); @@ -297,6 +309,7 @@ export function FilePane({ return ( ); } function EditableFilePane({ + serverId, + workspaceRoot, client, cwd, path, @@ -470,6 +492,8 @@ function EditableFilePane({ location, navigationRevision, }: { + serverId: string; + workspaceRoot: string; client: DaemonClient; cwd: string; path: string; @@ -622,6 +646,9 @@ function EditableFilePane({ location={location} navigationRevision={navigationRevision} imagePreviewUri={null} + workspaceRoot={workspaceRoot} + client={client} + serverId={serverId} /> )} diff --git a/packages/app/src/git/diff-document/hit-testing.test.ts b/packages/app/src/git/diff-document/hit-testing.test.ts index a0ed9c1578..d728c2f2d7 100644 --- a/packages/app/src/git/diff-document/hit-testing.test.ts +++ b/packages/app/src/git/diff-document/hit-testing.test.ts @@ -315,6 +315,7 @@ function reviewActionsForFirstAddition(): NonNullable< commentsByTarget: new Map(), editor: { target, commentId: null, body: "" }, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts index c2b6d86c9c..ace965c1ce 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -389,6 +389,7 @@ describe("diff document model", () => { commentId: null, }, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, @@ -790,6 +791,7 @@ function reviewActionsWithEditor( commentsByTarget: new Map(), editor: target ? { target, body: "", commentId: null } : null, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 139387ccb1..6bafd3eee3 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1,3 +1,5 @@ +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { floatingActionsClearance } from "@/components/ui/floating-action-button"; import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -31,6 +33,7 @@ import { import { type ParsedDiffFile } from "@/git/use-diff-query"; import type { ChangesState } from "@/panels/changes/state"; import { defaultChangesState } from "@/panels/changes/state"; +import { ReviewCommentSheet, ReviewFeedbackAction } from "@/review"; import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document"; import { ChangedFilesTree } from "@/git/changed-files-tree"; import { JUMP_TO_FILE_CLEARANCE, JumpToFile } from "@/git/jump-to-file"; @@ -1548,7 +1551,9 @@ export function ChangesSurface({ isDiffLoading, reviewActions, reviewAttachment, + reviewFeedback, } = useWorkingDiff({ + presentation, serverId, workspaceId: workspaceId ?? undefined, cwd, @@ -1729,6 +1734,7 @@ export function ChangesSurface({ ], ); + const { bottom: bottomInset } = useSafeAreaInsets(); const hasChanges = files.length > 0; const jumpToFileInset = jumpToFileClearance({ isCompact: isMobile, @@ -1778,27 +1784,41 @@ export function ChangesSurface({ }); const diffContent: ReactElement = ( - - + + + + - + ); const bodyContent = ( + + summarizeChecks(checks), [checks]); + const { t } = useTranslation(); + const summary = useMemo(() => summarizeChecks(checks, t), [checks, t]); const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState(); return ( @@ -91,13 +93,14 @@ export function ChecksSection({ {summary.parts.length > 0 ? ( + {summary.detailLead} {summary.parts.map((part, index) => ( {index > 0 ? ", " : ""} {part.text} ))} - {` ${summary.countNoun}`} + {summary.detailTrail} ) : null} @@ -186,6 +189,7 @@ function CheckRow({ isAddingLogsToChat: boolean; onAddLogsToChat: (check: PrPaneCheck) => void; }) { + const { t } = useTranslation(); const handlePress = useCallback(() => { void openExternalUrl(check.url); }, [check.url]); @@ -217,7 +221,9 @@ function CheckRow({ onPress={handleAddLogsToChat} style={styles.addButton} > - {isAddingLogsToChat ? "Adding..." : "Add to chat"} + {isAddingLogsToChat + ? t("workspace.git.pr.actions.addingToChat") + : t("workspace.git.pr.actions.addToChat")} ) : null} {check.timing && {check.timing}} diff --git a/packages/app/src/git/pull-request-panel/checks-summary.test.ts b/packages/app/src/git/pull-request-panel/checks-summary.test.ts index f014500753..747947b907 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.test.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import type { CheckStatus } from "./check-status"; import { summarizeChecks } from "./checks-summary"; import type { PrPaneCheck } from "./data"; @@ -9,7 +10,7 @@ function check(name: string, status: CheckStatus): PrPaneCheck { describe("summarizeChecks", () => { it("reports no checks without claiming the run passed", () => { - const summary = summarizeChecks([]); + const summary = summarizeChecks([], i18n.t); expect(summary.outcome).toBe("none"); expect(summary.headline).toBe("No checks"); @@ -18,11 +19,10 @@ describe("summarizeChecks", () => { }); it("leads with failures when anything failed", () => { - const summary = summarizeChecks([ - check("build", "success"), - check("lint", "failure"), - check("e2e", "pending"), - ]); + const summary = summarizeChecks( + [check("build", "success"), check("lint", "failure"), check("e2e", "pending")], + i18n.t, + ); expect(summary.outcome).toBe("failure"); expect(summary.headline).toBe("Some checks were not successful"); @@ -31,7 +31,7 @@ describe("summarizeChecks", () => { }); it("reports an unfinished run as in progress when nothing failed", () => { - const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")]); + const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")], i18n.t); expect(summary.outcome).toBe("pending"); expect(summary.headline).toBe("Some checks haven't completed yet"); @@ -39,7 +39,10 @@ describe("summarizeChecks", () => { }); it("treats a run of only successes and skips as passed", () => { - const summary = summarizeChecks([check("build", "success"), check("deploy", "skipped")]); + const summary = summarizeChecks( + [check("build", "success"), check("deploy", "skipped")], + i18n.t, + ); expect(summary.outcome).toBe("success"); expect(summary.headline).toBe("All checks have passed"); @@ -47,11 +50,10 @@ describe("summarizeChecks", () => { }); it("keeps the checks of each group together under a counted label", () => { - const summary = summarizeChecks([ - check("build", "failure"), - check("lint", "success"), - check("test", "failure"), - ]); + const summary = summarizeChecks( + [check("build", "failure"), check("lint", "success"), check("test", "failure")], + i18n.t, + ); const [failing, successful] = summary.groups; expect(failing?.label).toBe("2 failing checks"); @@ -60,9 +62,34 @@ describe("summarizeChecks", () => { }); it("says check rather than checks for a run of one", () => { - const summary = summarizeChecks([check("build", "failure")]); + const summary = summarizeChecks([check("build", "failure")], i18n.t); - expect(summary.countNoun).toBe("check"); expect(summary.detail).toBe("1 failing check"); }); + + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + const summary = summarizeChecks( + [ + check("build", "success"), + check("deploy", "skipped"), + check("lint", "failure"), + check("test", "failure"), + ], + i18n.t, + ); + + expect(summary.headline).toBe("Certaines vérifications ont échoué"); + expect(summary.detail).toBe("Vérifications : 2 en échec, 1 réussie(s), 1 ignorée(s)"); + expect(summary.groups.map((group) => group.label)).toEqual([ + "2 vérifications en échec", + "1 vérification réussie", + "1 vérification ignorée", + ]); + expect(summarizeChecks([], i18n.t).headline).toBe("Aucune vérification"); + } finally { + await i18n.changeLanguage("en"); + } + }); }); diff --git a/packages/app/src/git/pull-request-panel/checks-summary.ts b/packages/app/src/git/pull-request-panel/checks-summary.ts index f2f9b30a38..977706a9af 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.ts @@ -1,4 +1,5 @@ import { classifyCheck, type CheckPresentation } from "@/git/check-presentation"; +import type { TFunction } from "i18next"; import type { PrPaneCheck } from "./data"; /** @@ -16,27 +17,10 @@ const PRESENTATION_ORDER = [ "ignored", ] as const satisfies readonly CheckPresentation[]; -/** How each status reads inside a count phrase: "2 failing", "4 in progress". */ -const PRESENTATION_NOUN: Record = { - actionRequired: "needs action", - warning: "warning", - failure: "failing", - pending: "in progress", - manual: "manual", - success: "successful", - ignored: "skipped", -}; - /** The worst thing happening in the run, which is what the headline and the ring report. */ export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none"; -const OUTCOME_HEADLINE: Record = { - actionRequired: "Some checks need your attention", - failure: "Some checks were not successful", - pending: "Some checks haven't completed yet", - success: "All checks have passed", - none: "No checks", -}; +const COPY = "workspace.git.pr.checksOverview"; export interface ChecksCountPart { status: CheckPresentation; @@ -58,8 +42,12 @@ export interface ChecksSummary { headline: string; /** The count phrases behind `detail`, kept apart so the header can label each one. */ parts: readonly ChecksCountPart[]; - /** The noun closing the detail line — "checks", or "check" for a run of one. */ - countNoun: string; + /** + * The words around the count phrases in `detail`: "" and " checks" in English, where the noun + * closes the line, while other languages lead with it. + */ + detailLead: string; + detailTrail: string; /** The whole line: "2 failing, 4 in progress, 1 successful checks". Empty with no checks. */ detail: string; total: number; @@ -72,7 +60,7 @@ export interface ChecksSummary { * header, the ring, and the grouped list all read from one derivation instead of each * filtering the array again with its own idea of what counts. */ -export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { +export function summarizeChecks(checks: readonly PrPaneCheck[], t: TFunction): ChecksSummary { const groups: ChecksGroup[] = []; const parts: ChecksCountPart[] = []; @@ -83,24 +71,27 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { } groups.push({ status, - label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`, + label: t(`${COPY}.${matching.length === 1 ? "groupOne" : "groupMany"}.${status}`, { + count: matching.length, + }), checks: matching, }); parts.push({ status, count: matching.length, - text: `${matching.length} ${PRESENTATION_NOUN[status]}`, + text: t(`${COPY}.count.${status}`, { count: matching.length }), }); } const outcome = selectOutcome(checks); - const noun = countNoun(checks.length); + const { lead, trail } = detailFrame(checks.length, t); return { outcome, - headline: OUTCOME_HEADLINE[outcome], + headline: t(`${COPY}.headline.${outcome}`), parts, - countNoun: noun, - detail: parts.length === 0 ? "" : `${parts.map((part) => part.text).join(", ")} ${noun}`, + detailLead: lead, + detailTrail: trail, + detail: parts.length === 0 ? "" : `${lead}${parts.map((part) => part.text).join(", ")}${trail}`, total: checks.length, groups, }; @@ -131,6 +122,13 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome { return "success"; } -function countNoun(count: number): string { - return count === 1 ? "check" : "checks"; +/** + * The header renders each count phrase on its own, so the translated line is split around + * where the phrases go. + */ +function detailFrame(total: number, t: TFunction): { lead: string; trail: string } { + const marker = "\u0000"; + const line = t(`${COPY}.${total === 1 ? "detailOne" : "detailMany"}`, { parts: marker }); + const [lead = "", trail = ""] = line.split(marker); + return { lead, trail }; } diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index 21b145bedc..837433417e 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -9,12 +9,13 @@ import { isPipelineActiveStatus, mapPipelineStatus, } from "@/git/forges/gitlab"; +import { i18n } from "@/i18n/i18next"; import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors"; import { deriveAvatarColor, formatAge, - getActivityVerb, - getStateLabel, + getActivityVerbKey, + getStateLabelKey, mapPrPaneData, } from "./data"; @@ -779,37 +780,34 @@ describe("formatAge", () => { }); }); -describe("getStateLabel", () => { +const english = i18n.getFixedT("en"); +const french = i18n.getFixedT("fr"); + +describe("getStateLabelKey", () => { it.each([ - ["open", "Open"], - ["draft", "Draft"], - ["merged", "Merged"], - ["closed", "Closed"], - ] as const)("maps %s → %s", (state, expected) => { - expect(getStateLabel(state)).toBe(expected); + ["open", "Open", "Ouverte"], + ["draft", "Draft", "Brouillon"], + ["merged", "Merged", "Fusionnée"], + ["closed", "Closed", "Fermée"], + ] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => { + expect(english(getStateLabelKey(state))).toBe(englishLabel); + expect(french(getStateLabelKey(state))).toBe(frenchLabel); }); }); -describe("getActivityVerb", () => { - it("returns Commented for comment kind", () => { - expect(getActivityVerb({ kind: "comment" })).toBe("Commented"); - }); - - it("returns Approved for approved review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved"); - }); - - it("returns Requested changes for changes_requested review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe( +describe("getActivityVerbKey", () => { + it.each([ + [{ kind: "comment" }, "Commented", "A commenté"], + [{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"], + [ + { kind: "review", reviewState: "changes_requested" }, "Requested changes", - ); - }); - - it("returns Reviewed for a commented review with body (generic case)", () => { - expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed"); - }); - - it("returns Reviewed when reviewState is undefined", () => { - expect(getActivityVerb({ kind: "review" })).toBe("Reviewed"); + "A demandé des modifications", + ], + [{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"], + [{ kind: "review" }, "Reviewed", "A relu"], + ] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => { + expect(english(getActivityVerbKey(item))).toBe(englishVerb); + expect(french(getActivityVerbKey(item))).toBe(frenchVerb); }); }); diff --git a/packages/app/src/git/pull-request-panel/data.ts b/packages/app/src/git/pull-request-panel/data.ts index 0f1e2014e9..d1275f2f1e 100644 --- a/packages/app/src/git/pull-request-panel/data.ts +++ b/packages/app/src/git/pull-request-panel/data.ts @@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null { } } -export function getStateLabel(state: PrState): string { - if (state === "draft") return "Draft"; - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return "Open"; +export function getStateLabelKey(state: PrState) { + if (state === "draft") return "workspace.git.pr.states.draft"; + if (state === "merged") return "workspace.git.pr.states.merged"; + if (state === "closed") return "workspace.git.pr.states.closed"; + return "workspace.git.pr.states.open"; } -export function getActivityVerb(item: Pick): string { - if (item.kind === "comment") return "Commented"; - if (item.reviewState === "approved") return "Approved"; - if (item.reviewState === "changes_requested") return "Requested changes"; - return "Reviewed"; +export function getActivityVerbKey(item: Pick) { + if (item.kind === "comment") return "workspace.git.pr.activity.commented"; + if (item.reviewState === "approved") return "workspace.git.pr.activity.approved"; + if (item.reviewState === "changes_requested") { + return "workspace.git.pr.activity.requestedChanges"; + } + return "workspace.git.pr.activity.reviewed"; } diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index b7c0890c3a..429dd2be0e 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -63,7 +63,7 @@ import { canAddPullRequestActivityToChat, } from "./context-attachment"; import { ChecksSection, getCheckIdentity } from "./checks-section"; -import { getActivityVerb, getStateLabel } from "./data"; +import { getActivityVerbKey, getStateLabelKey } from "./data"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { CheckPresentationIcon } from "@/git/check-presentation.view"; @@ -538,7 +538,7 @@ export function PullRequestPane({ - {getStateLabel(data.state)} + {t(getStateLabelKey(data.state))} {nativeHeaderMeta} {repoIdentity ? ( @@ -568,7 +568,7 @@ export function PullRequestPane({
- Add all to chat + {t("workspace.git.pr.actions.addAllToChat")} ) : null} {activityLoading ? : null} {!activityLoading && visibleEntries.length === 0 ? ( - No activity yet + {t("workspace.git.pr.empty.noActivity")} ) : null} {!activityLoading ? visibleEntries.map(({ entry, collapsed }) => ( @@ -702,14 +702,14 @@ function ActivityKebab({ {renderKebabTriggerIcon} {attachEnabled && canAddPullRequestActivityToChat(activity) ? ( - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {activity.body.trim() !== "" ? ( @@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu } function ActivityVerb({ activity }: { activity: PrPaneActivity }) { - const verb = getActivityVerb(activity).toLowerCase(); + const { t } = useTranslation(); + const verb = t(getActivityVerbKey(activity)).toLowerCase(); if (activity.kind === "review" && activity.reviewState === "approved") { return ( @@ -815,6 +816,7 @@ function SingleActivityCard({ entry: Extract; collapsed: boolean; }) { + const { t } = useTranslation(); const { activity } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -884,7 +886,7 @@ function SingleActivityCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -935,6 +937,7 @@ function ReviewCard({ collapsed: boolean; collapsedEntryIds: ReadonlySet; }) { + const { t } = useTranslation(); const { review, threads } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -961,7 +964,7 @@ function ReviewCard({ onPress={handleAddToChat} style={styles.checkAddButton} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {collapsed ? ( @@ -995,7 +998,7 @@ function ReviewCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -1063,8 +1066,12 @@ function ThreadBlock({ ? formatPullRequestThreadPath(thread.location) : t("workspace.git.pr.thread.discussion")} - {thread.isResolved ? : null} - {thread.location?.isOutdated ? : null} + {thread.isResolved ? ( + + ) : null} + {thread.location?.isOutdated ? ( + + ) : null} {collapsed ? ( @@ -1080,7 +1087,7 @@ function ThreadBlock({ {renderKebabTriggerIcon} @@ -1124,7 +1131,7 @@ function ThreadBlock({ leftIcon={MessageSquarePlus} onPress={handleAddThreadToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index 341cc4946d..daeda1479d 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -7,6 +7,7 @@ import { buildReviewDraftKey, useInlineReviewController, useReviewAttachmentSnapshot, + useReviewFeedback, } from "@/review"; import { useCheckoutDiffQuery } from "@/git/use-diff-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; @@ -19,6 +20,7 @@ interface UseWorkingDiffOptions { ignoreWhitespace: boolean; enabled: boolean; queryScope?: string; + presentation?: string; } export function useWorkingDiff({ @@ -28,6 +30,7 @@ export function useWorkingDiff({ ignoreWhitespace, enabled, queryScope, + presentation, }: UseWorkingDiffOptions) { const { status, @@ -81,7 +84,7 @@ export function useWorkingDiff({ }), [baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId], ); - const reviewActions = useInlineReviewController({ reviewDraftKey }); + const reviewActions = useInlineReviewController({ reviewDraftKey, presentation }); const reviewAttachment = useReviewAttachmentSnapshot({ key: reviewDraftKey, diffFiles: files, @@ -90,6 +93,13 @@ export function useWorkingDiff({ baseRef, }); + const reviewFeedback = useReviewFeedback({ + serverId, + workspaceId, + reviewDraftKey, + attachment: reviewAttachment, + }); + return { status, isStatusLoading, @@ -107,6 +117,7 @@ export function useWorkingDiff({ isDiffLoading, reviewActions, reviewAttachment, + reviewFeedback, }; } diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.test.ts b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts index b4fdc3df00..184ba8db87 100644 --- a/packages/app/src/hooks/hover-safe-zone-tracker.test.ts +++ b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts @@ -94,14 +94,30 @@ describe("hover safe-zone tracker", () => { expect(handle.leaves).toBe(1); }); + it("bridges the gap to content above or below the trigger", () => { + const handle = createHandle({ + trigger: { left: 100, right: 130, top: 60, bottom: 90 }, + content: { left: 0, right: 300, top: 0, bottom: 52 }, + }); + + // In the gap between the card and the trigger below it. + handle.pointerMoved(115, 56); + expect(handle.enters).toBe(1); + expect(handle.leaves).toBe(0); + + // Beside the trigger, past the gap — outside. + handle.pointerMoved(200, 75); + expect(handle.leaves).toBe(1); + }); + it("treats overlapping trigger and content as having no bridge", () => { const handle = createHandle({ trigger: { left: 0, right: 200, top: 0, bottom: 50 }, - content: { left: 100, right: 300, top: 60, bottom: 100 }, + content: { left: 100, right: 300, top: 40, bottom: 100 }, }); - // Outside both rects, in what would be a bridge — should be outside. - handle.pointerMoved(150, 55); + // Outside both rects, in the corner neither of them covers — should be outside. + handle.pointerMoved(250, 20); expect(handle.enters).toBe(0); expect(handle.leaves).toBe(1); }); diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.ts b/packages/app/src/hooks/hover-safe-zone-tracker.ts index d097413c50..f9c4db4708 100644 --- a/packages/app/src/hooks/hover-safe-zone-tracker.ts +++ b/packages/app/src/hooks/hover-safe-zone-tracker.ts @@ -62,9 +62,20 @@ function isInsideSafeZone( if (isInsideRect(trigger, x, y)) return true; if (isInsideRect(content, x, y)) return true; if (!trigger || !content) return false; + return ( + isInsideHorizontalBridge(trigger, content, x, y) || + isInsideVerticalBridge(trigger, content, x, y) + ); +} - // Bridge: the horizontal strip connecting trigger and content, stretched - // vertically to span both. If they overlap horizontally there's no bridge. +// A card beside its trigger: the strip between them, stretched vertically to +// span both. If they overlap horizontally there's no horizontal bridge. +function isInsideHorizontalBridge( + trigger: RectLike, + content: RectLike, + x: number, + y: number, +): boolean { const bridgeLeft = Math.min(trigger.right, content.right); const bridgeRight = Math.max(trigger.left, content.left); if (bridgeLeft >= bridgeRight) return false; @@ -72,3 +83,19 @@ function isInsideSafeZone( const bridgeBottom = Math.max(trigger.bottom, content.bottom); return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; } + +// A card above or below its trigger: the strip between them, stretched +// horizontally to span both. If they overlap vertically there's no vertical bridge. +function isInsideVerticalBridge( + trigger: RectLike, + content: RectLike, + x: number, + y: number, +): boolean { + const bridgeTop = Math.min(trigger.bottom, content.bottom); + const bridgeBottom = Math.max(trigger.top, content.top); + if (bridgeTop >= bridgeBottom) return false; + const bridgeLeft = Math.min(trigger.left, content.left); + const bridgeRight = Math.max(trigger.right, content.right); + return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; +} diff --git a/packages/app/src/hooks/sidebar-status-view-model.test.ts b/packages/app/src/hooks/sidebar-status-view-model.test.ts index 62dc7142a8..c5bd8dcd11 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model"; import { buildStatusGroups, buildStatusShortcutIndex, - STATUS_BUCKET_LABELS, + getStatusBucketLabel, STATUS_BUCKET_ORDER, type StatusGroup, } from "./sidebar-status-view-model"; @@ -55,7 +56,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]); expect(groups[0]?.label).toBe("Needs input"); @@ -69,7 +70,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:b", statusBucket: "running" }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]); }); @@ -93,7 +94,7 @@ describe("buildStatusGroups", () => { }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]); }); @@ -109,7 +110,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([ "srv:ts", @@ -145,13 +146,13 @@ describe("buildStatusGroups", () => { }), ]; - const groups = buildStatusGroups(workspaces, projectNames); + const groups = buildStatusGroups(workspaces, projectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]); }); it("returns empty array for no workspaces", () => { - const groups = buildStatusGroups([], emptyProjectNames); + const groups = buildStatusGroups([], emptyProjectNames, i18n.t); expect(groups).toEqual([]); }); @@ -180,11 +181,11 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER); expect(groups.map((g) => g.label)).toEqual( - STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]), + STATUS_BUCKET_ORDER.map((b) => getStatusBucketLabel(b, i18n.t)), ); // Each group has exactly one row with the matching bucket for (const group of groups) { @@ -230,3 +231,20 @@ describe("buildStatusShortcutIndex", () => { expect(index.size).toBe(0); }); }); + +describe("getStatusBucketLabel", () => { + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + expect(STATUS_BUCKET_ORDER.map((bucket) => getStatusBucketLabel(bucket, i18n.t))).toEqual([ + "Attend une réponse", + "Échec", + "À relire", + "En cours", + "Terminé", + ]); + } finally { + await i18n.changeLanguage("en"); + } + }); +}); diff --git a/packages/app/src/hooks/sidebar-status-view-model.ts b/packages/app/src/hooks/sidebar-status-view-model.ts index 7bcbe3d345..5736c7f391 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model"; @@ -5,13 +6,17 @@ export type StatusBucket = SidebarWorkspaceEntry["statusBucket"]; export { STATUS_BUCKET_ORDER }; -export const STATUS_BUCKET_LABELS: Record = { - needs_input: "Needs input", - failed: "Failed", - attention: "Ready to review", - running: "Working", - done: "Done", -}; +const STATUS_BUCKET_LABEL_KEYS = { + needs_input: "sidebar.statusBucket.needsInput", + failed: "sidebar.statusBucket.failed", + attention: "sidebar.statusBucket.readyToReview", + running: "sidebar.statusBucket.working", + done: "sidebar.statusBucket.done", +} as const satisfies Record; + +export function getStatusBucketLabel(bucket: StatusBucket, t: TFunction): string { + return t(STATUS_BUCKET_LABEL_KEYS[bucket]); +} export interface StatusGroup { bucket: StatusBucket; @@ -22,6 +27,7 @@ export interface StatusGroup { export function buildStatusGroups( workspaces: SidebarWorkspaceEntry[], projectNamesByViewKey: Map, + t: TFunction, ): StatusGroup[] { const bucketRows = new Map(); @@ -42,7 +48,7 @@ export function buildStatusGroups( if (!rows || rows.length === 0) continue; rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey)); - groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows }); + groups.push({ bucket, label: getStatusBucketLabel(bucket, t), rows }); } return groups; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 842844cdc2..24ecc34461 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -27,6 +27,7 @@ export interface SidebarWorkspacePlacement { workspaceDirectory?: string; projectKind: WorkspaceStructureProject["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; + background?: boolean; name: string; } @@ -164,6 +165,7 @@ export function createSidebarWorkspaceEntry(input: { input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory), projectKind: input.workspace.projectKind, workspaceKind: input.workspace.workspaceKind, + background: input.workspace.background, name: input.workspace.name, title: input.workspace.title ?? null, pinnedAt: input.workspace.pinnedAt, diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index c5b77f0f0d..6c8c352069 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -66,6 +66,20 @@ describe("ACP provider catalog", () => { command: ["hoplite", "acp"], installLink: "https://hoplite.sh/docs/cli/acp", }); + expect(buildAcpProviderConfigPatch(findProvider("hoplite"))).toEqual({ + providers: { + hoplite: { + extends: "acp", + label: "Hoplite", + description: "Cloud coding agent through the local hoplite acp bridge", + command: ["hoplite", "acp"], + env: {}, + }, + }, + }); + const ids = getAcpProviderCatalog().map((entry) => entry.id); + expect(ids.indexOf("hermes")).toBeLessThan(ids.indexOf("hoplite")); + expect(ids.indexOf("hoplite")).toBeLessThan(ids.indexOf("junie")); expect(findProvider("hoplite").iconSvg).toContain('fill="currentColor"'); }); diff --git a/packages/app/src/hooks/use-file-download.ts b/packages/app/src/hooks/use-file-download.ts index 91a88170b8..683b7a2381 100644 --- a/packages/app/src/hooks/use-file-download.ts +++ b/packages/app/src/hooks/use-file-download.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useHosts } from "@/runtime/host-runtime"; +import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { useDownloadStore } from "@/stores/download-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; @@ -12,8 +12,9 @@ interface UseFileDownloadParams { /** * Returns a stable callback that downloads a single workspace file by its * workspace-relative path. Shared by the file explorer tree and the git diff - * pane so both surfaces download through the same host token + download-store - * pipeline instead of duplicating the plumbing. + * pane so both surfaces download through the same download-store pipeline instead + * of duplicating the plumbing: the HTTP token download over a direct TCP connection, + * the session socket over anything else. */ export function useFileDownload({ serverId, @@ -30,7 +31,7 @@ export function useFileDownload({ () => workspaceId?.trim() || normalizedWorkspaceRoot, [normalizedWorkspaceRoot, workspaceId], ); - const { requestFileDownloadToken } = useFileExplorerActions({ + const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({ serverId, workspaceId, workspaceRoot: normalizedWorkspaceRoot, @@ -48,9 +49,18 @@ export function useFileDownload({ fileName, path, daemonProfile, + activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null, requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath), + readFile: requestFileBytes, }); }, - [daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId], + [ + daemonProfile, + requestFileBytes, + requestFileDownloadToken, + serverId, + startDownload, + workspaceScopeId, + ], ); } diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 53714e37b3..c29feb5241 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -253,6 +253,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor [client, normalizedWorkspaceRoot, t], ); + const requestFileBytes = useCallback( + async (path: string, maxBytes: number) => { + if (!normalizedWorkspaceRoot) { + throw new Error(t("workspace.fileExplorer.states.unavailable")); + } + if (!client) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + // No request timeout: a large file over a slow relay legitimately takes longer than the + // 60 s default, and a dead connection still rejects on disconnect. + return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0); + }, + [client, normalizedWorkspaceRoot, t], + ); + const createEntry = useCallback( async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => { if (!client || !normalizedWorkspaceRoot) { @@ -342,6 +357,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor requestDirectoryListing, requestFilePreview, requestFileDownloadToken, + requestFileBytes, createEntry, renameEntry, duplicateEntry, diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index d396f26d9a..6d9c8d4c7c 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -15,6 +15,7 @@ import { parseClampedFontSize, parseTerminalScrollbackLines, resolveContentMaxWidth, + sanitizeFontFamily, saveAppSettings, type SettingsDeps, } from "./storage"; @@ -1094,3 +1095,34 @@ describe("content max width", () => { expect((await load("wide")).contentMaxWidth).toBeNull(); }); }); + +describe("font family CSS safety", () => { + it.each(["'foo", '"foo', "Menlo, 'foo", "foo\\", "foo/*", "foo(", "foo["])( + "rejects unfinished CSS syntax: %s", + (value) => expect(sanitizeFontFamily(value)).toBeNull(), + ); + + it.each([ + "Menlo", + "'Fira Code', monospace", + '"Fira Code", monospace', + "'Font\\'s name'", + "Font\\ Name", + "'Font (Mono)'", + "'Font [Mono]'", + ])("preserves complete font families: %s", (value) => + expect(sanitizeFontFamily(value)).toBe(value), + ); + + it.each(["uiFontFamily", "monoFontFamily"])( + "recovers a persisted unmatched quote in %s", + async (field) => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ [field]: "'foo" }), + }), + }); + expect(await loadAppSettingsFromStorage(deps)).toMatchObject({ [field]: "" }); + }, + ); +}); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index a954074e8b..54ee6b10bc 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -150,7 +150,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { contentMaxWidth: null, syntaxTheme: "one", workspaceTitleSource: "title", - sidebarWorkspaceTrailing: "diff", + sidebarWorkspaceTrailing: "timestamp", sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS, sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY, sidebarNavItems: [], @@ -242,7 +242,7 @@ const StoredAppSettingsSchema = z .catch(null), syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"), workspaceTitleSource: z.enum(["title", "branch"]).catch("title"), - sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"), + sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("timestamp"), sidebarRowItems: SidebarRowItemsSchema, sidebarChecksDisplay: z .enum(["iconAndText", "icon", "none"]) @@ -522,7 +522,24 @@ export function sanitizeFontFamily(value: unknown): string | null { if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) { return null; // control chars would corrupt the font-family string } - return trimmed; // quotes/commas are legit in stacks + // Unistyles serializes theme values into one shared stylesheet. An unfinished + // string, escape, comment, or block consumes the declarations that follow. + let quote: string | null = null; + let escaped = false; + for (const char of trimmed) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (quote !== null) { + if (char === quote) quote = null; + } else if (char === "'" || char === '"') { + quote = char; + } else if (/[()[\]/*]/.test(char)) { + return null; + } + } + return quote === null && !escaped ? trimmed : null; } async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{ diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 46b3428026..bf42a3b61a 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -74,6 +74,45 @@ const untranslatedLocalFallbacks = [ "Unable to save desktop settings.", ] as const; +const pullRequestPanelSources = [ + "git/pull-request-panel/pane.tsx", + "git/pull-request-panel/data.ts", + "git/pull-request-panel/checks-section.tsx", + "components/sidebar/sidebar-status-list.tsx", +] as const; +const untranslatedPullRequestPanelLabels = [ + "Activity", + "No activity yet", + "Add all to chat", + "Add to chat", + "Adding...", + "Comment actions", + "Thread actions", + "Commented", + "Approved", + "Requested changes", + "Reviewed", + "Draft", + "Merged", + "Closed", + "Open", + "Resolved", + "Outdated", +] as const; + +function findUntranslatedPullRequestPanelLabels(): string[] { + return pullRequestPanelSources.flatMap((source) => { + const contents = readFileSync(join(appSourceRoot, source), "utf8"); + const matches: string[] = untranslatedPullRequestPanelLabels.filter( + (text) => contents.includes(`"${text}"`) || new RegExp(`>\\s*${text}\\s*<`).test(contents), + ); + if (contents.includes("} group`")) { + matches.push("group"); + } + return matches.length === 0 ? [] : [`${source}: ${matches.join(", ")}`]; + }); +} + function collectSourceFiles(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); @@ -183,10 +222,53 @@ describe("translation resources", () => { expect(ko.desktop.daemon.status.notRunning).toBe("실행 중이 아님"); }); + it("uses the French navigation and Git meanings for reported labels", () => { + expect(fr.common.back).toBe("Retour"); + expect(fr.common.actions.back).toBe("Retour"); + expect(fr.common.actions.copy).toBe("Copier"); + expect(fr.common.states.starting).toBe("Démarrage…"); + expect(fr.common.connectionStatus.connecting).toBe("Connexion…"); + expect(fr.workspace.git.pr.sections.checks).toBe("Vérifications"); + expect(fr.sidebar.display.show.checks).toBe("Vérifications"); + }); + + it("separates French interpolation placeholders from neighboring words", () => { + const glued = Object.entries(flattenStrings(fr)).filter(([, value]) => + /\p{L}\{\{|\}\}\p{L}{2,}/u.test(value), + ); + expect(glued).toEqual([]); + }); + it("labels the immediate add-to-chat action without an ellipsis", () => { expect(en.workspace.fileActions.addToChat).toBe("Add to chat"); }); + it("keeps pull request panel and sidebar status group labels translated", () => { + expect(findUntranslatedPullRequestPanelLabels()).toEqual([]); + for (const resource of [ar, es, fr, ja, ko, ptBR, ru, zhCN]) { + const pr = resource.workspace.git.pr; + const englishPr = en.workspace.git.pr; + expect(pr.sections.activity).not.toBe(englishPr.sections.activity); + expect(pr.empty.noActivity).not.toBe(englishPr.empty.noActivity); + expect(pr.actions.addToChat).not.toBe(englishPr.actions.addToChat); + expect(pr.actions.addAllToChat).not.toBe(englishPr.actions.addAllToChat); + expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat); + expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions); + expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions); + for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) { + expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]); + } + for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) { + expect(pr.states[state]).not.toBe(englishPr.states[state]); + } + expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved); + expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated); + expect(resource.sidebar.statusGroupAccessibility).not.toBe( + en.sidebar.statusGroupAccessibility, + ); + } + }); + it("keeps local connection fallback errors translated", () => { expect(findUntranslatedConnectionErrors()).toEqual([]); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index d29de2b33d..5f00d8f70c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -30,6 +30,7 @@ export const ar: TranslationResources = { back: "خلف", loading: "تحميل...", actions: { + save: "حفظ", back: "خلف", cancel: "يلغي", close: "يغلق", @@ -123,6 +124,8 @@ export const ar: TranslationResources = { input: { accessibilityLabel: "وكيل الرسائل...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "ملء الشاشة", + exitFullscreen: "الخروج من ملء الشاشة", focusHint: "{{shortcut}}للتركيز", addAttachment: "إضافة مرفق", interruptAgent: "عامل المقاطعة", @@ -382,6 +385,9 @@ export const ar: TranslationResources = { completed: "اكتملت", }, }, + turnFooter: { + workedFor: "عمل لمدة {{duration}}", + }, compaction: { loading: "الضغط...", auto: "يتم ضغط السياق تلقائيًا", @@ -607,6 +613,9 @@ export const ar: TranslationResources = { updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", }, + sheet: { + openAsTab: "فتح كعلامة تبويب", + }, tabs: { loading: "تحميل...", modified: "تغييرات غير محفوظة", @@ -970,6 +979,47 @@ export const ar: TranslationResources = { actions: { viewPullRequest: "عرض", openOn: "فتح على {{brand}}", + addToChat: "إضافة إلى الدردشة", + addAllToChat: "إضافة الكل إلى الدردشة", + addingToChat: "جارٍ الإضافة...", + }, + checksOverview: { + headline: { + actionRequired: "بعض الفحوصات تحتاج إلى انتباهك", + failure: "بعض الفحوصات لم تنجح", + pending: "بعض الفحوصات لم تكتمل بعد", + success: "نجحت جميع الفحوصات", + none: "لا توجد فحوصات", + }, + count: { + actionRequired: "{{count}} تحتاج إجراءً", + warning: "{{count}} مع تحذير", + failure: "{{count}} فاشلة", + pending: "{{count}} قيد التشغيل", + manual: "{{count}} يدوية", + success: "{{count}} ناجحة", + ignored: "{{count}} متخطاة", + }, + detailOne: "الفحص: {{parts}}", + detailMany: "الفحوصات: {{parts}}", + groupOne: { + actionRequired: "{{count}} فحص يحتاج إجراءً", + warning: "{{count}} فحص مع تحذير", + failure: "{{count}} فحص فاشل", + pending: "{{count}} فحص قيد التشغيل", + manual: "{{count}} فحص يدوي", + success: "{{count}} فحص ناجح", + ignored: "{{count}} فحص متخطى", + }, + groupMany: { + actionRequired: "{{count}} فحوصات تحتاج إجراءً", + warning: "{{count}} فحوصات مع تحذير", + failure: "{{count}} فحوصات فاشلة", + pending: "{{count}} فحوصات قيد التشغيل", + manual: "{{count}} فحوصات يدوية", + success: "{{count}} فحوصات ناجحة", + ignored: "{{count}} فحوصات متخطاة", + }, }, checksSummary: { passedLabel: "نجح", @@ -983,17 +1033,21 @@ export const ar: TranslationResources = { checks: "الشيكات", pipeline: "خط المعالجة", reviews: "التعليقات", + activity: "النشاط", }, empty: { noJobs: "لا توجد مهام", loadingPipeline: "جارٍ تحميل خط المعالجة...", pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة", allowedToFail: "مسموح بالفشل", + noActivity: "لا يوجد نشاط بعد", }, approvals: "{{given}} من {{required}} موافقات", accessibility: { pullRequest: "سحب الطلب #{{number}}", pullRequest_mr: "طلب دمج !{{number}}", + commentActions: "إجراءات التعليق", + threadActions: "إجراءات سلسلة النقاش", checkStatus: { passed: "ناجح", failed: "فاشل", @@ -1022,6 +1076,8 @@ export const ar: TranslationResources = { }, thread: { discussion: "سلسلة المناقشة", + resolved: "تم الحل", + outdated: "قديم", }, errors: { statusLoadFailed: "غير قادر على تحميل حالة طلب السحب", @@ -1088,7 +1144,16 @@ export const ar: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "مجموعة {{label}}", + statusBucket: { + needsInput: "تحتاج إدخالاً", + failed: "فشل", + readyToReview: "جاهزة للمراجعة", + working: "قيد العمل", + done: "تم", + }, display: { + showBackground: "إظهار مساحات العمل في الخلفية", trigger: "تفضيلات العرض", heading: "العرض", grouping: { @@ -1703,6 +1768,12 @@ export const ar: TranslationResources = { helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.", fields: { target: "مضيف SSH", + password: "كلمة مرور الدايمون", + optional: "خياري", + }, + passwordVisibility: { + show: "إظهار كلمة المرور", + hide: "إخفاء كلمة المرور", }, actions: { cancel: "إلغاء", @@ -1931,6 +2002,8 @@ export const ar: TranslationResources = { dismiss: "رفض", }, contextWindow: { + noData: "لا توجد بيانات للسياق", + accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق", title: "نافذة السياق", used: "تم استخدام{{percentage}}%", tokens: "رموز{{used}}/{{max}}", @@ -1938,6 +2011,17 @@ export const ar: TranslationResources = { accessibility: "تم استخدام نافذة السياق{{percentage}}%", }, review: { + feedback: { + send: "إرسال الملاحظات ({{count}})", + sending: "جارٍ إرسال الملاحظات ({{count}})", + chooseAgent: "اختر وكيلاً", + sent: "تم إرسال الملاحظات إلى {{recipient}}", + "no-agents": "افتح علامة تبويب وكيل في مساحة العمل هذه لإرسال الملاحظات.", + disconnected: "اتصل بالمضيف لإرسال الملاحظات.", + "no-context": "لم تعد التعليقات المحفوظة تتطابق مع هذا الفرق.", + failed: "تعذر إرسال الملاحظات. حاول مرة أخرى.", + prompt: "يرجى معالجة مراجعة الكود هذه.", + }, comment: { add: "إضافة تعليق المراجعة", edit: "تحرير تعليق المراجعة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1e2d4c3f10..91e7e2c92f 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -26,6 +26,7 @@ export const en = { back: "Back", loading: "Loading...", actions: { + save: "Save", back: "Back", cancel: "Cancel", close: "Close", @@ -119,6 +120,8 @@ export const en = { input: { accessibilityLabel: "Message agent...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", focusHint: "{{shortcut}} to focus", addAttachment: "Add attachment", interruptAgent: "Interrupt agent", @@ -379,6 +382,9 @@ export const en = { completed: "Completed", }, }, + turnFooter: { + workedFor: "Worked for {{duration}}", + }, compaction: { loading: "Compacting...", auto: "Context automatically compacted", @@ -604,6 +610,9 @@ export const en = { updateHost: "Update the host to use the native terminal renderer.", unableToSubscribe: "Unable to subscribe to terminal", }, + sheet: { + openAsTab: "Open as tab", + }, tabs: { loading: "Loading...", modified: "Unsaved changes", @@ -978,6 +987,47 @@ export const en = { actions: { viewPullRequest: "View", openOn: "Open on {{brand}}", + addToChat: "Add to chat", + addAllToChat: "Add all to chat", + addingToChat: "Adding...", + }, + checksOverview: { + headline: { + actionRequired: "Some checks need your attention", + failure: "Some checks were not successful", + pending: "Some checks haven't completed yet", + success: "All checks have passed", + none: "No checks", + }, + count: { + actionRequired: "{{count}} needs action", + warning: "{{count}} warning", + failure: "{{count}} failing", + pending: "{{count}} in progress", + manual: "{{count}} manual", + success: "{{count}} successful", + ignored: "{{count}} skipped", + }, + detailOne: "{{parts}} check", + detailMany: "{{parts}} checks", + groupOne: { + actionRequired: "{{count}} needs action check", + warning: "{{count}} warning check", + failure: "{{count}} failing check", + pending: "{{count}} in progress check", + manual: "{{count}} manual check", + success: "{{count}} successful check", + ignored: "{{count}} skipped check", + }, + groupMany: { + actionRequired: "{{count}} needs action checks", + warning: "{{count}} warning checks", + failure: "{{count}} failing checks", + pending: "{{count}} in progress checks", + manual: "{{count}} manual checks", + success: "{{count}} successful checks", + ignored: "{{count}} skipped checks", + }, }, checksSummary: { passedLabel: "passed", @@ -991,17 +1041,21 @@ export const en = { checks: "Checks", pipeline: "Pipeline", reviews: "Reviews", + activity: "Activity", }, empty: { noJobs: "No jobs", loadingPipeline: "Loading pipeline…", pipelineJobsLoadFailed: "Could not load pipeline jobs", allowedToFail: "allowed to fail", + noActivity: "No activity yet", }, approvals: "{{given}} of {{required}} approvals", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Comment actions", + threadActions: "Thread actions", checkStatus: { passed: "Passed", failed: "Failed", @@ -1030,6 +1084,8 @@ export const en = { }, thread: { discussion: "Discussion thread", + resolved: "Resolved", + outdated: "Outdated", }, errors: { statusLoadFailed: "Unable to load pull request status", @@ -1096,7 +1152,16 @@ export const en = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} group", + statusBucket: { + needsInput: "Needs input", + failed: "Failed", + readyToReview: "Ready to review", + working: "Working", + done: "Done", + }, display: { + showBackground: "Show background", trigger: "Display preferences", heading: "Display", grouping: { @@ -1727,6 +1792,12 @@ export const en = { helper: "Connect to a Paseo daemon running on the remote host.", fields: { target: "SSH host", + password: "Daemon password", + optional: "Optional", + }, + passwordVisibility: { + show: "Show password", + hide: "Hide password", }, actions: { cancel: "Cancel", @@ -1956,6 +2027,8 @@ export const en = { dismiss: "Dismiss", }, contextWindow: { + noData: "No context data", + accessibilityNoData: "Context window: No context data", title: "Context window", used: "{{percentage}}% used", tokens: "{{used}} / {{max}} tokens", @@ -1963,6 +2036,17 @@ export const en = { accessibility: "Context window {{percentage}}% used", }, review: { + feedback: { + send: "Send feedback ({{count}})", + sending: "Sending feedback ({{count}})", + chooseAgent: "Choose an agent", + sent: "Feedback sent to {{recipient}}", + "no-agents": "Open an agent tab in this workspace to send feedback.", + disconnected: "Connect to the host to send feedback.", + "no-context": "Saved comments no longer match this diff.", + failed: "Failed to send feedback. Try again.", + prompt: "Please address this code review.", + }, comment: { add: "Add review comment", edit: "Edit review comment", @@ -2096,7 +2180,7 @@ export const en = { globalTitle: "Enable plugins", globalHint: "Global switch for every configured plugin", sourceLabel: "Plugin source", - sourcePlaceholder: "Directory, Git URL, or npm package", + sourcePlaceholder: "owner/slug, directory, Git URL, or npm package", docs: "Docs", install: "Install plugin", installing: "Installing…", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index cc37432b5b..6aff72fdd7 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -31,6 +31,7 @@ export const es: TranslationResources = { back: "Atrás", loading: "Cargando...", actions: { + save: "Guardar", back: "Atrás", cancel: "Cancelar", close: "Cerrar", @@ -124,6 +125,8 @@ export const es: TranslationResources = { input: { accessibilityLabel: "Agente de mensajes...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Pantalla completa", + exitFullscreen: "Salir de pantalla completa", focusHint: "{{shortcut}}para enfocar", addAttachment: "Agregar archivo adjunto", interruptAgent: "agente de interrupción", @@ -387,6 +390,9 @@ export const es: TranslationResources = { completed: "Completada", }, }, + turnFooter: { + workedFor: "Trabajó durante {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automáticamente", @@ -613,6 +619,9 @@ export const es: TranslationResources = { updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", unableToSubscribe: "No se puede suscribir al terminal", }, + sheet: { + openAsTab: "Abrir como pestaña", + }, tabs: { loading: "Cargando...", modified: "Cambios sin guardar", @@ -1003,6 +1012,47 @@ export const es: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir en {{brand}}", + addToChat: "Añadir al chat", + addAllToChat: "Añadir todo al chat", + addingToChat: "Añadiendo...", + }, + checksOverview: { + headline: { + actionRequired: "Algunas comprobaciones requieren tu atención", + failure: "Algunas comprobaciones no se superaron", + pending: "Algunas comprobaciones aún no han terminado", + success: "Todas las comprobaciones se superaron", + none: "Sin comprobaciones", + }, + count: { + actionRequired: "{{count}} con acción pendiente", + warning: "{{count}} con advertencia", + failure: "{{count}} con error", + pending: "{{count}} en curso", + manual: "{{count}} manual(es)", + success: "{{count}} superada(s)", + ignored: "{{count}} omitida(s)", + }, + detailOne: "Comprobación: {{parts}}", + detailMany: "Comprobaciones: {{parts}}", + groupOne: { + actionRequired: "{{count}} comprobación con acción pendiente", + warning: "{{count}} comprobación con advertencia", + failure: "{{count}} comprobación con error", + pending: "{{count}} comprobación en curso", + manual: "{{count}} comprobación manual", + success: "{{count}} comprobación superada", + ignored: "{{count}} comprobación omitida", + }, + groupMany: { + actionRequired: "{{count}} comprobaciones con acción pendiente", + warning: "{{count}} comprobaciones con advertencia", + failure: "{{count}} comprobaciones con error", + pending: "{{count}} comprobaciones en curso", + manual: "{{count}} comprobaciones manuales", + success: "{{count}} comprobaciones superadas", + ignored: "{{count}} comprobaciones omitidas", + }, }, checksSummary: { passedLabel: "pasó", @@ -1016,17 +1066,21 @@ export const es: TranslationResources = { checks: "cheques", pipeline: "Pipeline", reviews: "Reseñas", + activity: "Actividad", }, empty: { noJobs: "Sin trabajos", loadingPipeline: "Cargando pipeline...", pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline", allowedToFail: "permitido fallar", + noActivity: "Aún no hay actividad", }, approvals: "{{given}} de {{required}} aprobaciones", accessibility: { pullRequest: "Solicitud de extracción n.°{{number}}", pullRequest_mr: "Solicitud de fusión !{{number}}", + commentActions: "Acciones del comentario", + threadActions: "Acciones del hilo", checkStatus: { passed: "Superado", failed: "Fallido", @@ -1045,16 +1099,18 @@ export const es: TranslationResources = { open: "Abierto", }, activity: { - commented: "Comentado", - approved: "Aprobado", - requestedChanges: "Cambios solicitados", - reviewed: "Revisado", + commented: "Comentó", + approved: "Aprobó", + requestedChanges: "Solicitó cambios", + reviewed: "Revisó", }, time: { justNow: "En este momento", }, thread: { discussion: "Hilo de discusión", + resolved: "Resuelto", + outdated: "Desactualizado", }, errors: { statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción", @@ -1124,7 +1180,16 @@ export const es: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", + statusBucket: { + needsInput: "Necesita datos", + failed: "Con error", + readyToReview: "Para revisar", + working: "En ejecución", + done: "Terminado", + }, display: { + showBackground: "Mostrar espacios en segundo plano", trigger: "Preferencias de visualización", heading: "Visualización", grouping: { @@ -1749,6 +1814,12 @@ export const es: TranslationResources = { helper: "Conéctate a un daemon de Paseo en el host remoto.", fields: { target: "Host SSH", + password: "Contraseña del daemon", + optional: "Opcional", + }, + passwordVisibility: { + show: "Mostrar contraseña", + hide: "Ocultar contraseña", }, actions: { cancel: "Cancelar", @@ -1979,6 +2050,8 @@ export const es: TranslationResources = { dismiss: "Despedir", }, contextWindow: { + noData: "No hay datos de contexto", + accessibilityNoData: "Ventana de contexto: No hay datos de contexto", title: "ventana contextual", used: "{{percentage}}% utilizado", tokens: "Fichas{{used}}/{{max}}", @@ -1986,6 +2059,17 @@ export const es: TranslationResources = { accessibility: "Ventana de contexto{{percentage}}% utilizada", }, review: { + feedback: { + send: "Enviar comentarios ({{count}})", + sending: "Enviando comentarios ({{count}})", + chooseAgent: "Elegir un agente", + sent: "Comentarios enviados a {{recipient}}", + "no-agents": "Abre una pestaña de agente en este espacio de trabajo para enviar comentarios.", + disconnected: "Conéctate al host para enviar comentarios.", + "no-context": "Los comentarios guardados ya no coinciden con este diff.", + failed: "No se pudieron enviar los comentarios. Inténtalo de nuevo.", + prompt: "Por favor, atiende esta revisión de código.", + }, comment: { add: "Agregar comentario de revisión", edit: "Editar comentario de revisión", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index aaf2608f9e..a4471f594c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -18,7 +18,7 @@ export const fr: TranslationResources = { matches: "Résultats de recherche", previous: "Résultat précédent", next: "Résultat suivant", - toggleReplace: "Afficher le remplacement", + toggleReplace: "Afficher/masquer le remplacement", replaceWith: "Remplacer par", replace: "Remplacer", replaceAll: "Tout remplacer", @@ -28,51 +28,51 @@ export const fr: TranslationResources = { }, common: { bottomSheetBackdrop: "Arrière-plan du panneau inférieur", - back: "Dos", - loading: "Chargement...", + back: "Retour", + loading: "Chargement…", actions: { - back: "Dos", + save: "Enregistrer", + back: "Retour", cancel: "Annuler", close: "Fermer", - copy: "Copie", + copy: "Copier", copyLine: "Copier la ligne", - dismiss: "Rejeter", + dismiss: "Ignorer", reload: "Recharger", retry: "Réessayer", - search: "Recherche", + search: "Rechercher", select: "Sélectionner", selectAll: "Tout sélectionner", }, placeholders: { - search: "Recherche...", + search: "Rechercher…", }, empty: { - noResults: "Aucun résultat trouvé", + noResults: "Aucun résultat", noOptionsMatchSearch: "Aucune option ne correspond à votre recherche.", }, states: { - loading: "Chargement...", - starting: "Départ...", + loading: "Chargement…", + starting: "Démarrage…", copied: "Copié", - copiedLabel: "{{label}}copié", + copiedLabel: "Copié : {{label}}", downloadComplete: "Téléchargement terminé", - downloadFailed: "Le téléchargement a échoué", + downloadFailed: "Échec du téléchargement", }, errors: { error: "Erreur", - unableToSave: "Impossible d'enregistrer", + unableToSave: "Impossible d’enregistrer", unableToCopy: "Impossible de copier", nameRequired: "Le nom est requis", - daemonUnavailable: "Daemonindisponible", - daemonClientUnavailable: "ClientDaemonindisponible", - daemonClientDisconnected: "Le clientDaemonest déconnecté", - noFileFound: "Aucun fichier trouvé pour{{token}}", - unexpectedDictationError: - "Une erreur inattendue s'est produite lors du traitement de la dictée.", + daemonUnavailable: "Daemon indisponible", + daemonClientUnavailable: "Client du daemon indisponible", + daemonClientDisconnected: "Le client du daemon est déconnecté", + noFileFound: "Aucun fichier trouvé pour {{token}}", + unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.", }, connectionStatus: { online: "En ligne", - connecting: "De liaison", + connecting: "Connexion…", offline: "Hors ligne", error: "Erreur", idle: "Inactif", @@ -80,159 +80,161 @@ export const fr: TranslationResources = { }, shell: { menu: { - toggleSidebar: "Basculer la barre latérale", + toggleSidebar: "Afficher/masquer la barre latérale", open: "Ouvrir le menu", close: "Fermer le menu", }, commandCenter: { - placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...", - filePlaceholder: "Rechercher des fichiers...", - searchingFiles: "Recherche de fichiers...", - noMatches: "Aucune correspondance", - actions: "Actes", + placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents…", + filePlaceholder: "Rechercher des fichiers…", + searchingFiles: "Recherche de fichiers…", + noMatches: "Aucun résultat", + actions: "Actions", files: "Fichiers", workspaces: "Espaces de travail", agents: "Agents", newAgent: "Nouvel agent", open: "Ouvrir {{name}}", openInSidePane: "Ouvrir {{name}} dans le panneau latéral", - openInFocusedPane: "Ouvrir {{name}} dans le volet actif", + openInFocusedPane: "Ouvrir {{name}} dans le panneau actif", addProject: "Ajouter un projet", - home: "Maison", + home: "Accueil", groupByProject: "Grouper par projet", groupByStatus: "Grouper par statut", modelGroupLabel: "Modèle", modelSearchKeywords: - "changer de modèle modifier le modèle définir le modèle sélectionner le modèle", - thinkingGroupLabel: "Raisonnement", - thinkingSearchKeywords: "raisonnement effort réflexion", + "changer de modèle modifier le modèle définir le modèle sélectionner le modèle model", + thinkingGroupLabel: "Réflexion", + thinkingSearchKeywords: "réflexion raisonnement effort thinking", modeGroupLabel: "Mode", - modeSearchKeywords: "accès permission approbation mode", + modeSearchKeywords: "accès autorisation approbation mode permission", planModeGroupLabel: "Mode plan", planModeSearchKeywords: "plan planification mode plan", fastModeGroupLabel: "Rapide", - fastModeSearchKeywords: "rapide vitesse faible latence", + fastModeSearchKeywords: "rapide vitesse faible latence fast", settingOn: "Activé", settingOff: "Désactivé", }, }, composer: { placeholders: { - desktop: "Envoyez un message à l'agent, marquez@filesou utilisez/commandset/skills", - mobile: "Message,@files,/commands", - fallback: "Message...", + desktop: "Écrivez à l’agent, mentionnez des @fichiers ou utilisez les /commandes et /skills", + mobile: "Message, @fichiers, /commandes", + fallback: "Message…", terminal: "Prompt", }, input: { - accessibilityLabel: "Agent de messagerie...", - terminalAccessibilityLabel: "Terminal prompt", - focusHint: "{{shortcut}}pour se concentrer", + accessibilityLabel: "Écrire à l’agent…", + terminalAccessibilityLabel: "Prompt du terminal", + fullscreen: "Plein écran", + exitFullscreen: "Quitter le plein écran", + focusHint: "{{shortcut}} pour saisir", addAttachment: "Ajouter une pièce jointe", - interruptAgent: "Agent d'interruption", - queueMessage: "Message de file d'attente", + interruptAgent: "Interrompre l’agent", + queueMessage: "Mettre le message en file d’attente", sendAndInterrupt: "Envoyer et interrompre", - sendAndSteer: "Envoyer et guider", - sendMessage: "Envoyer un message", - queue: "File d'attente", + sendAndSteer: "Envoyer et réorienter", + sendMessage: "Envoyer le message", + queue: "Mettre en file d’attente", send: "Envoyer", }, cancel: { - cancelingAgent: "Agent d'annulation", - stopAgent: "Agent d'arrêt", + cancelingAgent: "Annulation de l’agent…", + stopAgent: "Arrêter l’agent", interrupt: "Interrompre", }, voice: { enableVoiceMode: "Activer le mode vocal", voiceMode: "Mode vocal", - unmuteVoiceMode: "Activer le mode vocal", - muteVoiceMode: "Mode voix muette", + unmuteVoiceMode: "Réactiver le micro du mode vocal", + muteVoiceMode: "Couper le micro du mode vocal", stopDictation: "Arrêter la dictée", startDictation: "Démarrer la dictée", - unmuteVoice: "Réactiver la voix", - muteVoice: "Voix muette", + unmuteVoice: "Réactiver le micro", + muteVoice: "Couper le micro", dictation: "Dictée", - interruptBeforeVoice: "Interrompre l'agent avant de démarrer le mode vocal", + interruptBeforeVoice: "Interrompez l’agent avant de démarrer le mode vocal", }, attachments: { addImage: "Ajouter une image", pasteImage: "Coller une image", - addFile: "Upload file", - addIssueOrPr: "Ajouter un problème ouPR", - addIssueOrPr_mr: "Ajouter un problème ou MR", + addFile: "Importer un fichier", + addIssueOrPr: "Ajouter un ticket ou une PR", + addIssueOrPr_mr: "Ajouter un ticket ou une MR", dropImagesHere: "Déposez des images ici", - dropFilesHere: "Drop files here", - editQueuedMessage: "Modifier le message en file d'attente", - sendQueuedMessageNow: "Envoyer le message en file d'attente maintenant", - openImage: "Ouvrir la pièce jointe de l'image", - removeImage: "Supprimer l'image jointe", - removeFile: "Remove file attachment", + dropFilesHere: "Déposez des fichiers ici", + editQueuedMessage: "Modifier le message en file d’attente", + sendQueuedMessageNow: "Envoyer maintenant le message en file d’attente", + openImage: "Ouvrir l’image jointe", + removeImage: "Retirer l’image jointe", + removeFile: "Retirer le fichier joint", openGithub: "Ouvrir {{kind}} {{number}}", - removeGithub: "Supprimer {{kind}} {{number}}", + removeGithub: "Retirer {{kind}} {{number}}", element: "Élément", - openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur", - removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur", - openReview: "Ouvrir la pièce jointe de l'avis", - removeReview: "Supprimer la pièce jointe de l'avis", + openBrowserElement: "Ouvrir l’élément de navigateur joint", + removeBrowserElement: "Retirer l’élément de navigateur joint", + openReview: "Ouvrir la revue jointe", + removeReview: "Retirer la revue jointe", }, errors: { - failedToSend: "Échec de l'envoi du message", - failedToCreateAgent: "Échec de la création de l'agent", + failedToSend: "Impossible d’envoyer le message", + failedToCreateAgent: "Impossible de créer l’agent", noHostSelected: "Aucun hôte sélectionné", - initialPromptRequired: "Une invite initiale est requise", - alreadyLoading: "Déjà en cours de chargement", - uploadFailed: "Failed to upload file", + initialPromptRequired: "Un prompt initial est requis", + alreadyLoading: "Chargement déjà en cours", + uploadFailed: "Impossible d’importer le fichier", noClipboardImage: - "Aucune image dans le presse-papiers actuel. Essayez de coller depuis votre clavier.", + "Aucune image dans le presse-papiers. Essayez de coller depuis votre clavier.", pasteImageFailed: "Impossible de coller l’image", - fileTooLarge: "{{fileName}} is too large (max {{size}})", + fileTooLarge: "{{fileName}} est trop volumineux (maximum {{size}})", }, clientCommands: { - archiveAgent: "Archiver l'agent actuel", - freshDraft: "Archivez cet agent et démarrez un nouveau brouillon", + archiveAgent: "Archiver l’agent actuel", + freshDraft: "Archiver cet agent et démarrer un nouveau brouillon", }, github: { - searching: "Recherche...", - noResults: "Aucun résultat trouvé.", - searchPlaceholder: "Problèmes de recherche et PR...", - searchPlaceholder_mr: "Problèmes de recherche et MR...", - title: "Joindre le problème ouPR", - title_mr: "Joindre le problème ou MR", + searching: "Recherche…", + noResults: "Aucun résultat.", + searchPlaceholder: "Rechercher des tickets et des PR…", + searchPlaceholder_mr: "Rechercher des tickets et des MR…", + title: "Joindre un ticket ou une PR", + title_mr: "Joindre un ticket ou une MR", }, }, agentControls: { provider: { fallback: "Fournisseur", - select: "Sélectionnez le fournisseur d'agent", + select: "Choisir le fournisseur d’agent", }, thinking: { - title: "Pensée", + title: "Réflexion", unknown: "Inconnu", - extraHigh: "Très haut", - select: "Sélectionnez l'option de réflexion", - selectWithValue: "Sélectionnez l'option de réflexion ({{value}})", + extraHigh: "Très élevé", + select: "Choisir le niveau de réflexion", + selectWithValue: "Choisir le niveau de réflexion ({{value}})", }, model: { unknown: "Modèle inconnu", }, features: { - title: "Caractéristiques", - open: "Fonctionnalités de l'agent ouvert", - on: "Sur", + title: "Fonctionnalités", + open: "Ouvrir les fonctionnalités de l’agent", + on: "Activé", off: "Désactivé", }, mode: { title: "Mode", - searchPlaceholder: "Modes de recherche...", - selectWithValue: "Sélectionnez le mode agent ({{value}})", + searchPlaceholder: "Rechercher un mode…", + selectWithValue: "Choisir le mode de l’agent ({{value}})", }, hints: { - thinking: "Mode réflexion", + thinking: "Mode de réflexion", model: "Changer de modèle", mode: "Changer de mode", }, }, agentStream: { - empty: "Commencez à discuter avec cet agent...", + empty: "Commencez à discuter avec cet agent…", scrollToBottom: "Faire défiler vers le bas", historyLoadFailed: "Impossible de charger l’historique de l’agent", messageCapped: "Ce message a été tronqué ({{bytes}} octets).", @@ -245,34 +247,34 @@ export const fr: TranslationResources = { required: "Autorisation requise", deny: "Refuser", accept: "Accepter", - implement: "Mettre en œuvre", - question: "Comment souhaitez-vous procéder?", + implement: "Implémenter", + question: "Comment souhaitez-vous procéder ?", proposedPlan: "Plan proposé", }, }, agentPanel: { states: { - notFound: "Agentintrouvable", - failedToLoad: "Échec du chargement de l'agent", - reconnecting: "Reconnexion à l’hôte", - updating: "Mise à jour des messages", + notFound: "Agent introuvable", + failedToLoad: "Impossible de charger l’agent", + reconnecting: "Reconnexion à l’hôte…", + updating: "Mise à jour des messages…", timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent.", timelineSyncRetrying: "Nouvelle tentative…", - archivingTitle: "Agent d'archivage...", - archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.", + archivingTitle: "Archivage de l’agent…", + archivingSubtitle: "Veuillez patienter pendant l’archivage de cet agent.", }, unavailable: { selectedHost: "Hôte sélectionné", unknownHost: - "Impossible d'ouvrir cet agent car{{serverLabel}}n'est pas configuré sur ce périphérique.", + "Impossible d’ouvrir cet agent, car {{serverLabel}} n’est pas configuré sur cet appareil.", addHost: - "Ajoutez l'hôte dans Paramètres ou ouvrez un agent sur un serveur configuré pour continuer.", - preparingSession: "Préparation de la séance{{serverLabel}}...", - connecting: "Connexion à{{serverLabel}}...", - showSoon: "Nous montrerons cet agent dans un instant.", - showWhenOnline: "Nous afficherons cet agent une fois que l'hôte sera en ligne.", - reconnectingTo: "Reconnexion à{{serverLabel}}...", - showAgainWhenReachable: "Nous afficherons à nouveau cet agent dès que l'hôte sera joignable.", + "Pour continuer, ajoutez l’hôte dans Paramètres ou ouvrez un agent sur un serveur configuré.", + preparingSession: "Préparation de la session {{serverLabel}}…", + connecting: "Connexion à {{serverLabel}}…", + showSoon: "Cet agent va s’afficher dans un instant.", + showWhenOnline: "Cet agent s’affichera dès que l’hôte sera en ligne.", + reconnectingTo: "Reconnexion à {{serverLabel}}…", + showAgainWhenReachable: "Cet agent s’affichera de nouveau dès que l’hôte sera joignable.", }, archived: { callout: "Cet agent est archivé", @@ -281,42 +283,42 @@ export const fr: TranslationResources = { }, sessions: { title: "Historique", - empty: "Aucune séance pour l'instant", - noMatches: "Aucune séance correspondante", + empty: "Aucune session pour l’instant", + noMatches: "Aucune session correspondante", tooManyMatches: "Trop de résultats — affinez la recherche", - hostLoadFailed: "{{host}} : Impossible de charger l'historique", - searchPlaceholder: "Rechercher dans l'historique", + hostLoadFailed: "{{host}} : impossible de charger l’historique", + searchPlaceholder: "Rechercher dans l’historique", actions: { loadMore: "Charger plus", clearSearch: "Effacer la recherche", }, }, agentList: { - fallbackTitle: "Nouvelle séance", + fallbackTitle: "Nouvelle session", dateSections: { - recent: "Récent", - today: "Aujourd'hui", + recent: "Récents", + today: "Aujourd’hui", yesterday: "Hier", thisWeek: "Cette semaine", thisMonth: "Ce mois-ci", - older: "Plus vieux", + older: "Plus anciens", }, status: { - initializing: "Départ", + initializing: "Démarrage…", idle: "Inactif", - running: "En cours d'exécution", + running: "En cours", error: "Erreur", closed: "Fermé", }, badges: { archived: "Archivé", - pending: "{{count}}en attente", + pending: "{{count}} en attente", attention: "Attention", }, archiveSheet: { - hostOffline: "Hosthors ligne", - runningAgent: "Cet agent est toujours en cours d'exécution. L’archiver arrêtera l’agent.", - archive: "Archive", + hostOffline: "Hôte hors ligne", + runningAgent: "Cet agent est toujours en cours d’exécution. L’archiver l’arrêtera.", + archive: "Archiver", }, }, message: { @@ -331,33 +333,33 @@ export const fr: TranslationResources = { }, actions: { copyCode: "Copier le code", - copyTurn: "Copier le tour", + copyTurn: "Copier l’échange", copyMessage: "Copier le message", - forkMenu: "Dupliquer le message", - forkInNewTab: "Dupliquer dans un nouvel onglet", - forkInNewWorkspace: "Dupliquer dans un nouvel espace de travail", - forkUnavailable: "Mettez l'hôte à jour pour utiliser ceci.", - forkMissingWorkspace: "Cet agent n'est pas dans un espace de travail.", - forkFailed: "Impossible de dupliquer le chat", + forkMenu: "Forker la conversation à partir d’ici", + forkInNewTab: "Forker dans un nouvel onglet", + forkInNewWorkspace: "Forker dans un nouvel espace de travail", + forkUnavailable: "Mettez à jour l’hôte pour utiliser cette fonction.", + forkMissingWorkspace: "Cet agent n’est dans aucun espace de travail.", + forkFailed: "Impossible de forker la conversation", openFile: "Ouvrir le fichier", copied: "Copié", }, attachments: { - dismissImage: "Ignorer l'image", - closeImage: "Fermer l'image", - imageLoadFailed: "Impossible de charger l'image", + dismissImage: "Ignorer l’image", + closeImage: "Fermer l’image", + imageLoadFailed: "Impossible de charger l’image", imageUnavailable: "Image indisponible", - imagePreviewUnavailable: "Aperçu de l'image indisponible.", - imagePreviewLoadFailed: "Impossible de charger l'aperçu de l'image.", + imagePreviewUnavailable: "Aperçu de l’image indisponible.", + imagePreviewLoadFailed: "Impossible de charger l’aperçu de l’image.", review: "Revue", commentsOne: "1 commentaire", commentsMany: "{{count}} commentaires", - textAttachment: "Texte en pièce jointe", + textAttachment: "Pièce jointe texte", text: "Texte", file: "Fichier", }, speak: { - header: "Rayon", + header: "Prononcé", }, activity: { details: "Détails", @@ -365,21 +367,21 @@ export const fr: TranslationResources = { dictation: { start: "Démarrer la dictée vocale", cancel: "Annuler la dictée", - retry: "Réessayer la dictée", + retry: "Relancer la dictée", insert: "Insérer la transcription", insertAndSend: "Insérer la transcription et envoyer", - failed: "Échec de la dictée:{{error}}", - failedRetry: "La dictée a échoué. Appuyez sur réessayer.", + failed: "Échec de la dictée : {{error}}", + failedRetry: "La dictée a échoué. Appuyez sur Réessayer.", }, question: { - submit: "Soumettre", + submit: "Envoyer", next: "Suivant", - answerPlaceholder: "Tapez votre réponse...", - otherPlaceholder: "Autre...", + answerPlaceholder: "Saisissez votre réponse…", + otherPlaceholder: "Autre…", }, todo: { title: "Tâches", - empty: "Aucune tâche pour l'instant.", + empty: "Aucune tâche pour l’instant.", tasksProgress: "{{completed}}/{{total}} tâches", activity: { created: "{{count}} tâches créées", @@ -388,18 +390,21 @@ export const fr: TranslationResources = { completed: "Terminée", }, }, + turnFooter: { + workedFor: "A travaillé pendant {{duration}}", + }, compaction: { - loading: "Compactage...", - auto: "Contexte automatiquement compacté", + loading: "Compaction…", + auto: "Contexte compacté automatiquement", manual: "Contexte compacté manuellement", - withTokens: "Contexte compacté (jetons{{tokens}}K)", + withTokens: "Contexte compacté ({{tokens}}K tokens)", completed: "Contexte compacté", }, }, importSession: { chooseHostTitle: en.importSession.chooseHostTitle, - title: "Session d'importation", - searchPlaceholder: "Rechercher des sessions...", + title: "Importer une session", + searchPlaceholder: "Rechercher des sessions…", scope: { host: "Sessions sur {{host}}", workspace: "Cet espace de travail", @@ -409,40 +414,40 @@ export const fr: TranslationResources = { }, status: { connectHost: "Connectez-vous à un hôte pour importer des sessions", - updateHost: "Mettez à jour l'hôte pour importer des sessions.", - noProviders: "Aucun fournisseur importable n'est activé.", - loading: "Chargement des sessions récentes...", + updateHost: "Mettez à jour l’hôte pour importer des sessions.", + noProviders: "Aucun fournisseur importable n’est activé.", + loading: "Chargement des sessions récentes…", failedProvider: "Impossible de charger les sessions {{provider}}", - failedImport: "Impossible d'importer la session sélectionnée.", + failedImport: "Impossible d’importer la session sélectionnée.", }, actions: { - refresh: "Sessions de rafraîchissement", + refresh: "Actualiser les sessions", showAll: "Tout afficher", loadMore: "Charger plus", }, preview: { - untitledSession: "Séance sans titre", - noPrompt: "Aucun aperçu rapide", + untitledSession: "Session sans titre", + noPrompt: "Aucun aperçu du prompt", }, empty: { noRecent: "Aucune session récente à importer.", noMatches: "Aucune session ne correspond à votre recherche.", alreadyImported: "Toutes les sessions récentes sont déjà importées.", - noProviderSessions: "Aucune session{{provider}}trouvée.", + noProviderSessions: "Aucune session {{provider}} trouvée.", }, row: { - importing: "Importation...", + importing: "Importation…", }, }, workspace: { route: { - loading: "Chargement de l'espace de travail", - connecting: "De liaison", - hostOffline: "{{hostName}}est hors ligne", - cannotReachHost: "Impossible d'atteindre{{hostName}}", - hostStatus: "StatutHost:{{status}}", + loading: "Chargement de l’espace de travail…", + connecting: "Connexion…", + hostOffline: "{{hostName}} est hors ligne", + cannotReachHost: "Impossible de joindre {{hostName}}", + hostStatus: "Statut de l’hôte : {{status}}", needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail", - manageHost: "Gérer l'hôte", + manageHost: "Gérer l’hôte", recovery: { archivedTitle: "Espace de travail archivé", restoreDescription: @@ -452,14 +457,14 @@ export const fr: TranslationResources = { unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.", restoreAction: "Restaurer", unarchiveAction: "Désarchiver", - restoringTitle: "Restauration de l'espace de travail", - restoringAction: "Restauration...", + restoringTitle: "Restauration de l’espace de travail", + restoringAction: "Restauration…", unavailableTitle: "Espace de travail indisponible", - checkFailedTitle: "Impossible de vérifier l'espace de travail", + checkFailedTitle: "Impossible de vérifier l’espace de travail", }, }, hoverCard: { - scriptsAccessibility: "ScriptsWorkspace", + scriptsAccessibility: "Scripts de l’espace de travail", copyPath: "Copier le chemin", copyBranchName: "Copier le nom de la branche", copied: "Copié", @@ -472,8 +477,8 @@ export const fr: TranslationResources = { copyRelativePath: "Copier le chemin relatif", revealIn: "Afficher dans {{target}}", download: "Télécharger", - addToChat: "Ajouter au chat", - moreActions: "Plus de propositions", + addToChat: "Ajouter à la conversation", + moreActions: "Plus d’actions", newFile: "Nouveau fichier", newFolder: "Nouveau dossier", collapseFolder: "Replier le dossier", @@ -482,24 +487,24 @@ export const fr: TranslationResources = { revert: "Abandonner les modifications", delete: "Supprimer", confirmDelete: { - fileTitle: "Supprimer le fichier ?", - folderTitle: "Supprimer le dossier ?", - message: "« {{name}} » sera définitivement supprimé.", + fileTitle: "Supprimer le fichier ?", + folderTitle: "Supprimer le dossier ?", + message: "« {{name}} » sera définitivement supprimé.", confirm: "Supprimer", cancel: "Annuler", }, confirmRevert: { - title: "Abandonner les modifications ?", - message: "Les modifications de « {{name}} » seront définitivement abandonnées.", + title: "Abandonner les modifications ?", + message: "Les modifications de « {{name}} » seront définitivement abandonnées.", confirm: "Abandonner", cancel: "Annuler", - failed: "Échec de l'abandon des modifications", + failed: "Impossible d’abandonner les modifications", }, }, fileExplorer: { sort: { name: "Nom", - modified: "Modifié", + modified: "Date de modification", size: "Taille", }, context: { @@ -507,10 +512,10 @@ export const fr: TranslationResources = { modified: "Modifié", }, actions: { - back: "Dos", + back: "Retour", retry: "Réessayer", refresh: "Actualiser les fichiers", - refreshing: "Actualisation des fichiers", + refreshing: "Actualisation des fichiers…", hideHiddenFiles: "Masquer les fichiers cachés", showHiddenFiles: "Afficher les fichiers cachés", }, @@ -519,16 +524,16 @@ export const fr: TranslationResources = { noVisibleFiles: "Aucun fichier visible", }, states: { - unavailable: "Workspacen'est pas disponible", - loading: "Chargement des fichiers...", + unavailable: "L’espace de travail est indisponible", + loading: "Chargement des fichiers…", }, errors: { - failedToListDirectory: "Échec de la liste du répertoire", - createFailed: "Échec de la création de l'entrée", - renameFailed: "Échec du renommage de l'entrée", - duplicateFailed: "Échec de la duplication de l'entrée", - revealFailed: "Échec de l'affichage de l'entrée", - deleteFailed: "Échec de la suppression de l'entrée", + failedToListDirectory: "Impossible de lister le répertoire", + createFailed: "Impossible de créer l’élément", + renameFailed: "Impossible de renommer l’élément", + duplicateFailed: "Impossible de dupliquer l’élément", + revealFailed: "Impossible d’afficher l’élément", + deleteFailed: "Impossible de supprimer l’élément", }, draft: { filePlaceholder: "Nom du fichier", @@ -537,33 +542,33 @@ export const fr: TranslationResources = { }, setup: { descriptor: { - label: "Installation", + label: "Configuration", completed: "Configuration terminée", - failed: "Échec de l'installation", - workspace: "ConfigurationWorkspace", + failed: "Échec de la configuration", + workspace: "Configuration de l’espace de travail", blocked: "Configuration bloquée", }, status: { - running: "En cours d'exécution", - completed: "Complété", - failed: "Échoué", - waiting: "En attente de la sortie de configuration", + running: "En cours", + completed: "Terminé", + failed: "Échec", + waiting: "En attente de la sortie de la configuration", blocked: "Bloqué", }, - waiting: "Configuration de l'espace de travail...", + waiting: "Configuration de l’espace de travail…", blocked: { - title: "La configuration n’a pas été exécutée", + title: "La configuration n’a pas été lancée", description: - "Cette PR provient de {{repository}}, un dépôt différent. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas examiné.", - run: "Exécuter la configuration", - runFailed: "Échec de la configuration de l’espace de travail", + "Cette PR provient de {{repository}}, un autre dépôt. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas relu.", + run: "Lancer la configuration", + runFailed: "Impossible de lancer la configuration de l’espace de travail", }, empty: { - noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail.", + noCommands: "Aucune commande de configuration n’a été exécutée pour cet espace de travail.", }, accessibility: { - noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail", - log: "Journal de configurationWorkspace", + noCommands: "Aucune commande de configuration n’a été exécutée pour cet espace de travail", + log: "Journal de configuration de l’espace de travail", }, log: { noOutput: "Aucune sortie", @@ -571,177 +576,180 @@ export const fr: TranslationResources = { }, browser: { unavailable: { - title: "Le navigateur est réservé au bureau", + title: "Le navigateur est réservé à l’app de bureau", subtitle: "Ouvrez cet espace de travail dans Electron pour utiliser le navigateur intégré.", }, - session: "Session de navigateur{{browserId}}", + session: "Session de navigateur {{browserId}}", controls: { - back: "Dos", - forward: "Avant", + back: "Retour", + forward: "Suivant", stopLoading: "Arrêter le chargement", - refresh: "Rafraîchir", - browserUrl: "NavigateurURL", - enterUrl: "EntrezURL", - openDevTools: "Outils de développement du navigateur ouvert", - cancelSelector: "Annuler le sélecteur d'élément", - annotateElement: "Annoter l'élément", - screenshotElement: "Capturer l'élément", - screenshotCopied: "Capture d'écran copiée dans le presse-papiers", + refresh: "Actualiser", + browserUrl: "URL du navigateur", + enterUrl: "Saisir une URL", + openDevTools: "Ouvrir les outils de développement du navigateur", + cancelSelector: "Annuler la sélection d’élément", + annotateElement: "Annoter l’élément", + screenshotElement: "Capturer l’élément", + screenshotCopied: "Capture d’écran copiée dans le presse-papiers", elementCopied: "Élément copié dans le presse-papiers", - screenshotFailed: "Impossible de copier la capture", + screenshotFailed: "Impossible de copier la capture d’écran", selectorLoading: "Attendez la fin du chargement de la page", - selectorFailed: "Impossible de démarrer le sélecteur d’élément", + selectorFailed: "Impossible de lancer le sélecteur d’élément", }, annotate: { - title: "Annoter l'élément", - placeholder: "Message à l'agent concernant cet élément…", + title: "Annoter l’élément", + placeholder: "Message à l’agent à propos de cet élément…", submit: "Joindre", cancel: "Annuler", }, devices: { - label: "Taille de l'appareil", + label: "Taille de l’appareil", responsive: "Adaptatif", }, errors: { - failedToLoad: "Échec du chargement de la page", - invalidUrl: "NavigateurURLinvalide", - unsupportedProtocol: "Navigateur non pris en charge bloquéURL:{{protocol}}", + failedToLoad: "Impossible de charger la page", + invalidUrl: "URL du navigateur invalide", + unsupportedProtocol: "URL bloquée, protocole non pris en charge : {{protocol}}", }, }, terminal: { - hostDisconnected: "Hostn'est pas connecté", - updateHost: "Mettez à jour l’hôte pour utiliser le terminal natif.", - unableToSubscribe: "Impossible de s'abonner au terminal", + hostDisconnected: "L’hôte n’est pas connecté", + updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", + unableToSubscribe: "Impossible de s’abonner au terminal", + }, + sheet: { + openAsTab: "Ouvrir dans un onglet", }, tabs: { - loading: "Chargement...", + loading: "Chargement…", modified: "Modifications non enregistrées", - loadingAgentTitle: "Titre d'agent de chargement", + loadingAgentTitle: "Chargement du titre de l’agent…", fallback: { newAgent: "Nouvel agent", - setup: "Installation", - workspaceSetup: "ConfigurationWorkspace", + setup: "Configuration", + workspaceSetup: "Configuration de l’espace de travail", terminal: "Terminal", browser: "Navigateur", agent: "Agent", - workspace: "Workspace", + workspace: "Espace de travail", }, switcher: { - trigger: "Changer d'onglet ({{count}}ouvert)", - title: "Changer d'onglet", - searchPlaceholder: "Onglets de recherche", + trigger: "Changer d’onglet ({{count}} ouverts)", + title: "Changer d’onglet", + searchPlaceholder: "Rechercher des onglets", }, menu: { - openFor: "Ouvrir le menu pour{{label}}", + openFor: "Ouvrir le menu de {{label}}", copyResumeCommand: "Copier la commande de reprise", - copyAgentId: "Copier l'identifiant de l'agent", - copyTerminalId: "Copier l'identifiant du terminal", - copyFilePath: "Copy file path", - rename: "Rebaptiser", - closeAbove: "Fermer les onglets ci-dessus", - closeBelow: "Fermer les onglets ci-dessous", - closeLeft: "Près de la gauche", - closeRight: "Près de la droite", + copyAgentId: "Copier l’ID de l’agent", + copyTerminalId: "Copier l’ID du terminal", + copyFilePath: "Copier le chemin du fichier", + rename: "Renommer", + closeAbove: "Fermer les onglets au-dessus", + closeBelow: "Fermer les onglets en dessous", + closeLeft: "Fermer les onglets à gauche", + closeRight: "Fermer les onglets à droite", closeOthers: "Fermer les autres onglets", moveToMain: "Déplacer vers le panneau principal", - reloadAgent: "Agent de rechargement", + reloadAgent: "Recharger l’agent", reloadAgentTooltip: - "Rechargez l'agent pour mettre à jour les compétences, les MCP ou le statut de connexion.", + "Rechargez l’agent pour mettre à jour les skills, les MCP ou l’état de connexion.", close: "Fermer", renameTerminal: "Renommer le terminal", - renameAgent: "Renommer l'agent", + renameAgent: "Renommer l’agent", }, actions: { newTab: "Nouvel onglet", newAgent: "Nouvel agent", newTerminal: "Nouveau terminal", - preparingTerminal: "Préparation de l'onglet du terminal", - preparingTerminalTooltip: "Préparation du terminal...", + preparingTerminal: "Préparation de l’onglet de terminal…", + preparingTerminalTooltip: "Préparation du terminal…", newBrowser: "Nouveau navigateur", - maximizePane: "Agrandir le volet", - restorePane: "Restaurer le volet", - closePane: "Fermer le volet", + maximizePane: "Agrandir le panneau", + restorePane: "Restaurer le panneau", + closePane: "Fermer le panneau", exitFocusMode: "Quitter le mode concentration", - splitRight: "Volet divisé à droite", - splitDown: "Diviser le volet vers le bas", + splitRight: "Diviser le panneau à droite", + splitDown: "Diviser le panneau vers le bas", changes: "Modifications", files: "Fichiers", - pullRequest: "Demande de fusion", - terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles", + pullRequest: "Pull request", + terminalProfilesMenu: "Profils de terminal", + editTerminalProfiles: "Modifier les profils", }, explorerSidebar: { - open: "Ouvrir le panneau latéral", - close: "Fermer le panneau latéral", - toggle: "Basculer le panneau latéral", - changes: "Changements", + open: "Ouvrir la barre latérale de l’explorateur", + close: "Fermer la barre latérale de l’explorateur", + toggle: "Afficher/masquer la barre latérale de l’explorateur", + changes: "Modifications", files: "Fichiers", }, toasts: { - copyFailed: "Échec de la copie", - agentIdCopiedLabel: "AgentID", - terminalIdCopiedLabel: "Identifiant du terminal", - resumeCommandCopiedLabel: "reprendre la commande", - filePathCopiedLabel: "File path", - resumeIdUnavailable: "ReprendreIDnon disponible", - resumeCommandUnavailable: "Commande de reprise non disponible", - reloadingAgent: "Agent de rechargement...", + copyFailed: "Impossible de copier", + agentIdCopiedLabel: "ID de l’agent", + terminalIdCopiedLabel: "ID du terminal", + resumeCommandCopiedLabel: "Commande de reprise", + filePathCopiedLabel: "Chemin du fichier", + resumeIdUnavailable: "ID de reprise indisponible", + resumeCommandUnavailable: "Commande de reprise indisponible", + reloadingAgent: "Rechargement de l’agent…", reloadedAgent: "Agent rechargé", - failedToReloadAgent: "Échec du rechargement de l'agent", - failedToCloseAgent: "Échec de la fermeture de l'agent", + failedToReloadAgent: "Impossible de recharger l’agent", + failedToCloseAgent: "Impossible de fermer l’agent", }, confirmations: { unsavedTitle: "Modifications non enregistrées", unsavedMessage: "Cet onglet contient des modifications non enregistrées. Le fermer supprimera le brouillon.", closeWithoutSaving: "Fermer sans enregistrer", - closePaneTitle: "Fermer le volet?", + closePaneTitle: "Fermer le panneau ?", bulkUnsaved: "{{count}} onglet(s) contiennent des modifications non enregistrées. Les fermer supprimera ces brouillons.", close: "Fermer", cancel: "Annuler", - archive: "Archive", - closeTerminalTitle: "Fermer le terminal?", - closeTerminalMessage: - "Tout processus en cours d’exécution dans ce terminal sera immédiatement arrêté.", - archiveRunningAgentTitle: "Archiver l'agent en cours d'exécution?", + archive: "Archiver", + closeTerminalTitle: "Fermer le terminal ?", + closeTerminalMessage: "Tout processus en cours dans ce terminal sera arrêté immédiatement.", + archiveRunningAgentTitle: "Archiver l’agent en cours ?", archiveRunningAgentMessage: - "Cet agent est toujours en cours d'exécution. L'archiver arrêtera l'agent et fermera l'onglet.", - closeTabsLeftTitle: "Fermer les onglets à gauche?", - closeTabsRightTitle: "Fermer les onglets à droite?", - closeOtherTabsTitle: "Fermer les autres onglets?", + "Cet agent est toujours en cours d’exécution. L’archiver arrêtera l’agent et fermera l’onglet.", + closeTabsLeftTitle: "Fermer les onglets à gauche ?", + closeTabsRightTitle: "Fermer les onglets à droite ?", + closeOtherTabsTitle: "Fermer les autres onglets ?", bulk: { - all: "Cela archivera les agents{{agents}}, fermera les terminaux{{terminals}}et fermera les onglets{{tabs}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + all: "Cette action archivera {{agents}} agent(s), fermera {{terminals}} terminal(s) et fermera {{tabs}} onglet(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", agentsAndTerminals: - "Cela archivera les agents{{agents}}et fermera les terminaux{{terminals}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + "Cette action archivera {{agents}} agent(s) et fermera {{terminals}} terminal(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", terminalsAndTabs: - "Cela fermera le(s) terminal(s){{terminals}}et fermera le(s) onglet(s){{tabs}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", - agentsAndTabs: "Cela archivera les agents{{agents}}et fermera les onglets{{tabs}}.", + "Cette action fermera {{terminals}} terminal(s) et {{tabs}} onglet(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", + agentsAndTabs: + "Cette action archivera {{agents}} agent(s) et fermera {{tabs}} onglet(s).", terminals: - "Cela fermera le(s) terminal(s){{terminals}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", - tabs: "Cela fermera les onglets{{tabs}}.", - agents: "Cela archivera les agents{{agents}}.", + "Cette action fermera {{terminals}} terminal(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", + tabs: "Cette action fermera {{tabs}} onglet(s).", + agents: "Cette action archivera {{agents}} agent(s).", }, }, }, header: { actions: { - workspaceActions: "ActionsWorkspace", + workspaceActions: "Actions de l’espace de travail", newAgent: "Nouvel agent", - newTerminal: "Nouvelle borne", - newBrowser: "Nouvel onglet du navigateur", - importSession: "Session d'importation", - copyPath: "Copier le chemin de l'espace de travail", + newTerminal: "Nouveau terminal", + newBrowser: "Nouvel onglet de navigateur", + importSession: "Importer une session", + copyPath: "Copier le chemin de l’espace de travail", copyBranchName: "Copier le nom de la branche", showSetup: "Afficher la configuration", }, toasts: { - workspacePathUnavailable: "Le cheminWorkspacen'est pas encore disponible", - branchNameUnavailable: "Nom de la succursale non disponible", + workspacePathUnavailable: "Le chemin de l’espace de travail n’est pas encore disponible", + branchNameUnavailable: "Nom de la branche indisponible", terminalQueued: - "Préparation de l'espace de travail, ouverture du terminal lorsque vous êtes prêt...", - workspacePathCopiedLabel: "CheminWorkspace", - branchNameCopiedLabel: "Nom de la succursale", + "Préparation de l’espace de travail, le terminal s’ouvrira dès qu’il sera prêt…", + workspacePathCopiedLabel: "Chemin de l’espace de travail", + branchNameCopiedLabel: "Nom de la branche", }, }, scripts: { @@ -751,231 +759,224 @@ export const fr: TranslationResources = { copyUrl: "Copier l’URL", openService: "Voir le service", restart: "Redémarrer", - run: "Courir", + run: "Lancer", stop: "Arrêter", view: "Voir le terminal", }, accessibility: { - trigger: "ScriptsWorkspace", + trigger: "Scripts de l’espace de travail", openService: "Voir le service {{scriptName}}", - viewTerminal: "Voir le terminal{{scriptName}}", - runScript: "Exécuter le script{{scriptName}}", - stopScript: "Arrêter{{scriptName}}", - restartScript: "Redémarrer{{scriptName}}", - copyUrl: "Copier l'URL de{{scriptName}}", - chooseUrl: "Choisir l’URL pour {{scriptName}}", - script: "Script{{scriptName}}", + viewTerminal: "Voir le terminal de {{scriptName}}", + runScript: "Lancer le script {{scriptName}}", + stopScript: "Arrêter {{scriptName}}", + restartScript: "Redémarrer {{scriptName}}", + copyUrl: "Copier l’URL de {{scriptName}}", + chooseUrl: "Choisir l’URL de {{scriptName}}", + script: "Script {{scriptName}}", }, routes: { public: "Proxy inverse", - paseo: "Memorable", + paseo: "Mémorable", direct: "Directe", }, states: { - exitCode: "quitter{{code}}", - startFailed: "Échec du démarrage de{{scriptName}}", - stopFailed: "Échec de l'arrêt de{{scriptName}}", + exitCode: "sortie {{code}}", + startFailed: "Impossible de démarrer {{scriptName}}", + stopFailed: "Impossible d’arrêter {{scriptName}}", }, }, tree: { - showFolderTree: "Afficher l'arborescence des dossiers", - hideFolderTree: "Masquer l'arborescence des dossiers", + showFolderTree: "Afficher l’arborescence des dossiers", + hideFolderTree: "Masquer l’arborescence des dossiers", }, git: { actions: { - moreOptions: "Plus d'options", - moreActions: "Plus de propositions", + moreOptions: "Plus d’options", + moreActions: "Plus d’actions", commit: { - label: "Commettre", - pending: "S'engager...", - success: "Engagé", + label: "Commiter", + pending: "Commit en cours…", + success: "Commité", }, pull: { - label: "Tirer", - pending: "Tirer...", - success: "Tiré", + label: "Pull", + pending: "Pull en cours…", + success: "Pull effectué", }, push: { - label: "Pousser", - pending: "Pousser...", - success: "Poussé", + label: "Push", + pending: "Push en cours…", + success: "Push effectué", }, pullAndPush: { - label: "Tirez et poussez", - pending: "Tirer et pousser...", - success: "Tiré et poussé", + label: "Pull et push", + pending: "Pull et push en cours…", + success: "Pull et push effectués", }, - viewPr: "VoirPR", + viewPr: "Voir la PR", createPr: { - label: "CréerPR", - pending: "Création dePR...", - success: "PRcréé", - label_mr: "Créer MR", - pending_mr: "Création de MR...", - success_mr: "MR créé", + label: "Créer une PR", + pending: "Création de la PR…", + success: "PR créée", + label_mr: "Créer une MR", + pending_mr: "Création de la MR…", + success_mr: "MR créée", }, mergeBranch: { - label: "Fusionner localement", - pending: "Fusion...", + label: "Fusionner en local", + pending: "Fusion…", success: "Fusionné", }, mergeFromBase: { - label: "Mise à jour de{{baseRef}}", - pending: "Mise à jour...", + label: "Mettre à jour depuis {{baseRef}}", + pending: "Mise à jour…", success: "Mis à jour", }, archive: { label: "Archiver l’espace de travail", - pending: "Archivage...", + pending: "Archivage…", success: "Archivé", }, mergePr: { - squash: "Fusionner PR (squash)", - merge: "Fusionner PR (merge)", - rebase: "Fusionner PR (rebase)", - pending: "Fusion de PR...", - success: "PR fusionné", - squash_mr: "Fusionner MR (squash)", - merge_mr: "Fusionner MR (merge)", - rebase_mr: "Fusionner MR (rebase)", - pending_mr: "Fusion de MR...", - success_mr: "MR fusionné", + squash: "Fusionner la PR (squash)", + merge: "Fusionner la PR (merge)", + rebase: "Fusionner la PR (rebase)", + pending: "Fusion de la PR…", + success: "PR fusionnée", + squash_mr: "Fusionner la MR (squash)", + merge_mr: "Fusionner la MR (merge)", + rebase_mr: "Fusionner la MR (rebase)", + pending_mr: "Fusion de la MR…", + success_mr: "MR fusionnée", }, - viewPr_mr: "Voir MR", + viewPr_mr: "Voir la MR", autoMerge: { enableSquash: "Fusion automatique (squash)", enableMerge: "Fusion automatique (merge)", enableRebase: "Fusion automatique (rebase)", enabled: "Fusion automatique activée", - enabling: "Activation de la fusion automatique...", - disabling: "Désactivation de la fusion automatique...", + enabling: "Activation de la fusion automatique…", + disabling: "Désactivation de la fusion automatique…", disabled: "Fusion automatique désactivée", }, unavailable: { viewPrNoForge: - "Voir {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté", + "Impossible de voir la {{noun}} pour l’instant : {{brand}} n’est pas connecté", pullNoRemote: - "Pull n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande", + "Pull indisponible : cette branche n’est pas encore liée à un dépôt distant", pullDirty: - "Pull n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", - pullUpToDate: "Pull n'est pas disponible car cette branche est déjà à jour", + "Pull indisponible tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", + pullUpToDate: "Pull indisponible : cette branche est déjà à jour", pushNoRemote: - "Push n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande", + "Push indisponible : cette branche n’est pas encore liée à un dépôt distant", pushBehind: - "Push n'est pas encore disponible, car de nouvelles modifications doivent être apportées en premier.", - pushNothing: "Push n'est pas disponible car il n'y a rien de nouveau à envoyer", + "Push indisponible pour l’instant : des modifications plus récentes doivent d’abord être récupérées", + pushNothing: "Push indisponible : il n’y a rien de nouveau à pousser", pullAndPushNoRemote: - "Le pull et le push ne sont pas disponibles ici car cette branche n'est pas encore connectée à une télécommande", + "Pull et push indisponibles : cette branche n’est pas encore liée à un dépôt distant", pullAndPushDirty: - "Pull et push ne sont pas disponibles tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + "Pull et push indisponibles tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", pullAndPushNoIncoming: - "Pull et push ne sont pas disponibles car il n'y a aucun changement entrant à récupérer d'abord", - pullAndPushInSync: - "Les fonctions Pull et Push ne sont pas disponibles car cette branche est déjà synchronisée", + "Pull et push indisponibles : aucune modification entrante à récupérer", + pullAndPushInSync: "Pull et push indisponibles : cette branche est déjà synchronisée", pullAndPushNothingToPush: - "Pull et push ne sont pas disponibles car il n'y a rien de nouveau à envoyer après le pull", + "Pull et push indisponibles : il n’y aurait rien de nouveau à pousser après le pull", createPrNoForge: - "Créer {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté", + "Impossible de créer une {{noun}} pour l’instant : {{brand}} n’est pas connecté", createPrNoCommits: - "CréerPRn'est pas disponible car cette branche n'a pas encore de nouveaux commits", - mergeNoBase: - "La fusion n'est pas disponible car nous n'avons pas pu déterminer la branche de base", + "Impossible de créer une PR : cette branche n’a pas encore de nouveau commit", + mergeNoBase: "Fusion indisponible : impossible de déterminer la branche de base", mergeDirty: - "La fusion n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + "Fusion indisponible tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", mergeNothing: - "La fusion n'est pas disponible car cette branche n'a encore rien de nouveau à fusionner", - updateNoBase: - "La mise à jour n'est pas disponible car nous n'avons pas pu déterminer la branche de base", + "Fusion indisponible : cette branche n’a encore rien de nouveau à fusionner", + updateNoBase: "Mise à jour indisponible : impossible de déterminer la branche de base", updateDirty: - "La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + "Mise à jour indisponible tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", updateCurrent: - "La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}", + "Mise à jour indisponible : cette branche est déjà à jour avec {{baseRef}}", mergePrNoGithub: - "La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté", + "Impossible de fusionner la PR pour l’instant : GitHub n’est pas connecté", archiveNotWorktree: - "L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo", + "Archivage indisponible : cet espace de travail n’a pas été créé comme worktree Paseo", mergePrNoForge: - "La fusion {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté", - mergePrMissing: - "La fusionPRn'est pas disponible car il n'y a pas encore de demande d'extraction", - mergePrDraft: - "La fusionPRn'est pas disponible car la demande d'extraction est encore un brouillon", - mergePrMerged: - "La fusionPRn'est pas disponible car la demande d'extraction est déjà fusionnée", - mergePrClosed: "La fusionPRn'est pas disponible car la demande d'extraction est fermée", - mergePrConflicts: - "La fusionPRn'est pas disponible car la demande d'extraction présente des conflits", + "Impossible de fusionner la {{noun}} pour l’instant : {{brand}} n’est pas connecté", + mergePrMissing: "Impossible de fusionner la PR : il n’y a pas encore de pull request", + mergePrDraft: "Impossible de fusionner la PR : la pull request est encore un brouillon", + mergePrMerged: "Impossible de fusionner la PR : la pull request est déjà fusionnée", + mergePrClosed: "Impossible de fusionner la PR : la pull request est fermée", + mergePrConflicts: "Impossible de fusionner la PR : la pull request a des conflits", mergePrQueue: - "MergePRn'est pas disponible ici car ce référentiel utilise une file d'attente de fusion", + "Impossible de fusionner la PR ici : ce dépôt utilise une file d’attente de fusion", mergePrNotReady: - "La fusion {{noun}} n'est pas disponible jusqu'à ce que {{brand}} signale que {{noun}} est prête à fusionner", + "Impossible de fusionner la {{noun}} tant que {{brand}} n’indique pas que la {{noun}} est prête à être fusionnée", autoMergeCannotDisable: "La fusion automatique est activée, mais ce compte ne peut pas la désactiver", }, toasts: { - failedCommit: "Échec de la validation", - failedPull: "Échec de l'extraction", - failedPush: "Échec de la poussée", - failedPullAndPush: "Impossible de tirer et de pousser", - failedCreatePr: "Échec de la création dePR", - failedMergePr: "Échec de la fusion dePR", - failedEnableAutoMerge: "Échec de l'activation de la fusion automatique", - failedDisableAutoMerge: "Échec de la désactivation de la fusion automatique", - baseRefUnavailable: "Réf de base indisponible", + failedCommit: "Impossible de commiter", + failedPull: "Échec du pull", + failedPush: "Échec du push", + failedPullAndPush: "Échec du pull et du push", + failedCreatePr: "Impossible de créer la PR", + failedMergePr: "Impossible de fusionner la PR", + failedEnableAutoMerge: "Impossible d’activer la fusion automatique", + failedDisableAutoMerge: "Impossible de désactiver la fusion automatique", + baseRefUnavailable: "Référence de base indisponible", failedMerge: "Échec de la fusion", - failedMergeFromBase: "Échec de la fusion à partir de la base", + failedMergeFromBase: "Échec de la fusion depuis la base", }, archiveWarning: { - title: "Archiver «{{workspaceName}}»?", - confirm: "Archive", + title: "Archiver « {{workspaceName}} » ?", + confirm: "Archiver", cancel: "Annuler", - uncommittedChanges: "Modifications non validées", - uncommittedChangesWithDiff: "Modifications non validées ({{diffStat}})", - addedLine: "Ligne ajoutée{{count}}", - addedLines: "{{count}}lignes ajoutées", - deletedLine: "Ligne supprimée{{count}}", - deletedLines: "{{count}}lignes supprimées", - unpushedCommit: "Validation non poussée{{count}}", - unpushedCommits: "Validations non poussées{{count}}", + uncommittedChanges: "Modifications non commitées", + uncommittedChangesWithDiff: "Modifications non commitées ({{diffStat}})", + addedLine: "{{count}} ligne ajoutée", + addedLines: "{{count}} lignes ajoutées", + deletedLine: "{{count}} ligne supprimée", + deletedLines: "{{count}} lignes supprimées", + unpushedCommit: "{{count}} commit non poussé", + unpushedCommits: "{{count}} commits non poussés", }, }, diff: { - openChangesTab: "Ouvrir l'onglet Modifications", - openDiffTab: "Ouvrir l'onglet Diff", - closeChangesTab: "Fermer l'onglet Modifications", + openChangesTab: "Ouvrir l’onglet Modifications", + openDiffTab: "Ouvrir l’onglet Diff", + closeChangesTab: "Fermer l’onglet Modifications", jumpToFile: { title: "Aller au fichier", }, binaryFile: "Fichier binaire", - tooLarge: "Diff trop grand pour être affiché", + tooLarge: "Diff trop volumineux pour être affiché", previewTooLargeTitle: "Ce diff est trop volumineux pour être prévisualisé", - previewTooLargeDescription: "Réduisez la comparaison pour la prévisualiser ici", - unified: "Différentiel unifié", - split: "Différent côte à côte", + previewTooLargeDescription: "Réduisez la comparaison pour le prévisualiser ici", + unified: "Diff unifié", + split: "Diff côte à côte", switchToUnified: "Passer au diff unifié", switchToSplit: "Passer au diff côte à côte", options: "Options du diff", - inlineDiff: "Diff intégré", + inlineDiff: "Diff en ligne", hideWhitespace: "Masquer les espaces", showWhitespace: "Afficher les espaces", scrollLongLines: "Faire défiler les longues lignes", - wrapLongLines: "Enroulez les longues lignes", - collapseAllFolders: "Réduire tous les dossiers", - expandAllFolders: "Développer tous les dossiers", - collapseAllFiles: "Réduire tous les fichiers", - expandAllFiles: "Développer tous les fichiers", - refreshing: "Rafraîchissant", - refresh: "Rafraîchir", - refreshState: "Actualiser l'état de git et de {{brand}}", - failedRefresh: "Échec de l'actualisation de l'état git.", - seeUncommittedChanges: "Voir les modifications non validées", - seeCommittedChanges: "Voir les modifications validées", - checkingRepository: "Vérification du référentiel...", - notRepository: "Pas un dépôt git", - diffMode: "Mode différentiel", - uncommitted: "Non engagé", - committed: "Engagé", - branchUnknown: "Inconnu", + wrapLongLines: "Renvoyer les longues lignes à la ligne", + collapseAllFolders: "Replier tous les dossiers", + expandAllFolders: "Déplier tous les dossiers", + collapseAllFiles: "Replier tous les fichiers", + expandAllFiles: "Déplier tous les fichiers", + refreshing: "Actualisation…", + refresh: "Actualiser", + refreshState: "Actualiser l’état de git et de {{brand}}", + failedRefresh: "Impossible d’actualiser l’état git.", + seeUncommittedChanges: "Voir les modifications non commitées", + seeCommittedChanges: "Voir les modifications commitées", + checkingRepository: "Vérification du dépôt…", + notRepository: "Ce n’est pas un dépôt git", + diffMode: "Mode de diff", + uncommitted: "Non commité", + committed: "Commité", + branchUnknown: "Inconnue", base: "base", newFile: "Nouveau", deletedFile: "Supprimé", @@ -983,81 +984,128 @@ export const fr: TranslationResources = { commits: { title: "Commits", countLabel: "{{count}} commits de l’espace de travail", - noneAhead: "Aucun commit en avance sur {{baseRef}} pour le moment", + noneAhead: "Aucun commit en avance sur {{baseRef}} pour l’instant", fileDiffEmpty: "Aucune modification à afficher", - fileDiffError: "Échec du chargement du diff du fichier", + fileDiffError: "Impossible de charger le diff du fichier", loading: "Chargement des commits…", - loadError: "Échec du chargement des commits", - empty: "Aucun commit pour le moment", + loadError: "Impossible de charger les commits", + empty: "Aucun commit pour l’instant", }, }, openInEditor: { open: "Ouvrir", - chooseEditor: "Choisir l'éditeur", - openIn: "Espace de travail ouvert dans{{target}}", - openFileIn: "Open {{fileName}} in {{target}}", - failedOpen: "Échec de l'ouverture de l'espace de travail", + chooseEditor: "Choisir l’éditeur", + openIn: "Ouvrir l’espace de travail dans {{target}}", + openFileIn: "Ouvrir {{fileName}} dans {{target}}", + failedOpen: "Impossible d’ouvrir l’espace de travail", }, pr: { actions: { viewPullRequest: "Voir", openOn: "Ouvrir sur {{brand}}", + addToChat: "Ajouter à la conversation", + addAllToChat: "Tout ajouter à la conversation", + addingToChat: "Ajout…", + }, + checksOverview: { + headline: { + actionRequired: "Certaines vérifications demandent votre attention", + failure: "Certaines vérifications ont échoué", + pending: "Certaines vérifications ne sont pas terminées", + success: "Toutes les vérifications ont réussi", + none: "Aucune vérification", + }, + count: { + actionRequired: "{{count}} à traiter", + warning: "{{count}} avec avertissement", + failure: "{{count}} en échec", + pending: "{{count}} en cours", + manual: "{{count}} manuelle(s)", + success: "{{count}} réussie(s)", + ignored: "{{count}} ignorée(s)", + }, + detailOne: "Vérification : {{parts}}", + detailMany: "Vérifications : {{parts}}", + groupOne: { + actionRequired: "{{count}} vérification à traiter", + warning: "{{count}} vérification avec avertissement", + failure: "{{count}} vérification en échec", + pending: "{{count}} vérification en cours", + manual: "{{count}} vérification manuelle", + success: "{{count}} vérification réussie", + ignored: "{{count}} vérification ignorée", + }, + groupMany: { + actionRequired: "{{count}} vérifications à traiter", + warning: "{{count}} vérifications avec avertissement", + failure: "{{count}} vérifications en échec", + pending: "{{count}} vérifications en cours", + manual: "{{count}} vérifications manuelles", + success: "{{count}} vérifications réussies", + ignored: "{{count}} vérifications ignorées", + }, }, checksSummary: { - passedLabel: "succès", - failedLabel: "échec", + passedLabel: "réussies", + failedLabel: "en échec", runningLabel: "en cours", passedAccessible: "Vérifications réussies", failedAccessible: "Vérifications en échec", runningAccessible: "Vérifications en cours", }, sections: { - checks: "Chèques", + checks: "Vérifications", pipeline: "Pipeline", - reviews: "Avis", + reviews: "Revues", + activity: "Activité", }, empty: { - noJobs: "Aucune tâche", - loadingPipeline: "Chargement du pipeline...", - pipelineJobsLoadFailed: "Impossible de charger les tâches du pipeline", - allowedToFail: "autorisé à échouer", + noJobs: "Aucun job", + loadingPipeline: "Chargement du pipeline…", + pipelineJobsLoadFailed: "Impossible de charger les jobs du pipeline", + allowedToFail: "échec autorisé", + noActivity: "Aucune activité pour le moment", }, approvals: "{{given}} sur {{required}} approbations", accessibility: { - pullRequest: "Demande de tirage #{{number}}", - pullRequest_mr: "Demande de fusion !{{number}}", + pullRequest: "Pull request #{{number}}", + pullRequest_mr: "Merge request !{{number}}", + commentActions: "Actions du commentaire", + threadActions: "Actions du fil", checkStatus: { - passed: "Réussi", + passed: "Réussie", failed: "Échec", warning: "Avertissement", actionRequired: "Action requise", - manual: "Manuel", + manual: "Manuelle", pending: "En attente", - skipped: "Ignoré", - cancelled: "Annulé", + skipped: "Ignorée", + cancelled: "Annulée", }, }, states: { draft: "Brouillon", - merged: "Fusionné", - closed: "Fermé", - open: "Ouvrir", + merged: "Fusionnée", + closed: "Fermée", + open: "Ouverte", }, activity: { - commented: "Commenté", - approved: "Approuvé", - requestedChanges: "Modifications demandées", - reviewed: "Révisé", + commented: "A commenté", + approved: "A approuvé", + requestedChanges: "A demandé des modifications", + reviewed: "A relu", }, time: { - justNow: "tout à l' heure", + justNow: "à l’instant", }, thread: { discussion: "Fil de discussion", + resolved: "Résolu", + outdated: "Obsolète", }, errors: { - statusLoadFailed: "Impossible de charger le statut de la demande d'extraction", - activityLoadFailed: "Impossible de charger l'activité de demande d'extraction", + statusLoadFailed: "Impossible de charger le statut de la pull request", + activityLoadFailed: "Impossible de charger l’activité de la pull request", }, }, forgeSetup: { @@ -1086,7 +1134,7 @@ export const fr: TranslationResources = { orange: "Orange", pink: "Rose", indigo: "Indigo", - teal: "Sarcelle", + teal: "Bleu canard", red: "Rouge", amber: "Ambre", blue: "Bleu", @@ -1105,7 +1153,7 @@ export const fr: TranslationResources = { color: "Couleur", save: "Enregistrer", delete: "Supprimer", - deleteTitle: "Supprimer {{name}} ?", + deleteTitle: "Supprimer {{name}} ?", deleteMessage_one: "Cela retire l’étiquette de {{count}} espace de travail sur cet hôte.", deleteMessage_other: "Cela retire l’étiquette de {{count}} espaces de travail sur cet hôte.", offline: "Cet hôte est hors ligne.", @@ -1123,8 +1171,17 @@ export const fr: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Groupe {{label}}", + statusBucket: { + needsInput: "Attend une réponse", + failed: "Échec", + readyToReview: "À relire", + working: "En cours", + done: "Terminé", + }, display: { - trigger: "Préférences d'affichage", + showBackground: "Afficher les espaces en arrière-plan", + trigger: "Préférences d’affichage", heading: "Affichage", grouping: { label: "Regroupement", @@ -1146,7 +1203,7 @@ export const fr: TranslationResources = { checks: "Vérifications", services: "Services", labels: "Libellés", - diff: "Statistiques de diff", + diff: "Statistiques du diff", timestamp: "Dernière activité", }, checks: { @@ -1174,8 +1231,8 @@ export const fr: TranslationResources = { }, host: { noHost: "Aucun hôte", - switchTitle: "Changer d'hôte", - searchPlaceholder: "Rechercher des hôtes...", + switchTitle: "Changer d’hôte", + searchPlaceholder: "Rechercher des hôtes…", }, actions: { addProject: "Ajouter un projet", @@ -1200,93 +1257,93 @@ export const fr: TranslationResources = { }, sections: { sessions: "Historique", - search: "Rechercher", + search: "Recherche", schedules: "Planifications", }, worktreeSetup: { - title: "Configurer les scripts d'arbre de travail", + title: "Configurer les scripts de worktree", description: - "Ajoutez des commandes de configuration pour que les nouveaux arbres de travail puissent installer des dépendances et se préparer automatiquement.", + "Ajoutez des commandes de configuration pour que les nouveaux worktrees installent leurs dépendances et se préparent automatiquement.", openProjectSettings: "Ouvrir les paramètres du projet", }, project: { actions: { menu: "Actions du projet", openSettings: "Ouvrir les paramètres du projet", - openNewWindow: "Open in new window", - openNewWindowFailed: "Couldn't open a new window", - openFolder: "Open in file manager", - openFolderFailed: "Couldn't open folder", - remove: "Supprimer le projet", - removing: "Suppression...", + openNewWindow: "Ouvrir dans une nouvelle fenêtre", + openNewWindowFailed: "Impossible d’ouvrir une nouvelle fenêtre", + openFolder: "Ouvrir dans le gestionnaire de fichiers", + openFolderFailed: "Impossible d’ouvrir le dossier", + remove: "Retirer le projet", + removing: "Retrait…", }, confirmations: { - removeTitle: "Supprimer le projet?", + removeTitle: "Retirer le projet ?", removeMessage: - "Supprimer «{{projectName}}» de la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", + "Retirer « {{projectName}} » de la barre latérale ?\n\nLes fichiers sur le disque ne seront pas modifiés.", removeConfirm: "Retirer", cancel: "Annuler", }, toasts: { - hostDisconnected: "Hostn'est pas connecté", - removeFailed: "Échec de la suppression de certains espaces de travail", - updateHostToRemove: "Mettez à jour le host pour supprimer des projets.", + hostDisconnected: "L’hôte n’est pas connecté", + removeFailed: "Impossible de retirer certains espaces de travail", + updateHostToRemove: "Mettez à jour l’hôte pour retirer des projets.", }, empty: { - title: "Aucun projet pour l'instant", + title: "Aucun projet pour l’instant", description: "Ajoutez un projet pour commencer", }, }, workspace: { status: { - serviceRunning: "Service {{name}} en cours", + serviceRunning: "Service {{name}} en cours d’exécution", serviceUnhealthy: "Service {{name}} en échec", - creating: "Création...", + creating: "Création…", }, checks: { - passed: "Réussis : {{count}}", - failed: "Échecs : {{count}}", - warning: "Avertissements : {{count}}", - actionRequired: "Action requise : {{count}}", - manual: "Manuels : {{count}}", - pending: "En attente : {{count}}", + passed: "Réussies : {{count}}", + failed: "Échouées : {{count}}", + warning: "Avertissements : {{count}}", + actionRequired: "Action requise : {{count}}", + manual: "Manuelles : {{count}}", + pending: "En attente : {{count}}", }, actions: { - menu: "ActionsWorkspace", + menu: "Actions de l’espace de travail", newWorkspace: "Nouvel espace de travail", showMore: "Afficher plus", showLess: "Afficher moins", - createWorkspaceFor: "Créer un nouvel espace de travail pour{{projectName}}", + createWorkspaceFor: "Créer un espace de travail pour {{projectName}}", copyPath: "Copier le chemin", copyBranchName: "Copier le nom de la branche", - rename: "Renommer l'espace de travail", + rename: "Renommer l’espace de travail", pin: "Épingler en haut", unpin: "Désépingler", - archive: "Archive", + archive: "Archiver", archiveWorkspace: "Archiver l’espace de travail", hideFromSidebar: "Masquer de la barre latérale", - archiving: "Archivage...", - hiding: "Dissimulation...", + archiving: "Archivage…", + hiding: "Masquage…", }, confirmations: { - hideTitle: "Masquer l'espace de travail?", + hideTitle: "Masquer l’espace de travail ?", hideMessage: - "Masquer «{{workspaceName}}» dans la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", - hideConfirm: "Cacher", + "Masquer « {{workspaceName}} » de la barre latérale ?\n\nLes fichiers sur le disque ne seront pas modifiés.", + hideConfirm: "Masquer", cancel: "Annuler", }, rename: { - title: "Renommer l'espace de travail", - submit: "Rebaptiser", - invalidBranchName: "Nom de succursale invalide", + title: "Renommer l’espace de travail", + submit: "Renommer", + invalidBranchName: "Nom de branche invalide", }, toasts: { - workspacePathUnavailable: "CheminWorkspacenon disponible", + workspacePathUnavailable: "Chemin de l’espace de travail indisponible", pathCopied: "Chemin copié", - branchNameCopied: "Nom de la succursale copié", - hostDisconnected: "Hostn'est pas connecté", - hideFailed: "Échec du masquage de l'espace de travail", - archiveFailed: "Échec de l'archivage de l'espace de travail", + branchNameCopied: "Nom de branche copié", + hostDisconnected: "L’hôte n’est pas connecté", + hideFailed: "Impossible de masquer l’espace de travail", + archiveFailed: "Impossible d’archiver l’espace de travail", }, }, }, @@ -1305,34 +1362,34 @@ export const fr: TranslationResources = { }, titlePlaceholder: "Titre (facultatif)", errors: { - hostDisconnected: "Hostn'est pas connecté", - createWorktreeFailed: "Échec de la création de l'arbre de travail", - composerStateRequired: "L'état du compositeur est requis", + hostDisconnected: "L’hôte n’est pas connecté", + createWorktreeFailed: "Impossible de créer le worktree", + composerStateRequired: "L’état de la zone de saisie est requis", selectModel: "Sélectionnez un modèle", }, tooltips: { - project: "Choose the project", - host: "Choose the host", - isolation: "Choose the isolation level", - startingRef: "Choisissez par où commencer", - launch: "Choose what to launch", + project: "Choisir le projet", + host: "Choisir l’hôte", + isolation: "Choisir le niveau d’isolation", + startingRef: "Choisir le point de départ", + launch: "Choisir quoi lancer", }, refPicker: { - startingRef: "Réf de départ", - intoBase: "dans {{baseRef}}", - searching: "Recherche...", + startingRef: "Réf. de départ", + intoBase: "vers {{baseRef}}", + searching: "Recherche…", noMatchingRefs: "Aucune référence correspondante.", - searchPlaceholder: "Rechercher des succursales et des PR", - title: "Commencer à partir de", + searchPlaceholder: "Rechercher des branches et des PR", + title: "Partir de", }, launch: { - title: "What to launch", + title: "Que lancer ?", chat: "Chat", terminal: "Terminal", - manageProfiles: "Manage profiles", - submit: "Launch", - promptPlaceholder: "Prompt {{name}}", - commandPlaceholder: "Run a command, or leave empty for a blank terminal", + manageProfiles: "Gérer les profils", + submit: "Lancer", + promptPlaceholder: "Prompt pour {{name}}", + commandPlaceholder: "Saisissez une commande, ou laissez vide pour un terminal vierge", }, }, desktop: { @@ -1343,181 +1400,180 @@ export const fr: TranslationResources = { close: "Fermer la fenêtre", }, quitting: { - title: "QuitterPaseo...", - detail: "Arrêt du démon local.", + title: "Fermeture de Paseo…", + detail: "Arrêt du daemon local.", }, daemon: { lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Statut", - builtInOnly: "Seul le démon de bureau intégré est affiché ici", - running: "en cours d'exécution", - notRunning: "ne fonctionne pas", - pid: "PID{{pid}}", + builtInOnly: "Seul le daemon intégré à l’application de bureau est affiché ici", + running: "en cours d’exécution", + notRunning: "arrêté", + pid: "PID {{pid}}", }, management: { - title: "Gérer le démon intégré", - hint: "LaissezPaseodémarrer et arrêter le démon intégré", - pauseTitle: "Suspendre le démon intégré", + title: "Gérer le daemon intégré", + hint: "Laisser Paseo démarrer et arrêter le daemon intégré", + pauseTitle: "Suspendre le daemon intégré", pauseMessage: - "Cela arrêtera immédiatement le démon intégré. Les agents en cours d'exécution et les terminaux connectés au démon intégré seront arrêtés.", - pauseAndStop: "Pause et arrêt", + "Le daemon intégré sera arrêté immédiatement. Les agents en cours et les terminaux connectés au daemon intégré seront arrêtés.", + pauseAndStop: "Suspendre et arrêter", registrationFailed: - "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + "Le daemon intégré a démarré, mais Paseo n’a pas pu enregistrer la connexion localhost. Désactivez puis réactivez la gestion du daemon, ou ajoutez localhost manuellement.", pausedStopFailed: - "La gestion du démon intégré a été suspendue, maisPaseon'a ​​pas pu arrêter le démon.", - updateFailed: "Impossible de mettre à jour la gestion des démons intégrés.", + "La gestion du daemon intégré a été suspendue, mais Paseo n’a pas pu arrêter le daemon.", + updateFailed: "Impossible de mettre à jour la gestion du daemon intégré.", }, keepRunning: { - title: "Laisser le démon fonctionner après avoir quitté", - hint: "Daemoncontinue de fonctionner lorsque vous quittezPaseo", + title: "Laisser le daemon tourner après la fermeture", + hint: "Le daemon continue de tourner quand vous quittez Paseo", }, logs: { title: "Fichier journal", - modalTitle: "JournauxDaemon", + modalTitle: "Journaux du daemon", unavailable: "Chemin du journal indisponible", empty: "(le fichier journal est vide)", copied: "Chemin du journal copié.", copyFailed: "Impossible de copier le chemin du journal.", - open: "Journaux ouverts", + open: "Ouvrir les journaux", copyPath: "Copier le chemin", }, fullStatus: { title: "Statut complet", - modalTitle: "StatutDaemon", - hint: "Exécute`paseo daemon status`et affiche la sortie", - view: "Afficher l'état", + modalTitle: "Statut du daemon", + hint: "Exécute `paseo daemon status` et affiche la sortie", + view: "Afficher le statut", copied: "Statut copié dans le presse-papiers.", - fetchFailed: "Échec de la récupération de l'état du démon:{{message}}", + fetchFailed: "Impossible de récupérer le statut du daemon : {{message}}", }, advancedSettings: "Paramètres avancés", - openAdvancedSettings: "Ouvrir les paramètres avancés du démon", + openAdvancedSettings: "Ouvrir les paramètres avancés du daemon", versionMismatch: - "Les versions de l'application et du démon ne correspondent pas. Mettez à jour les deux vers la même version pour une meilleure expérience.", - loadFailed: "Impossible de charger l'état du démon de bureau.", + "Les versions de l’application et du daemon ne correspondent pas. Mettez-les à jour vers la même version pour une meilleure expérience.", + loadFailed: "Impossible de charger le statut du daemon de l’application de bureau.", }, updates: { status: { - checking: "Vérification des mises à jour de l'application...", - installing: "Installation de la mise à jour de l'application...", - upToDate: "L'application est à jour.", - upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", - pending: "Nous vous informerons lorsque la mise à jour sera prête.", + checking: "Recherche de mises à jour de l’application…", + installing: "Installation de la mise à jour de l’application…", + upToDate: "L’application est à jour.", + upToDateWithLastChecked: "À jour. Dernière vérification à {{time}}.", + pending: "Nous vous préviendrons quand la mise à jour sera prête.", pendingWithLastChecked: - "Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.", - pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...", + "Nous vous préviendrons quand la mise à jour sera prête. Dernière vérification à {{time}}.", + pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement…", pendingWithVersionAndLastChecked: - "Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.", - availableWithVersion: "Mise à jour prête:{{version}}", + "Mise à jour trouvée : {{version}}. Téléchargement… Dernière vérification à {{time}}.", + availableWithVersion: "Mise à jour prête : {{version}}", availableWithVersionAndLastChecked: - "Mise à jour prête:{{version}}. Dernière vérification à {{time}}.", - available: "Une mise à jour de l'application est prête à être installée.", + "Mise à jour prête : {{version}}. Dernière vérification à {{time}}.", + available: "Une mise à jour de l’application est prête à être installée.", availableWithLastChecked: - "Une mise à jour de l'application est prête à être installée. Dernière vérification à {{time}}.", - installed: "Mise à jour de l'application installée. Redémarrage requis.", - failed: "Échec de la mise à jour de l'application.", - idle: "L'état de la mise à jour n'a pas encore été vérifié.", + "Une mise à jour de l’application est prête à être installée. Dernière vérification à {{time}}.", + installed: "Mise à jour de l’application installée. Redémarrage requis.", + failed: "Échec de la mise à jour de l’application.", + idle: "Les mises à jour n’ont pas encore été vérifiées.", }, - installError: "Impossible d'installer la mise à jour de l'application de bureau.", + installError: "Impossible d’installer la mise à jour de l’application de bureau.", callout: { installingTitle: "Installation de la mise à jour", - failedTitle: "La mise à jour a échoué", + failedTitle: "Échec de la mise à jour", availableTitle: "Mise à jour disponible", - genericError: "Quelque chose s'est mal passé.", - whatsNew: "Quoi de neuf", - installingAction: "Installation...", + genericError: "Une erreur s’est produite.", + whatsNew: "Nouveautés", + installingAction: "Installation…", installAndRestart: "Installer et redémarrer", - installingDescription: "Installation et redémarrage...", - versionReady: "{{version}}est prêt à être installé.", + installingDescription: "Installation et redémarrage…", + versionReady: "La version {{version}} est prête à être installée.", newVersionReady: "Une nouvelle version est prête à être installée.", restartWarning: - "La mise à niveau de l'application arrêtera l'exécution des agents et fermera les sessions de terminal.", + "La mise à jour de l’application arrêtera les agents en cours et fermera les sessions de terminal.", }, }, settings: { - loadFailed: "Impossible de charger les paramètres du bureau.", - saveFailed: "Impossible d'enregistrer les paramètres du bureau.", + loadFailed: "Impossible de charger les paramètres de l’application de bureau.", + saveFailed: "Impossible d’enregistrer les paramètres de l’application de bureau.", }, rosetta: { - title: "Téléchargez la versionApple Silicon", - runningIntel: "Vous exécutez la versionInteldePaseosousRosettasurApple Silicon.", + title: "Téléchargez la version Apple Silicon", + runningIntel: "Vous utilisez la version Intel de Paseo via Rosetta sur Apple Silicon.", highCpu: - "Cela entraîne une utilisation élevée du processeur. Téléchargez la versionApple Siliconpour le réparer.", + "Cela entraîne une forte utilisation du processeur. Téléchargez la version Apple Silicon pour corriger le problème.", download: "Télécharger", }, permissions: { notifications: { - allowed: "Les notifications sont autorisées par le système d'exploitation.", + allowed: "Les notifications sont autorisées par le système.", denied: "Les notifications sont refusées dans les paramètres système.", - notGranted: "Les notifications n'ont pas encore été accordées.", + notGranted: "Les notifications n’ont pas encore été autorisées.", webOnly: - "L’état des notifications sur le bureau est uniquement disponible sur l’exécution Web.", + "L’état des notifications de bureau n’est disponible que dans l’environnement web.", supported: "Les notifications de bureau sont prises en charge.", unsupported: "Les notifications de bureau ne sont pas prises en charge sur cette plateforme.", - apiUnavailable: "L'API de notification Web n'est pas disponible dans cet environnement.", + apiUnavailable: "L’API Web Notification n’est pas disponible dans cet environnement.", requestsWebOnly: - "Les demandes de notification sur le bureau sont uniquement disponibles sur l'exécution Web.", - requestUnavailable: "L'API de notification Web requestPermission() n'est pas disponible.", - requestFailed: "Échec de la demande d'autorisation de notification:{{message}}", - unexpectedState: "État d'autorisation de notification inattendu:{{state}}", + "Les demandes de notification de bureau ne sont disponibles que dans l’environnement web.", + requestUnavailable: + "La méthode requestPermission() de l’API Web Notification n’est pas disponible.", + requestFailed: "Échec de la demande d’autorisation de notification : {{message}}", + unexpectedState: "État d’autorisation de notification inattendu : {{state}}", }, microphone: { - webOnly: - "L’état du microphone de bureau est uniquement disponible dans l’environnement d’exécution Web.", - navigatorUnavailable: "Le navigateur n'est pas disponible dans cet environnement.", - granted: "L’accès au microphone est accordé.", - denied: "L'accès au microphone est refusé dans les paramètres système.", - notGranted: "L'autorisation du microphone n'a pas encore été accordée.", - unexpectedState: "État d'autorisation inattendu du microphone:{{state}}", + webOnly: "L’état du microphone n’est disponible que dans l’environnement web.", + navigatorUnavailable: "L’objet navigator n’est pas disponible dans cet environnement.", + granted: "L’accès au microphone est autorisé.", + denied: "L’accès au microphone est refusé dans les paramètres système.", + notGranted: "L’accès au microphone n’a pas encore été autorisé.", + unexpectedState: "État d’autorisation du microphone inattendu : {{state}}", statusApiUnavailable: - "L’API d’état du microphone n’est pas disponible dans ce runtime. Utilisez Request pour vérifier l’accès.", - queryFailed: "Échec de l'interrogation de l'état du microphone:{{message}}", - captureUnavailable: "La capture du microphone n'est pas disponible dans cet environnement.", + "L’API d’état du microphone n’est pas disponible dans cet environnement. Utilisez « Demander » pour vérifier l’accès.", + queryFailed: "Impossible de consulter l’état du microphone : {{message}}", + captureUnavailable: "La capture du microphone n’est pas disponible dans cet environnement.", permissionApiUnavailable: - "L'API d'état des autorisations n'est pas disponible. Utilisez Request pour vérifier l’accès.", + "L’API d’état des autorisations n’est pas disponible. Utilisez « Demander » pour vérifier l’accès.", requestsWebOnly: - "Les demandes de microphone de bureau sont uniquement disponibles sur le runtime Web.", + "Les demandes d’accès au microphone ne sont disponibles que dans l’environnement web.", captureApiUnavailable: - "L'API de capture de microphone n'est pas disponible dans cet environnement.", - requestDenied: - "L'autorisation du microphone a été refusée par l'utilisateur ou le système.", - noDevice: "Aucun microphone n'a été trouvé.", - requestFailed: "Échec de la demande d'autorisation du microphone:{{message}}", + "L’API de capture du microphone n’est pas disponible dans cet environnement.", + requestDenied: "L’accès au microphone a été refusé par l’utilisateur ou le système.", + noDevice: "Aucun microphone trouvé.", + requestFailed: "Échec de la demande d’accès au microphone : {{message}}", }, empty: { - notifications: "L'état de la notification n'a pas encore été vérifié.", - microphone: "L'état du microphone n'a pas encore été vérifié.", + notifications: "L’état des notifications n’a pas encore été vérifié.", + microphone: "L’état du microphone n’a pas encore été vérifié.", }, testNotification: { - title: "Test de notificationPaseo", - body: "Si vous pouvez voir cela, les notifications sur le bureau fonctionnent.", + title: "Test de notification Paseo", + body: "Si vous voyez ceci, les notifications de bureau fonctionnent.", notDelivered: - "La notification n'a pas été délivrée. Vérifiez Paramètres système > Notifications.", - failed: "Échec de l'envoi de la notification.", + "La notification n’a pas été délivrée. Vérifiez Réglages système > Notifications.", + failed: "Échec de l’envoi de la notification.", }, }, integrations: { cli: { - statusFailed: "Impossible de vérifier l'état de l'installation deCLI.", - installFailed: "Impossible d'installer lePaseoCLI.", + statusFailed: "Impossible de vérifier l’installation de la CLI.", + installFailed: "Impossible d’installer la CLI Paseo.", }, }, }, rootError: { title: "Paseo a rencontré un problème.", - body: "Réessayez pour recharger l'application. Si cela continue, joignez les détails ci-dessous au signalement.", + body: "Réessayez pour recharger l’application. Si le problème persiste, joignez les détails ci-dessous à votre signalement.", details: "Détails", }, startup: { - errorTitle: "Quelque chose s'est mal passé", + errorTitle: "Une erreur s’est produite", errorDescription: - "Le serveur local n'a pas pu démarrer. Si cela continue, veuillez signaler le problème surGitHubet inclure les journaux ci-dessous.", + "Le serveur local n’a pas pu démarrer. Si le problème persiste, signalez-le sur GitHub en joignant les journaux ci-dessous.", logs: { - loading: "Chargement des journaux du démon...", - unavailable: "Aucun journal de démon disponible.", - loadFailed: "Impossible de charger les journaux du démon:{{message}}", + loading: "Chargement des journaux du daemon…", + unavailable: "Aucun journal du daemon disponible.", + loadFailed: "Impossible de charger les journaux du daemon : {{message}}", }, }, openProject: { @@ -1527,24 +1583,24 @@ export const fr: TranslationResources = { description: "Ouvrez un dossier sur votre machine", }, importSession: { - title: "Session d'importation", - description: "Apportez des sessionsCLIexternes récentes", + title: "Importer une session", + description: "Ouvrez une session Claude Code, Codex ou autre lancée dans un terminal", }, setupProviders: { - title: "Fournisseurs d'installation", - description: "ConfigurezClaude Code,Codexet plus", + title: "Configurer les fournisseurs", + description: "Configurez Claude Code, Codex et plus encore", }, pairDevice: { title: "Associer un appareil", - description: "Connectez votre téléphone à ce démon", + description: "Connectez votre téléphone à ce daemon", }, }, }, projectPicker: { - placeholder: "Tapez pour rechercher...", + placeholder: "Tapez pour rechercher…", browse: "Parcourir…", - opening: "Projet d'ouverture...", - searching: "Recherche en cours...", + opening: "Ouverture du projet…", + searching: "Recherche…", empty: "Commencez à taper un chemin", errors: { directory_not_found: "Répertoire introuvable.", @@ -1554,76 +1610,77 @@ export const fr: TranslationResources = { }, branchSwitcher: { triggerTooltip: "Changer la branche de l’espace de travail", - currentBranch: "Branche actuelle:{{branchName}}. Appuyez pour changer de branche.", - placeholder: "Changer de branche...", - searchPlaceholder: "Filtrer les branches...", + currentBranch: "Branche actuelle : {{branchName}}. Appuyez pour changer de branche.", + placeholder: "Changer de branche…", + searchPlaceholder: "Filtrer les branches…", empty: "Aucune branche trouvée.", title: "Changer de branche", - uncommittedTitle: "Modifications non validées", + uncommittedTitle: "Modifications non commitées", uncommittedMessage: - "Vous avez des modifications non validées. Les ranger avant de changer de branche?", - stashAndSwitch: "Cachette et changement", - failedToStash: "Échec de la sauvegarde des modifications", - failedToSwitch: "Échec du changement de branche", - restoreStashTitle: "Restaurer les modifications cachées?", + "Vous avez des modifications non commitées. Les remiser avant de changer de branche ?", + stashAndSwitch: "Remiser et changer", + failedToStash: "Impossible de remiser les modifications", + failedToSwitch: "Impossible de changer de branche", + restoreStashTitle: "Restaurer les modifications remisées ?", restoreStashMessage: - "Cette branche a caché les modifications d'une session précédente. Souhaitez-vous les restaurer?", + "Cette branche contient des modifications remisées lors d’une session précédente. Voulez-vous les restaurer ?", restore: "Restaurer", later: "Plus tard", - stashRestored: "Modifications cachées restaurées", + stashRestored: "Modifications remisées restaurées", }, agentAutocomplete: { - searchingWorkspace: "Recherche dans l'espace de travail...", - loadingCommands: "Chargement des commandes...", + searchingWorkspace: "Recherche dans l’espace de travail…", + loadingCommands: "Chargement des commandes…", noFiles: "Aucun fichier ou répertoire trouvé", noCommands: "Aucune commande trouvée", failedToLoad: "Échec du chargement", - chooseProjectForCommands: "Choisissez un projet pour voir les commandes", - chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes", + chooseProjectForCommands: "Choisissez un projet pour afficher les commandes", + chooseModelForCommands: "Sélectionnez un modèle pour afficher les commandes", }, loadOlderHistory: { - failed: "Impossible de charger l'ancien historique", + failed: "Impossible de charger l’historique plus ancien", }, imageAttachmentPicker: { permissionTitle: "Autorisation requise", - permissionMessage: "Veuillez autoriser l'accès à votre photothèque pour joindre des images.", + permissionMessage: "Autorisez l’accès à votre photothèque pour joindre des images.", errorTitle: "Erreur", - failedToSelect: "Échec de la sélection de l'image", + failedToSelect: "Impossible de sélectionner l’image", dialogTitle: "Joindre des images", dialogFilterName: "Images", }, workspaceSetup: { title: "Créer un espace de travail", errors: { - failedCreateWorktree: "Échec de la création de l'arbre de travail", - failedOpenProject: "Échec de l'ouverture du projet", + failedCreateWorktree: "Impossible de créer le worktree", + failedOpenProject: "Impossible d’ouvrir le projet", selectModel: "Sélectionnez un modèle", - hostDisconnected: "Hostn'est pas connecté", - pendingRequired: "Aucune configuration d'espace de travail n'est en attente", - composerStateRequired: "L'état du compositeur de configurationWorkspaceest requis", + hostDisconnected: "L’hôte n’est pas connecté", + pendingRequired: "Aucune configuration d’espace de travail en attente", + composerStateRequired: + "L’état de la zone de saisie de configuration de l’espace de travail est requis", }, }, onboarding: { - title: "Bienvenue surPaseo", + title: "Bienvenue dans Paseo", subtitle: "Connectez votre ordinateur pour commencer", actions: { settings: "Paramètres", }, }, modelSelector: { - title: "Sélectionnez le fournisseur", - selectModel: "Sélectionnez le modèle", - selectedModel: "Sélectionnez le modèle ({{model}})", - loading: "Chargement...", + title: "Sélectionner le fournisseur", + selectModel: "Sélectionner le modèle", + selectedModel: "Sélectionner le modèle ({{model}})", + loading: "Chargement…", loadingShort: "Chargement", - loadingSelector: "Chargement du sélecteur de modèle...", + loadingSelector: "Chargement du sélecteur de modèle…", error: "Erreur", - defaultModel: "Défaut", + defaultModel: "Par défaut", profiles: "Profils", providers: "Fournisseurs", model: "Modèle", editProfiles: "Modifier", - editProfilesLabel: "Modifier les profils d'agent", + editProfilesLabel: "Modifier les profils d’agent", createProfile: "Créer un profil", createProfileFromModel: "Créer un profil à partir de ce modèle", editProfileLabel: "Modifier {{name}}", @@ -1631,43 +1688,43 @@ export const fr: TranslationResources = { modelCount: "{{count}} modèle", modelCountPlural: "{{count}} modèles", retry: "Réessayer", - retrying: "Nouvelle tentative...", + retrying: "Nouvelle tentative…", noMatches: "Aucun modèle ne correspond à votre recherche", - noMatchesForQuery: "Aucun modèle ne correspond à « {{query}} »", - searchAllPlaceholder: "Rechercher dans tous les modèles...", - searchPlaceholder: "Rechercher des modèles...", - openProviderSettings: "Ouvrir les paramètres{{provider}}", + noMatchesForQuery: "Aucun modèle ne correspond à « {{query}} »", + searchAllPlaceholder: "Rechercher dans tous les modèles…", + searchPlaceholder: "Rechercher des modèles…", + openProviderSettings: "Ouvrir les paramètres de {{provider}}", }, providerCatalog: { title: "Ajouter un fournisseur", - search: "Fournisseurs de recherche", + search: "Rechercher des fournisseurs", noProviders: "Aucun fournisseur trouvé", actions: { add: "Ajouter", - adding: "Ajout", + adding: "Ajout…", installed: "Installé", cancel: "Annuler", - installInstructions: "Instructions d'installation", - installInstructionsFor: "Instructions d'installation{{provider}}", + installInstructions: "Instructions d’installation", + installInstructionsFor: "Instructions d’installation de {{provider}}", }, errors: { - unableToInstall: "Impossible d'installer le fournisseur", + unableToInstall: "Impossible d’installer le fournisseur", }, }, providerSelection: { - defaultModel: "Défaut", - selectModel: "Sélectionnez le modèle", - loading: "Chargement...", + defaultModel: "Par défaut", + selectModel: "Sélectionner le modèle", + loading: "Chargement…", error: "Erreur", unavailable: "Indisponible", unknownError: "Erreur inconnue", readiness: { - initialPromptRequired: "Une invite initiale est requise", - noProviders: "Aucun fournisseur disponible sur l'hébergeur sélectionné", - modelDefaultsLoading: "Les valeurs par défaut du modèle sont toujours en cours de chargement", - noModelAvailable: "Aucun modèle n'est disponible pour le fournisseur sélectionné", - workspaceDirectoryNotFound: "RépertoireWorkspaceintrouvable", - hostDisconnected: "Hostn'est pas connecté", + initialPromptRequired: "Un prompt initial est requis", + noProviders: "Aucun fournisseur disponible sur l’hôte sélectionné", + modelDefaultsLoading: "Les valeurs par défaut du modèle sont en cours de chargement", + noModelAvailable: "Aucun modèle disponible pour le fournisseur sélectionné", + workspaceDirectoryNotFound: "Répertoire de l’espace de travail introuvable", + hostDisconnected: "L’hôte n’est pas connecté", }, }, pairing: { @@ -1676,11 +1733,11 @@ export const fr: TranslationResources = { label: "Mot de passe de l’hôte", }, hostConfirmation: { - title: "Se connecter à cet hôte ?", + title: "Se connecter à cet hôte ?", description: - "Cet hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.", + "Cet hôte pourra exécuter du code dans cette app et accéder à vos autres hôtes connectés. Ne vous connectez que si vous le reconnaissez.", descriptionChanged: - "Ce lien modifie la façon dont vous vous connectez à cet hôte. L'hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.", + "Ce lien modifie la façon dont vous vous connectez à cet hôte. L’hôte pourra exécuter du code dans cette app et accéder à vos autres hôtes connectés. Ne vous connectez que si vous le reconnaissez.", hostLabel: "Hôte", fingerprintLabel: "Empreinte de la clé", relayLabel: "Relais", @@ -1694,22 +1751,22 @@ export const fr: TranslationResources = { }, remoteSsh: { title: "SSH distant", - description: "Connectez-vous avec le client SSH de bureau.", + description: "Connexion via le client SSH de bureau.", }, scanQr: { - title: "Scanner le codeQR", - description: "Connexion relais cryptée.", + title: "Scanner le code QR", + description: "Connexion chiffrée via le relais.", }, pasteLink: { - title: "Coller le lien d'association", - description: "Connexion relais cryptée.", + title: "Coller le lien d’association", + description: "Connexion chiffrée via le relais.", }, }, direct: { title: "Connexion directe", - helper: "Saisissez l'adresse d'un serveurPaseo.", + helper: "Saisissez l’adresse d’un serveur Paseo.", fields: { - host: "Host", + host: "Hôte", port: "Port", password: "Mot de passe", optional: "Facultatif", @@ -1717,9 +1774,9 @@ export const fr: TranslationResources = { connectionUri: "URI de connexion", }, advanced: { - label: "Avancé", - show: "Afficher avancé", - hide: "Masquer avancé", + label: "Options avancées", + show: "Afficher les options avancées", + hide: "Masquer les options avancées", }, passwordVisibility: { show: "Afficher le mot de passe", @@ -1727,101 +1784,107 @@ export const fr: TranslationResources = { }, actions: { cancel: "Annuler", - connect: "Connecter", - connecting: "De liaison...", + connect: "Se connecter", + connecting: "Connexion…", }, errors: { - hostRequired: "Hostest requis", + hostRequired: "L’hôte est requis", invalidPort: "Le port doit être compris entre 1 et 65535", invalidConnection: "Connexion invalide", - failedTitle: "La connexion a échoué", - failedToConnect: "Nous n'avons pas réussi à nous connecter à{{endpoint}}.", - noAdditionalDetails: "{{detail}}(aucun détail supplémentaire fourni)", - timedOut: "La connexion a expiré. Vérifiez l'hôte/portet votre réseau.", - refused: "Connexion rejetée. Le serveur fonctionne-t-il à cette adresse?", - hostNotFound: "Hostintrouvable. Vérifiez le nom d'hôte et réessayez.", - hostUnreachable: "Hostest inaccessible. Vérifiez votre réseau et votre pare-feu.", + failedTitle: "Échec de la connexion", + failedToConnect: "Impossible de se connecter à {{endpoint}}.", + noAdditionalDetails: "{{detail}} (aucun détail supplémentaire)", + timedOut: "Délai de connexion dépassé. Vérifiez l’hôte, le port et votre réseau.", + refused: "Connexion refusée. Le serveur est-il bien lancé à cette adresse ?", + hostNotFound: "Hôte introuvable. Vérifiez le nom d’hôte et réessayez.", + hostUnreachable: "Hôte inaccessible. Vérifiez votre réseau et votre pare-feu.", tlsError: - "Erreur TLS. Les connexions directes utilisent SSL uniquement lorsqu'un terminateur TLS se trouve devant le démon.", + "Erreur TLS. Les connexions directes n’utilisent SSL que si un terminateur TLS est placé devant le daemon.", unableToConnect: - "Impossible de se connecter. Vérifiez l'hôte/portet que le démon est accessible.", - details: "Détails:{{detail}}", + "Impossible de se connecter. Vérifiez l’hôte, le port et que le daemon est joignable.", + details: "Détails : {{detail}}", }, }, remoteSsh: { title: "SSH distant", - helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.", + helper: "Connectez-vous à un daemon Paseo qui tourne sur l’hôte distant.", fields: { target: "Hôte SSH", + password: "Mot de passe du daemon", + optional: "Facultatif", + }, + passwordVisibility: { + show: "Afficher le mot de passe", + hide: "Masquer le mot de passe", }, actions: { cancel: "Annuler", - connect: "Connecter", - connecting: "Connexion...", + connect: "Se connecter", + connecting: "Connexion…", }, errors: { targetRequired: "L’hôte SSH est requis", invalidTarget: "Saisissez un hôte ssh:// valide", - failedToConnect: "Connexion SSH impossible. {{detail}}", + failedToConnect: "Impossible de se connecter en SSH. {{detail}}", }, }, link: { - title: "Coller le lien d'association", - helper: "Collez le lien d'appairage depuis votre serveur.", - label: "Lien d'appariement", + title: "Coller le lien d’association", + helper: "Collez le lien d’association fourni par votre serveur.", + label: "Lien d’association", errors: { - required: "Collez un lien d'appairage (.../#offer=...)", - missingOffer: "Le lien doit inclure#offer=...", - emptyOffer: "La charge utile de l'offre est vide", - invalid: "Lien d'association invalide", - unableToPair: "Impossible de coupler l'hôte", + required: "Collez un lien d’association (.../#offer=...)", + missingOffer: "Le lien doit contenir #offer=...", + emptyOffer: "Le contenu de l’offre est vide", + invalid: "Lien d’association invalide", + unableToPair: "Impossible d’associer l’hôte", }, alert: { - failedTitle: "Échec du couplage", + failedTitle: "Échec de l’association", }, actions: { cancel: "Annuler", - pair: "Paire", - pairing: "L'appariement...", + pair: "Associer", + pairing: "Association…", }, }, scan: { - title: "ScannerQR", - webUnavailableTitle: "Non disponible sur le Web", + title: "Scanner le code QR", + webUnavailableTitle: "Indisponible sur le web", webUnavailableBody: - "L'analyseQRn'est pas prise en charge dans la version Web. Utilisez plutôt \"Coller le lien\".", + "Le scan de code QR n’est pas pris en charge dans la version web. Utilisez plutôt « Coller le lien ».", backToSettings: "Retour aux paramètres", cameraPermissionTitle: "Autorisation de la caméra", cameraPermissionBody: - "Autorisez l'accès à la caméra pour scanner le code d'appairageQRà partir de votre démon.", - grantPermission: "Accorder l'autorisation", - pairing: "L'appariement...", - unableToPair: "Impossible de coupler l'hôte", + "Autorisez l’accès à la caméra pour scanner le code QR d’association affiché par votre daemon.", + grantPermission: "Accorder l’autorisation", + pairing: "Association…", + unableToPair: "Impossible d’associer l’hôte", errorTitle: "Erreur", }, device: { - loadingOffer: "Chargement de l'offre d'association...", - failedToLoadOffer: "Échec du chargement de l'offre d'association.", - relayDisabled: "Le relais n'est pas activé. Activer le relais pour coupler un appareil.", - enableTitle: "Activer le relais ?", + loadingOffer: "Chargement de l’offre d’association…", + failedToLoadOffer: "Impossible de charger l’offre d’association.", + relayDisabled: "Le relais n’est pas activé. Activez-le pour associer un appareil.", + enableTitle: "Activer le relais ?", enableDescription: - "Le relais permet à cet appareil de se connecter depuis n’importe où. Le trafic de couplage est chiffré de bout en bout.", + "Le relais permet à cet appareil de se connecter depuis n’importe où. Le trafic d’association est chiffré de bout en bout.", relayDocs: "Fonctionnement du relais", - relayDocsAccessibility: "Lire comment fonctionne le relais Paseo", + relayDocsAccessibility: "Découvrir le fonctionnement du relais Paseo", enableRelay: "Activer le relais", - enablingRelay: "Activation...", + enablingRelay: "Activation…", notNow: "Pas maintenant", directConnectionHint: "Sans relais, connectez-vous directement via TCP, Tailscale ou un autre VPN. Aucun code QR n’est créé.", updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.", - unavailable: "Offre de jumelage indisponible.", - hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.", + unavailable: "Offre d’association indisponible.", + hint: "Scannez ce code QR avec Paseo sur votre téléphone, ou copiez le lien ci-dessous.", securityWarning: "Traitez ce lien d’association comme un mot de passe. Toute personne qui le possède peut accéder à ce daemon.", - qrUnavailable: "CodeQRindisponible.", - qrAccessibility: "Code QR de couplage", + qrUnavailable: "Code QR indisponible.", + qrAccessibility: "Code QR d’association", retry: "Réessayer", - copy: "Copie", + copy: "Copier", copied: "Copié", }, }, @@ -1829,41 +1892,41 @@ export const fr: TranslationResources = { actions: { mute: "Couper la voix en temps réel", unmute: "Réactiver la voix en temps réel", - stop: "Arrêtez la voix en temps réel et interrompez le tour", + stop: "Arrêter la voix en temps réel et interrompre le tour", }, }, rewind: { - tooltip: "Revenez à ce message", - warning: "Cette action ne peut pas être annulée", + tooltip: "Revenir à ce message", + warning: "Cette action est irréversible", actions: { - conversation: "Rembobiner la conversation", - files: "Rembobiner les fichiers", - both: "Rembobiner la conversation et les fichiers", + conversation: "Revenir en arrière sur la conversation", + files: "Revenir en arrière sur les fichiers", + both: "Revenir en arrière sur la conversation et les fichiers", }, errors: { - failed: "Échec du rembobinage de l'agent", + failed: "Impossible de faire revenir l’agent en arrière", }, }, diffViewer: { - empty: "Aucun changement à afficher", + empty: "Aucune modification à afficher", }, serviceUrl: { - title: "Service ouvertURL", - message: "Ouvrir{{url}}?", - inPaseo: "DansPaseo", + title: "Ouvrir l’URL du service", + message: "Ouvrir {{url}} ?", + inPaseo: "Dans Paseo", externalBrowser: "Navigateur externe", - dontAskAgain: "Ne demande plus", + dontAskAgain: "Ne plus demander", }, downloads: { - requestTokenFailed: "Échec de la demande du jeton de téléchargement.", - hostUnavailable: "L'hôte de téléchargement n'est pas disponible.", - cancelled: "Le téléchargement a été annulé.", - failed: "Échec du téléchargement du fichier.", - shareFile: "Partager un fichier", - shareFileNamed: "Partager{{fileName}}", + requestTokenFailed: "Impossible d’obtenir le token de téléchargement.", + hostUnavailable: "L’hôte de téléchargement est indisponible.", + cancelled: "Téléchargement annulé.", + failed: "Impossible de télécharger le fichier.", + shareFile: "Partager le fichier", + shareFileNamed: "Partager {{fileName}}", }, menu: { - backdrop: "Toile de fond du menu", + backdrop: "Arrière-plan du menu", }, subagents: { title: "Sous-agents", @@ -1874,32 +1937,32 @@ export const fr: TranslationResources = { pillLabelNeedsInputOne: "1 attend une réponse", pillLabelNeedsInputMany: "{{count}} attendent une réponse", pillLabelReadyToReview: "{{count}} à relire", - detachAction: "Detacher {{label}}", - detachTooltip: "Detacher le sous-agent", - archiveAction: "Archiver{{label}}", - archiveTooltip: "Sous-agent d'archivage", + detachAction: "Détacher {{label}}", + detachTooltip: "Détacher le sous-agent", + archiveAction: "Archiver {{label}}", + archiveTooltip: "Archiver le sous-agent", archiveFinishedAction: "Archiver les sous-agents terminés", archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})", }, panels: { draft: { newAgent: "Nouvel agent", - creatingAgent: "Agent créateur", + creatingAgent: "Création de l’agent", }, file: { - directoryMissing: "Répertoire Workspace introuvable.", - loading: "Chargement du fichier...", + directoryMissing: "Répertoire de l’espace de travail introuvable.", + loading: "Chargement du fichier…", noPreview: "Aucun aperçu disponible", - binaryPreviewUnavailable: "Aperçu binaire indisponible", + binaryPreviewUnavailable: "Aperçu indisponible pour un fichier binaire", tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché", - failedToLoad: "Échec du chargement du fichier", - failedToLoadPreview: "Échec du chargement de l'aperçu du fichier", + failedToLoad: "Impossible de charger le fichier", + failedToLoadPreview: "Impossible de charger l’aperçu du fichier", editor: { - fileSize: "Taille {{size}}", + fileSize: "Taille du fichier : {{size}}", lines: "{{count}} lignes", - editorStatus: "État de l’éditeur : {{status}}", + editorStatus: "État de l’éditeur : {{status}}", unsavedChanges: "Modifications non enregistrées", - saving: "Enregistrement...", + saving: "Enregistrement…", saveFailed: "Échec de l’enregistrement", changedOnDisk: "Modifié sur le disque", vimMode: "Mode Vim {{mode}}", @@ -1912,7 +1975,7 @@ export const fr: TranslationResources = { conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.", overwrite: "Écraser", reload: "Recharger", - reloadTitle: "Recharger depuis le disque ?", + reloadTitle: "Recharger depuis le disque ?", reloadMessage: "Vos modifications locales seront perdues.", }, }, @@ -1923,106 +1986,120 @@ export const fr: TranslationResources = { chooseFile: "Choisissez un fichier", }, pullRequest: { - label: "Demande de fusion", - subtitle: "Détails de la demande de fusion", - emptyTitle: "Aucune demande de fusion pour le moment", + label: "Pull request", + subtitle: "Détails de la pull request", + emptyTitle: "Aucune pull request pour l’instant", emptyDescription: - "Créez une demande de fusion pour cette copie de travail afin d’afficher ses détails ici.", + "Créez une pull request pour cette copie de travail afin d’en voir les détails ici.", }, diff: { changesLabel: "Modifications", diffLabel: "Diff", - changesSubtitle: "Différences de l'arbre de travail", - commitSubtitle: "Différences du commit", - uncommittedSubtitle: "Modifications non validées", + changesSubtitle: "Diff de l’arbre de travail", + commitSubtitle: "Diff du commit", + uncommittedSubtitle: "Modifications non commitées", baseSubtitle: "Comparé à {{baseRef}}", - directoryMissing: "Répertoire Workspace introuvable.", + directoryMissing: "Répertoire de l’espace de travail introuvable.", empty: "Aucune modification", - loadError: "Échec du chargement des différences", - capabilityMissing: "Mettez à jour l'hôte pour voir les différences des commits.", + loadError: "Impossible de charger le diff", + capabilityMissing: "Mettez à jour l’hôte pour afficher le diff des commits.", }, }, toolCallDetails: { error: "Erreur", empty: "Aucun détail supplémentaire disponible", subAgentActivity: "Activité du sous-agent", - input: "Saisir", - output: "Sortir", + input: "Entrée", + output: "Sortie", }, toolCallGroup: { editedFiles: { - one: "a modifié {{count}} fichier", - other: "a modifié {{count}} fichiers", + one: "{{count}} fichier modifié", + other: "{{count}} fichiers modifiés", }, commands: { - one: "a exécuté {{count}} commande", - other: "a exécuté {{count}} commandes", + one: "{{count}} commande exécutée", + other: "{{count}} commandes exécutées", }, readFiles: { - one: "a lu {{count}} fichier", - other: "a lu {{count}} fichiers", + one: "{{count}} fichier lu", + other: "{{count}} fichiers lus", }, searches: { - one: "a effectué {{count}} recherche", - other: "a effectué {{count}} recherches", + one: "{{count}} recherche effectuée", + other: "{{count}} recherches effectuées", }, otherTools: { - one: "a utilisé {{count}} autre outil", - other: "a utilisé {{count}} autres outils", + one: "{{count}} autre outil utilisé", + other: "{{count}} autres outils utilisés", }, paseoCalls: { - one: "a appelé Paseo {{count}} fois", - other: "a appelé Paseo {{count}} fois", + one: "{{count}} appel à Paseo", + other: "{{count}} appels à Paseo", }, and: "et", }, renameModal: { - rename: "Rebaptiser", - saving: "Sauvegarde...", + rename: "Renommer", + saving: "Enregistrement…", }, sidebarCallout: { - dismiss: "Rejeter", + dismiss: "Ignorer", }, contextWindow: { - title: "Fenêtre contextuelle", - used: "{{percentage}}% utilisé", - tokens: "Jetons{{used}}/{{max}}", - sessionCost: "Coût de la séance{{cost}}", - accessibility: "Fenêtre contextuelle{{percentage}}% utilisé", + noData: "Aucune donnée de contexte", + accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte", + title: "Fenêtre de contexte", + used: "{{percentage}} % utilisés", + tokens: "{{used}} / {{max}} tokens", + sessionCost: "Coût de la session : {{cost}}", + accessibility: "Fenêtre de contexte : {{percentage}} % utilisés", }, review: { + feedback: { + send: "Envoyer les commentaires ({{count}})", + sending: "Envoi des commentaires ({{count}})", + chooseAgent: "Choisir un agent", + sent: "Commentaires envoyés à {{recipient}}", + "no-agents": + "Ouvrez un onglet d’agent dans cet espace de travail pour envoyer les commentaires.", + disconnected: "Connectez-vous à l’hôte pour envoyer les commentaires.", + "no-context": "Les commentaires enregistrés ne correspondent plus à ce diff.", + failed: "Échec de l’envoi des commentaires. Réessayez.", + prompt: "Veuillez traiter cette revue de code.", + }, comment: { - add: "Ajouter un commentaire", - edit: "Modifier le commentaire de l'avis", - delete: "Supprimer le commentaire de l'avis", - label: "Revoir le commentaire", + add: "Ajouter un commentaire de revue", + edit: "Modifier le commentaire de revue", + delete: "Supprimer le commentaire de revue", + label: "Commentaire de revue", placeholder: "Laisser un commentaire", cancel: "Annuler", - cancelAccessibility: "Annuler le commentaire de révision", - save: "Commentaire", - saveAccessibility: "Enregistrer le commentaire de l'avis", + cancelAccessibility: "Annuler le commentaire de revue", + save: "Commenter", + saveAccessibility: "Enregistrer le commentaire de revue", }, }, settings: { title: "Paramètres", - loading: "Chargement des paramètres...", + loading: "Chargement des paramètres…", groups: { app: "Application", - host: "Host", + host: "Hôte", }, hostPicker: { - switchHost: "Changer d'hôte", - local: "Locale", + switchHost: "Changer d’hôte", + local: "Local", }, - backToWorkspace: "Dos", + backToWorkspace: "Retour", addHost: "Ajouter un hôte", - enableBuiltInDaemon: "Activer le démon intégré", + enableBuiltInDaemon: "Activer le daemon intégré", projects: "Projets", projectList: { - hostLoadFailed: "Impossible de charger les projets depuis l'hôte{{hostName}}:{{message}}", - editProject: "Modifier{{projectName}}", + hostLoadFailed: "Impossible de charger les projets depuis l’hôte {{hostName}} : {{message}}", + editProject: "Modifier {{projectName}}", }, - groupInfo: "À propos de{{title}}", + groupInfo: "À propos de {{title}}", sections: { general: "Général", chat: "Discussion", @@ -2042,32 +2119,32 @@ export const fr: TranslationResources = { editor: { title: "Éditeur", vimKeybindings: "Raccourcis Vim", - vimHint: "S’applique aux fichiers source sur le web et le bureau.", + vimHint: "S’applique aux fichiers source dans l’app web et l’app de bureau.", }, notifications: { title: "Notifications", permission: "Autorisation des notifications", refreshAccessibility: "Actualiser l’autorisation des notifications", playSound: "Émettre un son", - playSoundHint: "Émet un son lorsqu’une notification de bureau arrive", + playSoundHint: "Émet un son à l’arrivée d’une notification de bureau", test: "Tester la notification", testHint: "Envoie une notification avec ces réglages", - permissionRequired: "Autorisez l’accès aux notifications avant le test", + permissionRequired: "Autorisez les notifications avant de tester", send: "Envoyer", - sending: "Envoi...", + sending: "Envoi…", sentTitle: "Notification de test envoyée", sentDescription: "Paseo a transmis la notification au système d’exploitation.", sendFailedTitle: "Impossible d’envoyer la notification de test", }, hostSections: { projects: "Projets", - connections: "Relations", + connections: "Connexions", agents: "Agents", metadata: "Métadonnées", - workspaces: "Workspaces", + workspaces: "Espaces de travail", providers: "Fournisseurs", usage: "Utilisation", - terminals: "Terminals", + terminals: "Terminaux", plugins: "Plugins", host: "Aperçu", }, @@ -2095,43 +2172,43 @@ export const fr: TranslationResources = { description: "Les onglets du navigateur partagent les connexions et les données des sites dans Paseo.", clear: "Effacer les données du navigateur", - clearing: "Effacement...", - confirmTitle: "Effacer les données du navigateur ?", + clearing: "Effacement…", + confirmTitle: "Effacer les données du navigateur ?", confirmMessage: "Vous serez déconnecté des sites et les onglets ouverts seront rechargés.", success: "Données du navigateur effacées.", - error: "Impossible d'effacer les données du navigateur.", + error: "Impossible d’effacer les données du navigateur.", }, defaultSend: { label: "Envoi par défaut", descriptions: { interrupt: - "Lorsque l'agent est en cours d'exécution, Entrée interrompt. Commande/Ctrl+Entrée met en file d'attente.", + "Quand l’agent est en cours d’exécution, Entrée l’interrompt. Cmd/Ctrl+Entrée met en file d’attente.", steer: - "Lorsque l’agent est en cours d’exécution, Entrée oriente le tour actif. Commande/Ctrl+Entrée met en file d’attente.", + "Quand l’agent est en cours d’exécution, Entrée réoriente le tour en cours. Cmd/Ctrl+Entrée met en file d’attente.", queue: - "Lorsque l'agent est en cours d'exécution, Entrée met en file d'attente. Commande/Ctrl+Entrée envoie.", + "Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.", }, options: { interrupt: "Interrompre", - steer: "Orienter", - queue: "File d'attente", + steer: "Réorienter", + queue: "File d’attente", }, }, serviceUrls: { options: { ask: "Demander", - inApp: "DansPaseo", + inApp: "Dans Paseo", external: "Navigateur externe", }, }, terminalScrollback: { - label: "DéfilementTerminal", + label: "Historique du terminal", description: "Lignes conservées dans le tampon du terminal intégré", - accessibilityLabel: "Lignes de défilementTerminal", + accessibilityLabel: "Lignes d’historique du terminal", }, autoExpandReasoning: { label: "Toujours afficher le raisonnement", - description: "Afficher le raisonnement de l'agent entièrement développé par défaut", + description: "Afficher par défaut le raisonnement de l’agent entièrement déplié", }, toolCallDetail: { label: "Affichage des appels d’outils", @@ -2143,7 +2220,7 @@ export const fr: TranslationResources = { }, language: { label: "Langue", - description: "Langue de l'application", + description: "Langue de l’application", options: { system: "Système", ar: "العربية", @@ -2166,67 +2243,68 @@ export const fr: TranslationResources = { accessibilityLabel: "Utiliser l’ancien rendu du terminal", }, testAudio: "Tester le son", - playTest: "Jouer à l'essai", - playing: "Jouant...", - playbackFailed: "Échec de la lecture:{{message}}", + playTest: "Lancer le test", + playing: "Lecture…", + playbackFailed: "Échec de la lecture : {{message}}", app: { - title: "App diagnostic", - rowTitle: "App diagnostic", - rowHint: "Collect connection, daemon, provider, desktop, and log details", - run: "Run", - running: "Running diagnostic...", + title: "Diagnostic de l’app", + rowTitle: "Diagnostic de l’app", + rowHint: + "Collecter les informations sur la connexion, le daemon, les fournisseurs, l’app de bureau et les journaux", + run: "Lancer", + running: "Diagnostic en cours…", copyLabel: "diagnostic", - copyAccessibility: "Copy diagnostic", - copyFailed: "Failed to copy diagnostic", - refreshAccessibility: "Refresh diagnostic", - refreshingAccessibility: "Refreshing diagnostic", + copyAccessibility: "Copier le diagnostic", + copyFailed: "Impossible de copier le diagnostic", + refreshAccessibility: "Actualiser le diagnostic", + refreshingAccessibility: "Actualisation du diagnostic", progress: { client: "Client", - desktop: "Desktop", + desktop: "App de bureau", }, }, }, about: { title: "À propos", - appVersion: "Version de l'application", - whatsNewHint: "Notes de version pour chaque release", + appVersion: "Version de l’application", + whatsNewHint: "Les nouveautés de chaque version", thisDevice: "Cet appareil", connectedHosts: "Hôtes connectés", offline: "Hors ligne", - versionDiffers: "La version diffère de cet appareil", + versionDiffers: "Version différente de celle de cet appareil", releaseChannel: { - label: "Canal de sortie", + label: "Canal de publication", description: - "Passez àBetapour obtenir des mises à jour plus tôt et contribuer à les façonner", + "Passez à la Bêta pour recevoir les mises à jour plus tôt et contribuer à les façonner", stable: "Stable", - beta: "Beta", + beta: "Bêta", }, updates: { - label: "Mises à jour de l'application", - readyToInstall: "Prêt à installer:{{version}}", - installTitle: "Installer la mise à jour du bureau", - installMessage: "Cela met à jourPaseosur cet ordinateur", + label: "Mises à jour de l’application", + readyToInstall: "Prête à installer : {{version}}", + installTitle: "Installer la mise à jour de l’app de bureau", + installMessage: "Paseo sera mis à jour sur cet ordinateur", installConfirm: "Installer la mise à jour", - update: "Mise à jour", - updateTo: "Mise à jour vers{{version}}", - installing: "Installation...", + update: "Mettre à jour", + updateTo: "Mettre à jour vers {{version}}", + installing: "Installation…", check: "Vérifier", - checking: "Vérification...", + checking: "Vérification…", alertTitle: "Erreur", - alertMessage: "Impossible d'ouvrir la boîte de dialogue de confirmation de mise à jour.", + alertMessage: "Impossible d’ouvrir la boîte de dialogue de confirmation de mise à jour.", }, }, appearance: { theme: { title: "Thème", - accessibilityLabel: "Thème:{{value}}", + accessibilityLabel: "Thème : {{value}}", options: { - light: "Lumière", + light: "Clair", dark: "Sombre", zinc: "Zinc", midnight: "Minuit", claude: "Claude", - ghostty: "Fantôme", + ghostty: "Ghostty", pureBlack: "Noir pur", auto: "Système", }, @@ -2236,7 +2314,7 @@ export const fr: TranslationResources = { }, chatOutline: { title: "Plan de la discussion", - description: "Afficher un plan pour passer d’une requête à l’autre", + description: "Afficher un plan pour naviguer entre les prompts", }, sidebar: { header: { @@ -2245,64 +2323,65 @@ export const fr: TranslationResources = { "Choisissez les éléments affichés en haut de la barre latérale et leur ordre", }, footer: { - title: "Pied", + title: "Pied de page", description: - "Choisissez les lignes affichées en bas de la barre latérale et leur ordre. Ajouter un projet et la rangée d’icônes restent toujours visibles", + "Choisissez les lignes affichées en bas de la barre latérale et leur ordre. « Ajouter un projet » et la rangée d’icônes restent toujours visibles", }, - moveUp: "Déplacer vers le haut", - moveDown: "Déplacer vers le bas", + moveUp: "Monter", + moveDown: "Descendre", }, fonts: { title: "Polices", - systemDefault: "Valeur par défaut du système", - interfaceFont: "Police d'interface", + systemDefault: "Police par défaut du système", + interfaceFont: "Police de l’interface", interfaceFontHint: - "Utilisé dans toute l'application. Laisser vide pour la valeur par défaut du système", - interfaceFontAccessibility: "Famille de polices d'interface", - interfaceSize: "Taille de l'interface", + "Utilisée dans toute l’application. Laissez vide pour utiliser la police par défaut du système", + interfaceFontAccessibility: "Famille de police de l’interface", + interfaceSize: "Taille de l’interface", interfaceSizeHint: "Utilisée pour la navigation, les contrôles et les libellés", - interfaceSizeAccessibility: "Taille de police de l'interface", + interfaceSizeAccessibility: "Taille de police de l’interface", contentSize: "Taille du contenu", - contentSizeHint: "Utilisée pour le texte du chat et le Markdown rendu", + contentSizeHint: "Utilisée pour le texte de la discussion et le Markdown rendu", contentSizeAccessibility: "Taille de police du contenu", - codeFont: "Police de code", + codeFont: "Police du code", codeFontHint: - "Utilisé dans le code, les différences et la sortie du terminal. Laisser vide pour la valeur par défaut du système", - codeFontAccessibility: "Famille de polices de code", + "Utilisée pour le code, les diffs et la sortie du terminal. Laissez vide pour utiliser la police par défaut du système", + codeFontAccessibility: "Famille de police du code", codeSize: "Taille du code", codeSizeHint: "Utilisée pour le code, les diffs et la sortie du terminal", - codeSizeAccessibility: "Taille de la police du code", + codeSizeAccessibility: "Taille de police du code", }, layout: { title: "Mise en page", contentWidth: "Largeur du contenu", - contentWidthHint: "Largeur maximale du chat et des fichiers Markdown sur les grands écrans", + contentWidthHint: + "Largeur maximale de la discussion et des fichiers Markdown sur les grands écrans", contentWidthAccessibility: "Largeur du contenu en pixels", reset: "Réinitialiser", resetAccessibility: "Réinitialiser la largeur du contenu", }, syntax: { title: "Syntaxe", - highlightTheme: "Thème de surbrillance", - highlightThemeHint: "Couleurs du code, indépendamment du thème de l'application", - highlightThemeAccessibility: "Thème phare:{{value}}", + highlightTheme: "Thème de coloration", + highlightThemeHint: "Couleurs du code, indépendantes du thème de l’application", + highlightThemeAccessibility: "Thème de coloration : {{value}}", previewAccessibility: - "Aperçu en direct de la typographie du contenu, du thème de syntaxe et de la police de code", + "Aperçu en direct de la typographie du contenu, du thème de syntaxe et de la police du code", previewContent: "Aperçu du contenu et du code", }, }, shortcuts: { dialogTitle: "Raccourcis", searchPlaceholder: "Rechercher des raccourcis", - unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur le bureau", - capturePrompt: "Appuyez sur le raccourci...", - unassigned: "Non défini", + unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur l’app de bureau", + capturePrompt: "Appuyez sur le raccourci…", + unassigned: "Non attribué", actions: { menu: "Actions pour {{name}}", - done: "Fait", + done: "Terminé", cancel: "Annuler", bind: "Attribuer", - rebind: "Relier", + rebind: "Réattribuer", clear: "Effacer", reset: "Rétablir la valeur par défaut", resetAll: "Tout réinitialiser", @@ -2310,85 +2389,85 @@ export const fr: TranslationResources = { sections: { general: "Général", workspaces: "Projets et espaces de travail", - tabsPanes: "Onglets et volets", + tabsPanes: "Onglets et panneaux", layout: "Disposition", - agentInput: "EntréeAgent", + agentInput: "Zone de saisie", }, help: { - openProject: "Projet ouvert", + openProject: "Ouvrir un projet", newWorkspace: "Nouvel espace de travail", - newWorktree: "Nouvel arbre de travail", + newWorktree: "Nouveau worktree", archiveWorkspace: "Archiver l’espace de travail", newTab: "Nouvel onglet", - closeCurrentTab: "Fermer l'onglet actuel", - jumpToWorkspace: "Accéder à l'espace de travail", - jumpToTab: "Aller à l'onglet", + closeCurrentTab: "Fermer l’onglet actuel", + jumpToWorkspace: "Aller à l’espace de travail", + jumpToTab: "Aller à l’onglet", previousWorkspace: "Espace de travail précédent", nextWorkspace: "Espace de travail suivant", previousTab: "Onglet précédent", nextTab: "Onglet suivant", - splitPaneRight: "Volet divisé à droite", - splitPaneDown: "Diviser le volet vers le bas", - focusPaneLeft: "Volet de mise au point à gauche", - focusPaneRight: "Volet de mise au point à droite", - focusPaneUp: "Volet de mise au point vers le haut", - focusPaneDown: "Volet de mise au point vers le bas", - moveTabLeft: "Déplacer l'onglet vers la gauche", - moveTabRight: "Déplacer l'onglet vers la droite", - moveTabUp: "Déplacer l'onglet vers le haut", - moveTabDown: "Déplacer l'onglet vers le bas", - closePane: "Fermer le volet", - newTerminal: "Nouvelle borne", + splitPaneRight: "Diviser le panneau vers la droite", + splitPaneDown: "Diviser le panneau vers le bas", + focusPaneLeft: "Activer le panneau de gauche", + focusPaneRight: "Activer le panneau de droite", + focusPaneUp: "Activer le panneau du haut", + focusPaneDown: "Activer le panneau du bas", + moveTabLeft: "Déplacer l’onglet vers la gauche", + moveTabRight: "Déplacer l’onglet vers la droite", + moveTabUp: "Déplacer l’onglet vers le haut", + moveTabDown: "Déplacer l’onglet vers le bas", + closePane: "Fermer le panneau", + newTerminal: "Nouveau terminal", searchFiles: "Rechercher des fichiers", - toggleCommandCenter: "Basculer le centre de commande", + toggleCommandCenter: "Afficher/masquer la palette de commandes", showKeyboardShortcuts: "Afficher les raccourcis clavier", - toggleLeftSidebar: "Basculer la barre latérale gauche", - toggleRightSidebar: "Basculer le panneau latéral", - toggleBothSidebars: "Basculer les deux barres latérales", - toggleSettings: "Basculer les paramètres", - toggleFocusMode: "Basculer le mode de mise au point", - cycleTheme: "Thème du cycle", - focusMessageInput: "Saisie du message de focus", - cycleAgentMode: "Parcourir les modes de l'agent", - toggleVoiceMode: "Changer le mode vocal", - startStopDictation: "Démarrer la dictée/stop", - interruptAgent: "Agent d'interruption", - sendMessage: "Envoyer un message", - queueMessage: "Message de file d'attente", - muteUnmuteVoiceMode: "Mode vocal/unmutemuet", + toggleLeftSidebar: "Afficher/masquer la barre latérale gauche", + toggleRightSidebar: "Afficher/masquer la barre latérale de l’explorateur", + toggleBothSidebars: "Afficher/masquer les deux barres latérales", + toggleSettings: "Afficher/masquer les paramètres", + toggleFocusMode: "Activer/désactiver le mode focus", + cycleTheme: "Parcourir les thèmes", + focusMessageInput: "Aller à la zone de saisie", + cycleAgentMode: "Parcourir les modes de l’agent", + toggleVoiceMode: "Activer/désactiver le mode vocal", + startStopDictation: "Démarrer/arrêter la dictée", + interruptAgent: "Interrompre l’agent", + sendMessage: "Envoyer le message", + queueMessage: "Mettre le message en file d’attente", + muteUnmuteVoiceMode: "Couper/réactiver le micro en mode vocal", switchProject: "Changer de projet", }, helpNotes: { showKeyboardShortcuts: - "Disponible lorsque le focus n’est pas dans un champ de texte ou un terminal.", + "Disponible quand le focus n’est ni dans un champ de texte ni dans un terminal.", }, }, integrations: { title: "Intégrations", docs: { - cli: "DocumentsCLI", - openCli: "Ouvrir la documentationCLI", + cli: "Documentation CLI", + openCli: "Ouvrir la documentation CLI", }, commandLine: { title: "Ligne de commande", - description: "Agents de contrôle et de script depuis votre terminal", + description: "Pilotez et scriptez vos agents depuis votre terminal", }, actions: { install: "Installer", - installing: "Installation...", + installing: "Installation…", installed: "Installé", }, }, permissions: { title: "Autorisations", microphone: "Microphone", - refresh: "Rafraîchir", - refreshing: "Rafraîchissant...", + refresh: "Actualiser", + refreshing: "Actualisation…", refreshAccessibility: "Actualiser les autorisations du bureau", actions: { - granted: "Accordé", - request: "Demande", - requesting: "Demander...", + granted: "Accordée", + request: "Demander", + requesting: "Demande…", }, }, host: { @@ -2407,12 +2486,12 @@ export const fr: TranslationResources = { options: { none: "Par défaut", violet: "Violet", - sky: "Ciel", + sky: "Bleu ciel", emerald: "Émeraude", orange: "Orange", pink: "Rose", indigo: "Indigo", - teal: "Sarcelle", + teal: "Bleu canard", red: "Rouge", amber: "Ambre", blue: "Bleu", @@ -2431,250 +2510,252 @@ export const fr: TranslationResources = { workspaceName: "my-workspace", }, }, - notFound: "Hostintrouvable", + notFound: "Hôte introuvable", badges: { relay: "Relais", - local: "Locale", + local: "Local", remoteSsh: "SSH distant", }, connections: { - title: "Relations", + title: "Connexions", removeTitle: "Supprimer la connexion", - removeMessage: "Supprimer{{name}}? Cela ne peut pas être annulé.", - removeAction: "Retirer", + removeMessage: "Supprimer {{name}} ? Cette action est irréversible.", + removeAction: "Supprimer", removeErrorTitle: "Erreur", removeErrorMessage: "Impossible de supprimer la connexion", - timeout: "Temps mort", + timeout: "Délai dépassé", }, pairDevices: { title: "Associer des appareils", rowTitle: "Associer un appareil", - rowHint: "Scannez un codeQRou copiez un lien pour connecter votre téléphone à cet hôte", + rowHint: "Scannez un QR code ou copiez un lien pour connecter votre téléphone à cet hôte", }, skills: { ...en.settings.host.skills, - sectionTitle: "Compétences en orchestration", - title: "Compétences en orchestration", - description: "Apprenez à vos agents à orchestrer via leCLI", + sectionTitle: "Skills d’orchestration", + title: "Skills d’orchestration", + description: "Apprenez à vos agents à orchestrer via la CLI", updateAvailable: "Mise à jour disponible", - updateTitle: "Mettre à jour les compétencesPaseo?", - updateFallback: "Synchronisez les compétences regroupées sur votre machine.", - uninstallTitle: "Désinstaller les compétencesPaseo?", + updateTitle: "Mettre à jour les skills Paseo ?", + updateFallback: "Synchronisez les skills fournis sur cet hôte.", + uninstallTitle: "Désinstaller les skills Paseo ?", uninstallMessage: - "Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.", - choose: "Choisir les compétences", - chooseAll: "Toutes les compétences", + "Supprime tous les skills d’orchestration Paseo de ~/.agents, ~/.claude et ~/.codex sur cet hôte.", + choose: "Choisir les skills", + chooseAll: "Tous les skills", chooseAllHint: - "Gardez installées toutes les compétences fournies, y compris celles ajoutées plus tard.", - chooseList: "Compétences fournies", - chooseEmpty: "Cette version ne fournit aucune compétence.", - removeTitle: "Supprimer les compétences décochées ?", + "Garder installés tous les skills fournis, y compris ceux ajoutés plus tard.", + chooseList: "Skills fournis", + chooseEmpty: "Cet hôte ne fournit aucun skill.", + removeTitle: "Supprimer les skills décochés ?", removeMessage: - "{{skills}} seront supprimées de ~/.agents, ~/.claude et ~/.codex. Tout ce que vous avez ajouté dans ces dossiers de compétences est également supprimé.", - saveFailed: "Impossible d'enregistrer votre sélection de compétences.", - statusFailed: "Impossible de vérifier l'état des compétences d'orchestration.", - updateFailed: "Impossible de mettre à jour les compétences d'orchestration.", - uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.", - saveSelectionFailed: - "Impossible d'enregistrer la sélection des compétences d'orchestration.", + "{{skills}} seront supprimés de ~/.agents, ~/.claude et ~/.codex sur cet hôte. Tout ce que vous avez ajouté dans ces dossiers de skills sera également supprimé.", + saveFailed: "Impossible d’enregistrer votre sélection de skills.", + statusFailed: "Impossible de vérifier l’état des skills d’orchestration.", + updateFailed: "Impossible de mettre à jour les skills d’orchestration.", + uninstallFailed: "Impossible de désinstaller les skills d’orchestration.", + saveSelectionFailed: "Impossible d’enregistrer la sélection des skills d’orchestration.", actions: { install: "Installer", - installing: "Installation...", + installing: "Installation…", installed: "Installé", - update: "Mise à jour", - working: "Fonctionnement...", + update: "Mettre à jour", + working: "En cours…", remove: "Supprimer", uninstall: "Désinstaller", save: "Enregistrer", - saving: "Enregistrement...", + saving: "Enregistrement…", cancel: "Annuler", }, operations: { - add: "Ajouter une compétence", - update: "Mettre à jour la compétence", - delete: "Supprimer la compétence", + add: "Ajouter un skill", + update: "Mettre à jour le skill", + delete: "Supprimer le skill", }, }, orchestration: { title: "Orchestration", - unavailable: "Connectez-vous à cet hôte pour gérer l'orchestration", + unavailable: "Connectez-vous à cet hôte pour gérer l’orchestration", enableTools: { - title: "Activer les outilsPaseo", - hint: "Les agents pourront gérer les arbres de travail, les agents et les horaires", - accessibilityLabel: "Injecter les outilsPaseo", + title: "Activer les outils Paseo", + hint: "Les agents pourront gérer les worktrees, les agents et les planifications", + accessibilityLabel: "Injecter les outils Paseo", }, systemPrompt: { - title: "Invite système", - hint: "Ajoute une invite système à tous les agents", - sheetTitle: "Ajouter une invite système", - accessibilityLabel: "Ajouter une invite système", - placeholder: "Gardez toujours des réponses concises.", + title: "Prompt système", + hint: "Ajoute un prompt système à tous les agents", + sheetTitle: "Ajouter un prompt système", + accessibilityLabel: "Ajouter un prompt système", + placeholder: "Toujours répondre de façon concise.", edit: "Modifier", reset: "Réinitialiser", - save: "Sauvegarder", - saving: "Sauvegarde...", + save: "Enregistrer", + saving: "Enregistrement…", }, }, agents: { - unavailable: "Connect to this host to manage agents", + unavailable: "Connectez-vous à cet hôte pour gérer les agents", }, workspaces: { - unavailable: "Connect to this host to manage workspaces", + unavailable: "Connectez-vous à cet hôte pour gérer les espaces de travail", }, terminalProfiles: { - unavailable: "Connect to this host to manage terminal profiles", - sectionTitle: "Terminal profiles", - editProfile: "Edit profile", - addProfileTitle: "Add terminal profile", - editProfileTitle: "Edit terminal profile", + unavailable: "Connectez-vous à cet hôte pour gérer les profils de terminal", + sectionTitle: "Profils de terminal", + editProfile: "Modifier le profil", + addProfileTitle: "Ajouter un profil de terminal", + editProfileTitle: "Modifier le profil de terminal", namePlaceholder: "Claude Code", commandPlaceholder: "claude", argsPlaceholder: "--dangerously-skip-permissions", - nameLabel: "Name", - commandLabel: "Command", + nameLabel: "Nom", + commandLabel: "Commande", argsLabel: "Arguments", - nameRequired: "Name is required", - commandRequired: "Command is required", - argsHint: "Space-separated arguments passed to the command", - saving: "Saving...", - remove: "Remove", - removeConfirmTitle: "Remove profile?", - removeConfirmMessage: 'Remove "{{name}}"?', - moveUp: "Move up", - moveDown: "Move down", - save: "Save", - emptyState: "No profiles yet. Add one to launch terminals with a specific command.", + nameRequired: "Le nom est obligatoire", + commandRequired: "La commande est obligatoire", + argsHint: "Arguments séparés par des espaces, transmis à la commande", + saving: "Enregistrement…", + remove: "Supprimer", + removeConfirmTitle: "Supprimer le profil ?", + removeConfirmMessage: "Supprimer « {{name}} » ?", + moveUp: "Monter", + moveDown: "Descendre", + save: "Enregistrer", + emptyState: + "Aucun profil pour le moment. Ajoutez-en un pour lancer des terminaux avec une commande précise.", }, agentProfiles: { - sectionTitle: "Profils d'agent", - unavailable: "Connectez-vous à cet hôte pour gérer les profils d'agent", - unsupported: - "Cet hôte exécute un daemon qui ne prend pas encore en charge les profils d'agent", + sectionTitle: "Profils d’agent", + unavailable: "Connectez-vous à cet hôte pour gérer les profils d’agent", + unsupported: "Le daemon de cet hôte ne prend pas encore en charge les profils d’agent", emptyState: - "Aucun profil pour le moment. Ajoutez-en un pour démarrer des agents à partir d'un fournisseur et d'un modèle enregistrés.", - addProfileTitle: "Ajouter un profil d'agent", + "Aucun profil pour le moment. Ajoutez-en un pour démarrer des agents avec un fournisseur et un modèle enregistrés.", + addProfileTitle: "Ajouter un profil d’agent", newProfile: "Nouveau profil", editProfile: "Modifier le profil", - editProfileTitle: "Modifier le profil d'agent", + editProfileTitle: "Modifier le profil d’agent", nameLabel: "Nom", - namePlaceholder: "Travail sur l'UI", + namePlaceholder: "Travail sur l’UI", iconLabel: "Icône", noIcon: "Aucune", providerLabel: "Fournisseur", providerPlaceholder: "Sélectionner un fournisseur", - noProviders: "Aucun fournisseur n'est disponible sur cet hôte", + noProviders: "Aucun fournisseur disponible sur cet hôte", modelLabel: "Modèle", - noModels: "Ce fournisseur n'a aucun modèle à choisir", + noModels: "Ce fournisseur ne propose aucun modèle", modeLabel: "Mode", - noModes: "Ce fournisseur n'a aucun mode à choisir", - thinkingLabel: "Pensée", - noThinkingOptions: "Ce modèle n'a aucun niveau de pensée", - featuresLabel: "Caractéristiques", - featureCount: "{{count}} caractéristiques", - featureCountOne: "{{count}} caractéristique", + noModes: "Ce fournisseur ne propose aucun mode", + thinkingLabel: "Réflexion", + noThinkingOptions: "Ce modèle n’a aucun niveau de réflexion", + featuresLabel: "Fonctionnalités", + featureCount: "{{count}} fonctionnalités", + featureCountOne: "{{count}} fonctionnalité", notesLabel: "Quand l’utiliser", notesPlaceholder: - "À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.", + "À utiliser pour le travail sur l’UI : composants, mise en page et design tokens.", notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.", save: "Enregistrer", - saving: "Enregistrement...", + saving: "Enregistrement…", remove: "Supprimer", - removeConfirmTitle: "Supprimer le profil ?", - removeConfirmMessage: "Supprimer « {{name}} » ?", - moveUp: "Déplacer vers le haut", - moveDown: "Déplacer vers le bas", + removeConfirmTitle: "Supprimer le profil ?", + removeConfirmMessage: "Supprimer « {{name}} » ?", + moveUp: "Monter", + moveDown: "Descendre", }, daemon: { rename: { - editLabel: "Modifier l'étiquette", - title: "Renommer l'hôte", - placeholder: "MonHost", + editLabel: "Modifier le libellé", + title: "Renommer l’hôte", + placeholder: "Mon hôte", submit: "Renommer", }, restart: { - title: "Redémarrer le démon", - hint: "Redémarre le processus démon. L'application se reconnectera automatiquement", + title: "Redémarrer le daemon", + hint: "Redémarre le processus du daemon. L’application se reconnectera automatiquement", confirm: "Redémarrer", - confirmTitle: "Redémarrer{{name}}", + confirmTitle: "Redémarrer {{name}}", confirmMessage: - "Cela redémarrera le démon. Les agents qui s'y exécutent continueront à fonctionner; l'application se reconnectera automatiquement.", - restarting: "Redémarrage...", + "Le daemon va redémarrer. Les agents qui y tournent continueront de s’exécuter et l’application se reconnectera automatiquement.", + restarting: "Redémarrage…", unableToReconnectTitle: "Impossible de se reconnecter", unableToReconnectMessage: - "{{name}}n'est pas revenu en ligne. Veuillez vérifier qu'il a redémarré.", - unavailableTitle: "Hostindisponible", + "{{name}} n’est pas revenu en ligne. Vérifiez qu’il a bien redémarré.", + unavailableTitle: "Hôte indisponible", unavailableMessage: - "Cet hôte n'est pas connecté. Attendez qu'il soit en ligne avant de redémarrer.", - offlineTitle: "Hosthors ligne", + "Cet hôte n’est pas connecté. Attendez qu’il soit en ligne avant de redémarrer.", + offlineTitle: "Hôte hors ligne", offlineMessage: - "Cet hôte est hors ligne.Paseose reconnecte automatiquement: attendez qu'il soit de nouveau en ligne avant de redémarrer.", + "Cet hôte est hors ligne. Paseo se reconnecte automatiquement : attendez qu’il soit de nouveau en ligne avant de redémarrer.", requestFailedTitle: "Erreur", requestFailedMessage: - "Échec de l'envoi de la demande de redémarrage.Paseose reconnecte automatiquement - réessayez une fois que l'hôte apparaît comme en ligne.", + "Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.", dialogFailedMessage: - "Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.", + "Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.", }, update: { desktopManagedHint: "Ce daemon est géré par Paseo Desktop. Mettez à jour Paseo Desktop sur l’hôte.", - title: "Update daemon", - hint: "Update the daemon to the latest version and restart it", - confirm: "Update", - confirmTitle: "Update {{name}}", + title: "Mettre à jour le daemon", + hint: "Met à jour le daemon vers la dernière version puis le redémarre", + confirm: "Mettre à jour", + confirmTitle: "Mettre à jour {{name}}", confirmMessage: - "This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.", - updating: "Updating...", - phaseStarting: "Preparing update...", - phaseDownloading: "Downloading packages...", - phaseInstalling: "Installing...", - phaseComplete: "Update complete, restarting...", - unableToReconnectTitle: "Unable to reconnect", + "Le daemon va être mis à jour vers la dernière version puis redémarré. Les agents en cours seront brièvement interrompus.", + updating: "Mise à jour…", + phaseStarting: "Préparation de la mise à jour…", + phaseDownloading: "Téléchargement des paquets…", + phaseInstalling: "Installation…", + phaseComplete: "Mise à jour terminée, redémarrage…", + unableToReconnectTitle: "Impossible de se reconnecter", unableToReconnectMessage: - "{{name}} did not come back online after updating. Please verify the daemon restarted.", - unavailableTitle: "Host unavailable", + "{{name}} n’est pas revenu en ligne après la mise à jour. Vérifiez que le daemon a bien redémarré.", + unavailableTitle: "Hôte indisponible", unavailableMessage: - "This host is not connected. Wait for it to come online before updating.", - offlineTitle: "Host offline", - offlineMessage: "This host is offline. Wait until it is back online before updating.", - requestFailedTitle: "Update failed", - requestFailedMessage: "Failed to update the daemon: {{error}}", - dialogFailedMessage: "Unable to open the update confirmation dialog.", + "Cet hôte n’est pas connecté. Attendez qu’il soit en ligne avant de lancer la mise à jour.", + offlineTitle: "Hôte hors ligne", + offlineMessage: + "Cet hôte est hors ligne. Attendez qu’il soit de nouveau en ligne avant de lancer la mise à jour.", + requestFailedTitle: "Échec de la mise à jour", + requestFailedMessage: "Impossible de mettre à jour le daemon : {{error}}", + dialogFailedMessage: + "Impossible d’ouvrir la boîte de dialogue de confirmation de la mise à jour.", }, dangerZone: "Zone dangereuse", remove: { - title: "Supprimer l'hôte", - localTitle: "Remove localhost connection", + title: "Supprimer l’hôte", + localTitle: "Supprimer la connexion localhost", hint: "Supprime cet hôte et ses connexions enregistrées de cet appareil", - localHint: "Removes localhost from this device and stops the built-in daemon", - localConfirmTitle: "Remove localhost connection and stop daemon?", - confirmMessage: "Supprimer{{name}}? Cela supprimera ses connexions enregistrées.", + localHint: "Supprime localhost de cet appareil et arrête le daemon intégré", + localConfirmTitle: "Supprimer la connexion localhost et arrêter le daemon ?", + confirmMessage: "Supprimer {{name}} ? Ses connexions enregistrées seront supprimées.", localConfirmMessage: - "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected.", + "La connexion localhost sera supprimée, la gestion du daemon intégré désactivée et le daemon géré arrêté. Les hôtes distants restent connectés.", errorTitle: "Erreur", - errorMessage: "Impossible de supprimer l'hôte", - localErrorMessage: "Unable to remove localhost connection", + errorMessage: "Impossible de supprimer l’hôte", + localErrorMessage: "Impossible de supprimer la connexion localhost", }, }, }, providers: { title: "Fournisseurs", addProvider: "Ajouter un fournisseur", - providerDetails: "Détails du fournisseur{{name}}", - enableProvider: "Activer{{name}}", + providerDetails: "Détails du fournisseur {{name}}", + enableProvider: "Activer {{name}}", unavailable: "Connectez-vous à cet hôte pour voir les fournisseurs", - loading: "Chargement...", - addErrorTitle: "Unable to add provider", + loading: "Chargement…", + addErrorTitle: "Impossible d’ajouter le fournisseur", updateErrorTitle: "Impossible de mettre à jour le fournisseur", actions: { - menu: "{{name}} actions", - remove: "Remove provider", - removing: "Removing...", + menu: "Actions de {{name}}", + remove: "Supprimer le fournisseur", + removing: "Suppression…", }, remove: { - confirmTitle: "Remove {{name}}?", - confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", - confirm: "Remove", - errorTitle: "Unable to remove provider", + confirmTitle: "Supprimer {{name}} ?", + confirmMessage: + "L’entrée du fournisseur sera supprimée de config.json. Cette action est irréversible.", + confirm: "Supprimer", + errorTitle: "Impossible de supprimer le fournisseur", }, statuses: { disabled: "Désactivé", @@ -2688,40 +2769,40 @@ export const fr: TranslationResources = { many: "{{count}} modèles", addModel: "Ajouter un modèle", addCustomTitle: "Ajouter un modèle personnalisé", - modelId: "ModèleID", - modelIdPlaceholder: "par ex. ouvert/gpt-5", + modelId: "ID du modèle", + modelIdPlaceholder: "ex. openai/gpt-5", add: "Ajouter", - adding: "Ajout...", - failedToSave: "Échec de l'enregistrement du modèle", - removeModel: "Supprimer{{id}}", + adding: "Ajout…", + failedToSave: "Impossible d’enregistrer le modèle", + removeModel: "Supprimer {{id}}", searchPlaceholder: "Rechercher des modèles", - loading: "Chargement des modèles...", + loading: "Chargement des modèles…", retry: "Réessayer", - retrying: "Nouvelle tentative...", + retrying: "Nouvelle tentative…", noSearchMatches: "Aucun modèle ne correspond à votre recherche", noneDetected: "Aucun modèle détecté", - discovered: "Découvert", + discovered: "Détectés", custom: "Modèles personnalisés", - updated: "{{time}}mis à jour", + updated: "Mis à jour {{time}}", }, diagnostic: { - title: "Diagnostique", - button: "Diagnostique", - refresh: "Rafraîchir", - refreshing: "Rafraîchissant...", + title: "Diagnostic", + button: "Diagnostic", + refresh: "Actualiser", + refreshing: "Actualisation…", copyLabel: "diagnostic", copyAccessibility: "Copier le diagnostic", - copyFailed: "Échec de la copie du diagnostic", + copyFailed: "Impossible de copier le diagnostic", refreshAccessibility: "Actualiser le diagnostic", - refreshingAccessibility: "Diagnostic rafraîchissant", - running: "Exécution du diagnostic...", + refreshingAccessibility: "Actualisation du diagnostic", + running: "Exécution du diagnostic…", none: "Aucun diagnostic disponible", - failedToFetch: "Échec de la récupération du diagnostic", + failedToFetch: "Impossible de récupérer le diagnostic", unknownError: "Erreur inconnue", }, }, project: { - noEditableTarget: "Ce projet n'est pas modifiable sur cet hôte.", + noEditableTarget: "Ce projet n’est pas modifiable sur cet hôte.", backToProjects: "Retour aux projets", edit: { title: "Modifier le projet", @@ -2729,85 +2810,86 @@ export const fr: TranslationResources = { nameLabel: "Nom du projet", icon: "Icône", chooseImage: "Choisir une image", - useAutomatic: "Utiliser l'automatique", - imageUrl: "URL d'image ou de site", - save: "Enregistrer", + useAutomatic: "Icône automatique", + imageUrl: "URL d’une image ou d’un site", + save: "Enregistrer les modifications", savedToast: "Projet mis à jour", }, readFailures: { - invalidTitle: "paseo.json n'a pas pu être analysé", + invalidTitle: "Impossible d’analyser paseo.json", invalidDescription: "Corrigez le fichier sur le disque, puis rechargez.", - missingTitle: "Cet hôte n'a pas ce projet", - missingSingleHost: "L'hôte sélectionné n'a aucune trace de ce projet.", + missingTitle: "Ce projet n’existe pas sur cet hôte", + missingSingleHost: "L’hôte sélectionné n’a aucune trace de ce projet.", transportTitle: "Impossible de charger paseo.json", - transportFallback: "L'hôte n'a pas répondu.", + transportFallback: "L’hôte n’a pas répondu.", failedTitle: "Impossible de charger paseo.json", failedDescription: "Rechargez pour réessayer.", }, worktree: { - title: "Crochets de cycle de vie Worktree", - info: "Commandes exécutées lorsqu'un arbre de travail est créé ou supprimé pour ce projet", - docs: "Documents", + title: "Hooks de cycle de vie des worktrees", + info: "Commandes exécutées à la création ou à la suppression d’un worktree de ce projet", + docs: "Documentation", docsTooltip: - "Voir la documentation pour plus de détails et les variables d'environnement disponibles pour ces commandes", - setup: "Installation", - setupAccessibility: "Commandes de configuration de Worktree", - uncommittedTitle: "Validez les modifications de paseo.json", + "Consultez la documentation pour plus de détails et la liste des variables d’environnement disponibles pour ces commandes", + setup: "Configuration", + setupAccessibility: "Commandes de configuration du worktree", + uncommittedTitle: "Commitez les modifications de paseo.json", uncommittedDescription: "Les nouveaux worktrees utilisent le script de configuration de la branche de base sélectionnée.", - teardown: "Démolir", - teardownAccessibility: "Commandes de démontage de Worktree", + teardown: "Démontage", + teardownAccessibility: "Commandes de démontage du worktree", }, scripts: { title: "Scripts", - info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer à partir de n'importe quel agent de ce projet", - empty: "Pas encore de scripts.", + info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer depuis n’importe quel agent de ce projet", + empty: "Aucun script pour le moment.", untitled: "Script sans titre", - port: "port{{port}}", - menuAccessibility: "Ouvrir le menu des scripts", - removeTitle: "Supprimer le script?", - removeMessage: "Supprimer{{name}}?", - removeFallbackName: "ce scénario", + port: "port {{port}}", + menuAccessibility: "Ouvrir le menu du script", + removeTitle: "Supprimer le script ?", + removeMessage: "Supprimer {{name}} ?", + removeFallbackName: "ce script", name: "Nom", command: "Commande", nameAccessibility: "Nom du script", - commandAccessibility: "Commande de script", - nameRequired: "Le nom est requis", - commandRequired: "La commande est requise", - newScript: "Nouveau scénario", - editScript: "Modifier{{name}}", - runAsService: "Exécuter en tant que service", - serviceHint: "Paseosupervise le processus et attribue un port via $PASEO_PORT", + commandAccessibility: "Commande du script", + nameRequired: "Le nom est obligatoire", + commandRequired: "La commande est obligatoire", + newScript: "Nouveau script", + editScript: "Modifier {{name}}", + runAsService: "Exécuter comme service", + serviceHint: "Paseo supervise le processus et lui attribue un port via $PASEO_PORT", actions: { add: "Ajouter un script", edit: "Modifier", - remove: "Retirer", + remove: "Supprimer", }, }, metadata: { - title: "Génération de métadonnées", - info: "Instructions spécifiques au projet injectées dans les invites de l'IA quePaseoutilise pour générer des métadonnées: utilisez-les pour appliquer les conventions de votre équipe telles que la dénomination des branches, le style de validation ou le formatPR.", - branchName: "Noms des succursales", + title: "Génération des métadonnées", + info: "Instructions propres au projet, injectées dans les prompts IA que Paseo utilise pour générer les métadonnées : servez-vous-en pour imposer les conventions de votre équipe (nommage des branches, style de commit, format des PR).", + branchName: "Noms de branches", branchNamePlaceholder: - "Préfixez les branches avec feat/ ou fix/, mb/ pour les branches personnelles", - commitMessage: "Valider les messages", - commitMessagePlaceholder: "Utiliser des commits conventionnels avec une portée", - pullRequest: "Demandes de tirage", + "Préfixez les branches par feat/ ou fix/, et par mb/ pour les branches personnelles", + commitMessage: "Messages de commit", + commitMessagePlaceholder: "Utilisez les Conventional Commits avec un scope", + pullRequest: "Pull requests", pullRequestPlaceholder: - "Commencez avec un résumé d'un paragraphe, incluez une section sur le plan de test", + "Commencez par un résumé d’un paragraphe et incluez une section « Test plan »", }, writeFailures: { staleTitle: "Configuration modifiée sur le disque", - staleDescription: "Rechargez pour récupérer le dernier paseo.json avant de sauvegarder.", - failedTitle: "Impossible d'enregistrer paseo.json", - failedDescription: "Réessayez ou rechargez la dernière version à partir du disque.", + staleDescription: + "Rechargez pour récupérer la dernière version de paseo.json avant d’enregistrer.", + failedTitle: "Impossible d’enregistrer paseo.json", + failedDescription: "Réessayez ou rechargez la dernière version depuis le disque.", }, actions: { reload: "Recharger", - tryAgain: "Essayer à nouveau", - save: "Sauvegarder", + tryAgain: "Réessayer", + save: "Enregistrer", saved: "Projet enregistré", - saving: "Sauvegarde...", + saving: "Enregistrement…", cancel: "Annuler", }, }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 3491b507a2..9cbc630da2 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -31,6 +31,7 @@ export const ja: TranslationResources = { back: "戻る", loading: "読み込み中...", actions: { + save: "保存", back: "戻る", cancel: "キャンセル", close: "閉じる", @@ -124,6 +125,8 @@ export const ja: TranslationResources = { input: { accessibilityLabel: "エージェントにメッセージ...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全画面表示", + exitFullscreen: "全画面表示を終了", focusHint: "{{shortcut}}でフォーカス", addAttachment: "添付ファイルを追加", interruptAgent: "エージェントを中断", @@ -387,6 +390,9 @@ export const ja: TranslationResources = { completed: "完了", }, }, + turnFooter: { + workedFor: "作業時間 {{duration}}", + }, compaction: { loading: "コンテキストを圧縮中...", auto: "コンテキストが自動的に圧縮されました", @@ -613,6 +619,9 @@ export const ja: TranslationResources = { updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", }, + sheet: { + openAsTab: "タブで開く", + }, tabs: { loading: "読み込み中...", modified: "未保存の変更", @@ -983,6 +992,47 @@ export const ja: TranslationResources = { actions: { viewPullRequest: "表示", openOn: "{{brand}}で開く", + addToChat: "チャットに追加", + addAllToChat: "すべてチャットに追加", + addingToChat: "追加中...", + }, + checksOverview: { + headline: { + actionRequired: "対応が必要なチェックがあります", + failure: "失敗したチェックがあります", + pending: "完了していないチェックがあります", + success: "すべてのチェックに合格しました", + none: "チェックなし", + }, + count: { + actionRequired: "{{count}} 件要対応", + warning: "{{count}} 件警告", + failure: "{{count}} 件失敗", + pending: "{{count}} 件実行中", + manual: "{{count}} 件手動", + success: "{{count}} 件成功", + ignored: "{{count}} 件スキップ", + }, + detailOne: "チェック: {{parts}}", + detailMany: "チェック: {{parts}}", + groupOne: { + actionRequired: "要対応のチェック {{count}} 件", + warning: "警告のチェック {{count}} 件", + failure: "失敗したチェック {{count}} 件", + pending: "実行中のチェック {{count}} 件", + manual: "手動のチェック {{count}} 件", + success: "成功したチェック {{count}} 件", + ignored: "スキップされたチェック {{count}} 件", + }, + groupMany: { + actionRequired: "要対応のチェック {{count}} 件", + warning: "警告のチェック {{count}} 件", + failure: "失敗したチェック {{count}} 件", + pending: "実行中のチェック {{count}} 件", + manual: "手動のチェック {{count}} 件", + success: "成功したチェック {{count}} 件", + ignored: "スキップされたチェック {{count}} 件", + }, }, checksSummary: { passedLabel: "成功", @@ -996,17 +1046,21 @@ export const ja: TranslationResources = { checks: "チェック", pipeline: "パイプライン", reviews: "レビュー", + activity: "アクティビティ", }, empty: { noJobs: "ジョブなし", loadingPipeline: "パイプラインを読み込み中...", pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした", allowedToFail: "失敗を許可", + noActivity: "まだアクティビティはありません", }, approvals: "{{given}} / {{required}} 承認", accessibility: { pullRequest: "プルリクエスト#{{number}}", pullRequest_mr: "マージリクエスト !{{number}}", + commentActions: "コメントの操作", + threadActions: "スレッドの操作", checkStatus: { passed: "成功", failed: "失敗", @@ -1035,6 +1089,8 @@ export const ja: TranslationResources = { }, thread: { discussion: "ディスカッションスレッド", + resolved: "解決済み", + outdated: "古い", }, errors: { statusLoadFailed: "プルリクエストのステータスを読み込めません", @@ -1101,7 +1157,16 @@ export const ja: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} グループ", + statusBucket: { + needsInput: "入力待ち", + failed: "失敗", + readyToReview: "レビュー待ち", + working: "実行中", + done: "完了", + }, display: { + showBackground: "バックグラウンドを表示", trigger: "表示設定", heading: "表示", grouping: { @@ -1719,6 +1784,12 @@ export const ja: TranslationResources = { helper: "リモートホストで動作する Paseo デーモンに接続します。", fields: { target: "SSH ホスト", + password: "デーモンのパスワード", + optional: "任意", + }, + passwordVisibility: { + show: "パスワードを表示", + hide: "パスワードを非表示", }, actions: { cancel: "キャンセル", @@ -1951,6 +2022,8 @@ export const ja: TranslationResources = { dismiss: "閉じる", }, contextWindow: { + noData: "コンテキストデータがありません", + accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません", title: "コンテキストウィンドウ", used: "{{percentage}}%使用", tokens: "{{used}} / {{max}}トークン", @@ -1958,6 +2031,18 @@ export const ja: TranslationResources = { accessibility: "コンテキストウィンドウ{{percentage}}%使用", }, review: { + feedback: { + send: "フィードバックを送信 ({{count}})", + sending: "フィードバックを送信中 ({{count}})", + chooseAgent: "エージェントを選択", + sent: "{{recipient}} にフィードバックを送信しました", + "no-agents": + "フィードバックを送信するには、このワークスペースでエージェントタブを開いてください。", + disconnected: "フィードバックを送信するにはホストに接続してください。", + "no-context": "保存したコメントはこの差分と一致しなくなりました。", + failed: "フィードバックを送信できませんでした。もう一度お試しください。", + prompt: "このコードレビューに対応してください。", + }, comment: { add: "レビューコメントを追加", edit: "レビューコメントを編集", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 7afe54b158..98598d39dd 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -30,6 +30,7 @@ export const ko: TranslationResources = { back: "뒤로", loading: "불러오는 중...", actions: { + save: "저장", back: "뒤로", cancel: "취소", close: "닫기", @@ -123,6 +124,8 @@ export const ko: TranslationResources = { input: { accessibilityLabel: "에이전트에게 메시지...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "전체 화면", + exitFullscreen: "전체 화면 종료", focusHint: "{{shortcut}}로 포커스", addAttachment: "첨부 추가", interruptAgent: "에이전트 중단", @@ -383,6 +386,9 @@ export const ko: TranslationResources = { completed: "완료됨", }, }, + turnFooter: { + workedFor: "작업 시간 {{duration}}", + }, compaction: { loading: "압축하는 중...", auto: "컨텍스트가 자동으로 압축되었습니다", @@ -608,6 +614,9 @@ export const ko: TranslationResources = { updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", unableToSubscribe: "터미널을 구독할 수 없습니다", }, + sheet: { + openAsTab: "탭으로 열기", + }, tabs: { loading: "불러오는 중...", modified: "저장되지 않은 변경사항", @@ -977,6 +986,47 @@ export const ko: TranslationResources = { actions: { viewPullRequest: "보기", openOn: "{{brand}}에서 열기", + addToChat: "채팅에 추가", + addAllToChat: "모두 채팅에 추가", + addingToChat: "추가 중...", + }, + checksOverview: { + headline: { + actionRequired: "확인이 필요한 검사가 있습니다", + failure: "실패한 검사가 있습니다", + pending: "아직 완료되지 않은 검사가 있습니다", + success: "모든 검사를 통과했습니다", + none: "검사 없음", + }, + count: { + actionRequired: "{{count}}개 조치 필요", + warning: "{{count}}개 경고", + failure: "{{count}}개 실패", + pending: "{{count}}개 진행 중", + manual: "{{count}}개 수동", + success: "{{count}}개 성공", + ignored: "{{count}}개 건너뜀", + }, + detailOne: "검사: {{parts}}", + detailMany: "검사: {{parts}}", + groupOne: { + actionRequired: "조치 필요 검사 {{count}}개", + warning: "경고 검사 {{count}}개", + failure: "실패한 검사 {{count}}개", + pending: "진행 중인 검사 {{count}}개", + manual: "수동 검사 {{count}}개", + success: "성공한 검사 {{count}}개", + ignored: "건너뛴 검사 {{count}}개", + }, + groupMany: { + actionRequired: "조치 필요 검사 {{count}}개", + warning: "경고 검사 {{count}}개", + failure: "실패한 검사 {{count}}개", + pending: "진행 중인 검사 {{count}}개", + manual: "수동 검사 {{count}}개", + success: "성공한 검사 {{count}}개", + ignored: "건너뛴 검사 {{count}}개", + }, }, checksSummary: { passedLabel: "통과", @@ -990,17 +1040,21 @@ export const ko: TranslationResources = { checks: "검사", pipeline: "파이프라인", reviews: "리뷰", + activity: "활동", }, empty: { noJobs: "작업 없음", loadingPipeline: "파이프라인 로드 중…", pipelineJobsLoadFailed: "파이프라인 작업을 로드할 수 없습니다.", allowedToFail: "실패가 허용됨", + noActivity: "아직 활동이 없습니다", }, approvals: "{{required}} 중 {{given}} 승인", accessibility: { pullRequest: "풀 리퀘스트 #{{number}}", pullRequest_mr: "병합 요청 !{{number}}", + commentActions: "댓글 작업", + threadActions: "스레드 작업", checkStatus: { passed: "통과", failed: "실패", @@ -1029,6 +1083,8 @@ export const ko: TranslationResources = { }, thread: { discussion: "토론 스레드", + resolved: "해결됨", + outdated: "오래됨", }, errors: { statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다", @@ -1095,7 +1151,16 @@ export const ko: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} 그룹", + statusBucket: { + needsInput: "입력 필요", + failed: "실패", + readyToReview: "검토 대기", + working: "실행 중", + done: "완료", + }, display: { + showBackground: "백그라운드 표시", trigger: "표시 설정", heading: "표시", grouping: { @@ -1711,6 +1776,12 @@ export const ko: TranslationResources = { helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.", fields: { target: "SSH 호스트", + password: "데몬 비밀번호", + optional: "선택 사항", + }, + passwordVisibility: { + show: "비밀번호 표시", + hide: "비밀번호 숨기기", }, actions: { cancel: "취소", @@ -1941,6 +2012,8 @@ export const ko: TranslationResources = { dismiss: "닫기", }, contextWindow: { + noData: "컨텍스트 데이터 없음", + accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음", title: "컨텍스트 윈도우", used: "{{percentage}}% 사용됨", tokens: "{{used}} / {{max}} 토큰", @@ -1948,6 +2021,17 @@ export const ko: TranslationResources = { accessibility: "컨텍스트 윈도우 {{percentage}}% 사용됨", }, review: { + feedback: { + send: "피드백 보내기 ({{count}})", + sending: "피드백 전송 중 ({{count}})", + chooseAgent: "에이전트 선택", + sent: "{{recipient}}에게 피드백을 보냈습니다", + "no-agents": "피드백을 보내려면 이 작업 공간에서 에이전트 탭을 여세요.", + disconnected: "피드백을 보내려면 호스트에 연결하세요.", + "no-context": "저장된 댓글이 더 이상 이 변경 사항과 일치하지 않습니다.", + failed: "피드백 전송에 실패했습니다. 다시 시도하세요.", + prompt: "이 코드 리뷰를 반영해 주세요.", + }, comment: { add: "리뷰 댓글 추가", edit: "리뷰 댓글 편집", diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index 104bf6b1d0..8146ad9747 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -27,7 +27,7 @@ export const pluginSettings = { globalTitle: "تفعيل الإضافات", globalHint: "مفتاح عام لكل الإضافات المضبوطة", sourceLabel: "مصدر الإضافة", - sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm", + sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm", docs: "الوثائق", install: "تثبيت الإضافة", installing: "جارٍ التثبيت…", @@ -82,7 +82,7 @@ export const pluginSettings = { globalTitle: "Activar plugins", globalHint: "Interruptor global para todos los plugins configurados", sourceLabel: "Fuente del plugin", - sourcePlaceholder: "Directorio, URL de Git o paquete npm", + sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm", docs: "Documentación", install: "Instalar plugin", installing: "Instalando…", @@ -137,7 +137,7 @@ export const pluginSettings = { globalTitle: "Activer les plugins", globalHint: "Interrupteur global pour tous les plugins configurés", sourceLabel: "Source du plugin", - sourcePlaceholder: "Dossier, URL Git ou paquet npm", + sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm", docs: "Documentation", install: "Installer le plugin", installing: "Installation…", @@ -163,7 +163,7 @@ export const pluginSettings = { globalEnabled: "Plugins activés", globalDisabled: "Plugins désactivés", }, - removeConfirmTitle: "Supprimer {{id}} ?", + removeConfirmTitle: "Supprimer {{id}} ?", removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.", states: { offlineTitle: "L’hôte des plugins est hors ligne", @@ -192,7 +192,7 @@ export const pluginSettings = { globalTitle: "プラグインを有効化", globalHint: "設定済みプラグインすべての共通スイッチ", sourceLabel: "プラグインソース", - sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ", + sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ", docs: "ドキュメント", install: "プラグインをインストール", installing: "インストール中…", @@ -246,7 +246,7 @@ export const pluginSettings = { globalTitle: "플러그인 활성화", globalHint: "설정된 모든 플러그인의 전역 스위치", sourceLabel: "플러그인 소스", - sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지", + sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지", docs: "문서", install: "플러그인 설치", installing: "설치 중…", @@ -301,7 +301,7 @@ export const pluginSettings = { globalTitle: "Ativar plugins", globalHint: "Controle global para todos os plugins configurados", sourceLabel: "Fonte do plugin", - sourcePlaceholder: "Diretório, URL Git ou pacote npm", + sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm", docs: "Documentação", install: "Instalar plugin", installing: "Instalando…", @@ -356,7 +356,7 @@ export const pluginSettings = { globalTitle: "Включить плагины", globalHint: "Общий переключатель для всех настроенных плагинов", sourceLabel: "Источник плагина", - sourcePlaceholder: "Каталог, URL Git или пакет npm", + sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm", docs: "Документация", install: "Установить плагин", installing: "Установка…", @@ -410,7 +410,7 @@ export const pluginSettings = { globalTitle: "启用插件", globalHint: "所有已配置插件的全局开关", sourceLabel: "插件来源", - sourcePlaceholder: "目录、Git URL 或 npm 包", + sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包", docs: "文档", install: "安装插件", installing: "正在安装…", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index a08088b2e4..c3ca4994d3 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -31,6 +31,7 @@ export const ptBR: TranslationResources = { back: "Voltar", loading: "Carregando...", actions: { + save: "Salvar", back: "Voltar", cancel: "Cancelar", close: "Fechar", @@ -124,6 +125,8 @@ export const ptBR: TranslationResources = { input: { accessibilityLabel: "Enviar mensagem ao agente...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Tela cheia", + exitFullscreen: "Sair da tela cheia", focusHint: "{{shortcut}} para focar", addAttachment: "Adicionar anexo", interruptAgent: "Interromper agente", @@ -386,6 +389,9 @@ export const ptBR: TranslationResources = { completed: "Concluída", }, }, + turnFooter: { + workedFor: "Trabalhou por {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automaticamente", @@ -612,6 +618,9 @@ export const ptBR: TranslationResources = { updateHost: "Atualize o host para usar o renderizador de terminal nativo.", unableToSubscribe: "Não foi possível assinar o terminal", }, + sheet: { + openAsTab: "Abrir como aba", + }, tabs: { loading: "Carregando...", modified: "Alterações não salvas", @@ -993,6 +1002,47 @@ export const ptBR: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir no {{brand}}", + addToChat: "Adicionar ao chat", + addAllToChat: "Adicionar tudo ao chat", + addingToChat: "Adicionando...", + }, + checksOverview: { + headline: { + actionRequired: "Algumas verificações precisam da sua atenção", + failure: "Algumas verificações não foram bem-sucedidas", + pending: "Algumas verificações ainda não terminaram", + success: "Todas as verificações foram aprovadas", + none: "Nenhuma verificação", + }, + count: { + actionRequired: "{{count}} com ação pendente", + warning: "{{count}} com aviso", + failure: "{{count}} com falha", + pending: "{{count}} em andamento", + manual: "{{count}} manual(is)", + success: "{{count}} aprovada(s)", + ignored: "{{count}} ignorada(s)", + }, + detailOne: "Verificação: {{parts}}", + detailMany: "Verificações: {{parts}}", + groupOne: { + actionRequired: "{{count}} verificação com ação pendente", + warning: "{{count}} verificação com aviso", + failure: "{{count}} verificação com falha", + pending: "{{count}} verificação em andamento", + manual: "{{count}} verificação manual", + success: "{{count}} verificação aprovada", + ignored: "{{count}} verificação ignorada", + }, + groupMany: { + actionRequired: "{{count}} verificações com ação pendente", + warning: "{{count}} verificações com aviso", + failure: "{{count}} verificações com falha", + pending: "{{count}} verificações em andamento", + manual: "{{count}} verificações manuais", + success: "{{count}} verificações aprovadas", + ignored: "{{count}} verificações ignoradas", + }, }, checksSummary: { passedLabel: "passou", @@ -1006,17 +1056,21 @@ export const ptBR: TranslationResources = { checks: "Verificações", pipeline: "Pipeline", reviews: "Revisões", + activity: "Atividade", }, empty: { noJobs: "Sem jobs", loadingPipeline: "Carregando pipeline...", pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline", allowedToFail: "permitido falhar", + noActivity: "Nenhuma atividade ainda", }, approvals: "{{given}} de {{required}} aprovações", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Ações do comentário", + threadActions: "Ações da conversa", checkStatus: { passed: "Aprovado", failed: "Falhou", @@ -1036,15 +1090,17 @@ export const ptBR: TranslationResources = { }, activity: { commented: "Comentou", - approved: "Aprovado", + approved: "Aprovou", requestedChanges: "Solicitou alterações", - reviewed: "Revisado", + reviewed: "Revisou", }, time: { justNow: "agora mesmo", }, thread: { discussion: "Tópico de discussão", + resolved: "Resolvido", + outdated: "Desatualizado", }, errors: { statusLoadFailed: "Não foi possível carregar o status da pull request", @@ -1114,7 +1170,16 @@ export const ptBR: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", + statusBucket: { + needsInput: "Precisa de resposta", + failed: "Com falha", + readyToReview: "Para revisar", + working: "Em execução", + done: "Concluído", + }, display: { + showBackground: "Mostrar espaços em segundo plano", trigger: "Preferências de exibição", heading: "Exibição", grouping: { @@ -1734,6 +1799,12 @@ export const ptBR: TranslationResources = { helper: "Conecte-se a um daemon Paseo no host remoto.", fields: { target: "Host SSH", + password: "Senha do daemon", + optional: "Opcional", + }, + passwordVisibility: { + show: "Mostrar senha", + hide: "Ocultar senha", }, actions: { cancel: "Cancelar", @@ -1964,6 +2035,8 @@ export const ptBR: TranslationResources = { dismiss: "Dispensar", }, contextWindow: { + noData: "Sem dados de contexto", + accessibilityNoData: "Janela de contexto: sem dados de contexto", title: "Janela de contexto", used: "{{percentage}}% usado", tokens: "{{used}} / {{max}} tokens", @@ -1971,6 +2044,17 @@ export const ptBR: TranslationResources = { accessibility: "Janela de contexto {{percentage}}% usada", }, review: { + feedback: { + send: "Enviar comentários ({{count}})", + sending: "Enviando comentários ({{count}})", + chooseAgent: "Escolher um agente", + sent: "Comentários enviados para {{recipient}}", + "no-agents": "Abra uma aba de agente neste espaço de trabalho para enviar comentários.", + disconnected: "Conecte-se ao host para enviar comentários.", + "no-context": "Os comentários salvos não correspondem mais a este diff.", + failed: "Falha ao enviar comentários. Tente novamente.", + prompt: "Por favor, atenda a esta revisão de código.", + }, comment: { add: "Adicionar comentário de revisão", edit: "Editar comentário de revisão", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4526784896..57616a22a7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -31,6 +31,7 @@ export const ru: TranslationResources = { back: "Назад", loading: "Загрузка...", actions: { + save: "Сохранить", back: "Назад", cancel: "Отмена", close: "Закрыть", @@ -124,6 +125,8 @@ export const ru: TranslationResources = { input: { accessibilityLabel: "Написать агенту...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Полный экран", + exitFullscreen: "Выйти из полноэкранного режима", focusHint: "{{shortcut}}, для фокуса", addAttachment: "Добавить вложение", interruptAgent: "Прервать агента", @@ -386,6 +389,9 @@ export const ru: TranslationResources = { completed: "Завершена", }, }, + turnFooter: { + workedFor: "Время работы: {{duration}}", + }, compaction: { loading: "Сжатие контекста...", auto: "Контекст сжат автоматически", @@ -614,6 +620,9 @@ export const ru: TranslationResources = { updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", unableToSubscribe: "Не удалось подключиться к терминалу", }, + sheet: { + openAsTab: "Открыть во вкладке", + }, tabs: { loading: "Загрузка...", modified: "Несохранённые изменения", @@ -987,6 +996,47 @@ export const ru: TranslationResources = { actions: { viewPullRequest: "Просмотреть", openOn: "Открыть на {{brand}}", + addToChat: "Добавить в чат", + addAllToChat: "Добавить всё в чат", + addingToChat: "Добавление...", + }, + checksOverview: { + headline: { + actionRequired: "Некоторые проверки требуют внимания", + failure: "Некоторые проверки не пройдены", + pending: "Некоторые проверки ещё не завершены", + success: "Все проверки пройдены", + none: "Нет проверок", + }, + count: { + actionRequired: "требует действий: {{count}}", + warning: "с предупреждением: {{count}}", + failure: "не пройдено: {{count}}", + pending: "выполняется: {{count}}", + manual: "вручную: {{count}}", + success: "пройдено: {{count}}", + ignored: "пропущено: {{count}}", + }, + detailOne: "Проверка ({{parts}})", + detailMany: "Проверки ({{parts}})", + groupOne: { + actionRequired: "Требует действий: {{count}}", + warning: "С предупреждением: {{count}}", + failure: "Не пройдена: {{count}}", + pending: "Выполняется: {{count}}", + manual: "Ручная: {{count}}", + success: "Пройдена: {{count}}", + ignored: "Пропущена: {{count}}", + }, + groupMany: { + actionRequired: "Требуют действий: {{count}}", + warning: "С предупреждением: {{count}}", + failure: "Не пройдены: {{count}}", + pending: "Выполняются: {{count}}", + manual: "Ручные: {{count}}", + success: "Пройдены: {{count}}", + ignored: "Пропущены: {{count}}", + }, }, checksSummary: { passedLabel: "успешно", @@ -1000,17 +1050,21 @@ export const ru: TranslationResources = { checks: "Проверки", pipeline: "Пайплайн", reviews: "Ревью", + activity: "Активность", }, empty: { noJobs: "Нет заданий", loadingPipeline: "Загрузка пайплайна...", pipelineJobsLoadFailed: "Не удалось загрузить задания пайплайна", allowedToFail: "допускается сбой", + noActivity: "Активности пока нет", }, approvals: "Одобрено: {{given}} из {{required}}", accessibility: { pullRequest: "PR #{{number}}", pullRequest_mr: "MR !{{number}}", + commentActions: "Действия с комментарием", + threadActions: "Действия с обсуждением", checkStatus: { passed: "Успешно", failed: "Ошибка", @@ -1039,6 +1093,8 @@ export const ru: TranslationResources = { }, thread: { discussion: "Ветка обсуждения", + resolved: "Решено", + outdated: "Устарело", }, errors: { statusLoadFailed: "Не удалось загрузить статус PR", @@ -1105,7 +1161,16 @@ export const ru: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Группа «{{label}}»", + statusBucket: { + needsInput: "Ожидает ввода", + failed: "Ошибка", + readyToReview: "На проверку", + working: "В работе", + done: "Готово", + }, display: { + showBackground: "Показать фоновые пространства", trigger: "Настройки отображения", heading: "Отображение", grouping: { @@ -1732,6 +1797,12 @@ export const ru: TranslationResources = { helper: "Подключитесь к демону Paseo на удалённом хосте.", fields: { target: "Хост SSH", + password: "Пароль демона", + optional: "Необязательно", + }, + passwordVisibility: { + show: "Показать пароль", + hide: "Скрыть пароль", }, actions: { cancel: "Отмена", @@ -1964,6 +2035,8 @@ export const ru: TranslationResources = { dismiss: "Закрыть", }, contextWindow: { + noData: "Нет данных о контексте", + accessibilityNoData: "Контекстное окно: нет данных о контексте", title: "Контекстное окно", used: "Использовано: {{percentage}}%", tokens: "Токены: {{used}} / {{max}}", @@ -1971,6 +2044,17 @@ export const ru: TranslationResources = { accessibility: "Использовано {{percentage}}% контекстного окна", }, review: { + feedback: { + send: "Отправить отзыв ({{count}})", + sending: "Отправка отзыва ({{count}})", + chooseAgent: "Выберите агента", + sent: "Отзыв отправлен: {{recipient}}", + "no-agents": "Откройте вкладку агента в этой рабочей области, чтобы отправить отзыв.", + disconnected: "Подключитесь к хосту, чтобы отправить отзыв.", + "no-context": "Сохранённые комментарии больше не соответствуют этому diff.", + failed: "Не удалось отправить отзыв. Повторите попытку.", + prompt: "Пожалуйста, учтите эту проверку кода.", + }, comment: { add: "Добавить комментарий к ревью", edit: "Изменить комментарий к ревью", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d13007dbea..99088e068a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -30,6 +30,7 @@ export const zhCN: TranslationResources = { back: "返回", loading: "加载中...", actions: { + save: "保存", back: "返回", cancel: "取消", close: "关闭", @@ -123,6 +124,8 @@ export const zhCN: TranslationResources = { input: { accessibilityLabel: "给 Agent 发消息...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全屏", + exitFullscreen: "退出全屏", focusHint: "{{shortcut}} 聚焦", addAttachment: "添加附件", interruptAgent: "中断 Agent", @@ -382,6 +385,9 @@ export const zhCN: TranslationResources = { completed: "已完成", }, }, + turnFooter: { + workedFor: "工作了 {{duration}}", + }, compaction: { loading: "正在压缩...", auto: "上下文已自动压缩", @@ -607,6 +613,9 @@ export const zhCN: TranslationResources = { updateHost: "请更新主机以使用原生终端渲染器。", unableToSubscribe: "无法订阅 Terminal", }, + sheet: { + openAsTab: "作为标签页打开", + }, tabs: { loading: "正在加载...", modified: "未保存的更改", @@ -962,6 +971,47 @@ export const zhCN: TranslationResources = { actions: { viewPullRequest: "查看", openOn: "在 {{brand}} 上打开", + addToChat: "添加到聊天", + addAllToChat: "全部添加到聊天", + addingToChat: "正在添加...", + }, + checksOverview: { + headline: { + actionRequired: "部分检查需要你处理", + failure: "部分检查未通过", + pending: "部分检查尚未完成", + success: "所有检查均已通过", + none: "没有检查", + }, + count: { + actionRequired: "{{count}} 个需处理", + warning: "{{count}} 个警告", + failure: "{{count}} 个失败", + pending: "{{count}} 个进行中", + manual: "{{count}} 个手动", + success: "{{count}} 个成功", + ignored: "{{count}} 个已跳过", + }, + detailOne: "检查:{{parts}}", + detailMany: "检查:{{parts}}", + groupOne: { + actionRequired: "{{count}} 个需处理的检查", + warning: "{{count}} 个有警告的检查", + failure: "{{count}} 个失败的检查", + pending: "{{count}} 个进行中的检查", + manual: "{{count}} 个手动检查", + success: "{{count}} 个成功的检查", + ignored: "{{count}} 个已跳过的检查", + }, + groupMany: { + actionRequired: "{{count}} 个需处理的检查", + warning: "{{count}} 个有警告的检查", + failure: "{{count}} 个失败的检查", + pending: "{{count}} 个进行中的检查", + manual: "{{count}} 个手动检查", + success: "{{count}} 个成功的检查", + ignored: "{{count}} 个已跳过的检查", + }, }, checksSummary: { passedLabel: "通过", @@ -975,17 +1025,21 @@ export const zhCN: TranslationResources = { checks: "Checks", pipeline: "流水线", reviews: "Reviews", + activity: "动态", }, empty: { noJobs: "无作业", loadingPipeline: "正在加载流水线...", pipelineJobsLoadFailed: "无法加载流水线作业", allowedToFail: "允许失败", + noActivity: "暂无动态", }, approvals: "{{given}} / {{required}} 批准", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "合并请求 !{{number}}", + commentActions: "评论操作", + threadActions: "讨论串操作", checkStatus: { passed: "成功", failed: "失败", @@ -998,22 +1052,24 @@ export const zhCN: TranslationResources = { }, }, states: { - draft: "Draft", - merged: "已 merge", + draft: "草稿", + merged: "已合并", closed: "已关闭", - open: "Open", + open: "开放", }, activity: { commented: "已评论", approved: "已批准", requestedChanges: "请求修改", - reviewed: "已 review", + reviewed: "已审查", }, time: { justNow: "刚刚", }, thread: { discussion: "讨论主题", + resolved: "已解决", + outdated: "已过时", }, errors: { statusLoadFailed: "无法加载 Pull Request 状态", @@ -1080,7 +1136,16 @@ export const zhCN: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} 分组", + statusBucket: { + needsInput: "需要输入", + failed: "失败", + readyToReview: "待查看", + working: "运行中", + done: "已完成", + }, display: { + showBackground: "显示后台工作区", trigger: "显示偏好", heading: "显示", grouping: { @@ -1683,6 +1748,12 @@ export const zhCN: TranslationResources = { helper: "连接到远程主机上运行的 Paseo 守护进程。", fields: { target: "SSH 主机", + password: "守护进程密码", + optional: "可选", + }, + passwordVisibility: { + show: "显示密码", + hide: "隐藏密码", }, actions: { cancel: "取消", @@ -1909,6 +1980,8 @@ export const zhCN: TranslationResources = { dismiss: "关闭", }, contextWindow: { + noData: "暂无上下文数据", + accessibilityNoData: "上下文窗口:暂无上下文数据", title: "上下文窗口", used: "已使用 {{percentage}}%", tokens: "{{used}} / {{max}} tokens", @@ -1916,6 +1989,17 @@ export const zhCN: TranslationResources = { accessibility: "上下文窗口已使用 {{percentage}}%", }, review: { + feedback: { + send: "发送反馈 ({{count}})", + sending: "正在发送反馈 ({{count}})", + chooseAgent: "选择智能体", + sent: "已向 {{recipient}} 发送反馈", + "no-agents": "请在此工作区打开智能体标签页以发送反馈。", + disconnected: "请连接到主机以发送反馈。", + "no-context": "保存的评论不再匹配此差异。", + failed: "发送反馈失败。请重试。", + prompt: "请处理此代码审查。", + }, comment: { add: "添加 review 评论", edit: "编辑 review 评论", diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index d68e731e56..571474643b 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { formatShortcut } from "@/utils/format-shortcut"; +import { keyboardEventToComboString } from "./shortcut-string"; import { buildKeyboardShortcutHelpSections, buildEffectiveBindings, @@ -1026,6 +1027,90 @@ describe("unassigned shortcuts", () => { }); describe("matching", () => { + it("dispatches a captured Russian letter shortcut on the same key", () => { + const event = keyboardInput({ key: "и", code: "KeyB", ctrlKey: true }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Ctrl+B"); + const bindings = buildEffectiveBindings({ + "sidebar-toggle-left-ctrl-period-non-mac": combo!, + }); + + expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe( + "sidebar.toggle.left", + ); + }); + + it("dispatches a recorded dotted-uppercase letter with Shift held", () => { + const event = keyboardInput({ key: "İ", code: "KeyS", ctrlKey: true, shiftKey: true }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Ctrl+Shift+S"); + const bindings = buildEffectiveBindings({ + "sidebar-toggle-left-ctrl-period-non-mac": combo!, + }); + expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe( + "sidebar.toggle.left", + ); + }); + + it.each(["и", "И", "β", "ش", "ב", "é"])( + "matches default and recorded letter bindings for unsupported letter %s", + (key) => { + const event = keyboardInput({ key, code: "KeyB", ctrlKey: true }); + expectShortcutResolution({ + event, + context: desktopNonMac, + action: "sidebar.toggle.left", + }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Ctrl+B"); + expect( + resolveShortcut({ + event, + context: desktopNonMac, + bindings: buildEffectiveBindings({ + "sidebar-toggle-left-ctrl-period-non-mac": combo!, + }), + }).match?.action, + ).toBe("sidebar.toggle.left"); + }, + ); + + it.each(["v", "†", "Dead", "Unidentified", ""])( + "does not match an unrelated character or named key %s by physical letter", + (key) => { + expectNoShortcutResolution({ + event: { key, code: "KeyB", ctrlKey: true }, + context: desktopNonMac, + }); + }, + ); + + it("does not match non-letter bindings by physical key for unsupported letters", () => { + expectNoShortcutResolution({ + event: { key: "ю", code: "Period", ctrlKey: true }, + context: desktopNonMac, + }); + }); + + it("dispatches a captured Dvorak binding on the same logical key", () => { + const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Cmd+B"); + const bindings = buildEffectiveBindings({ "sidebar-toggle-left-mac-cmd-b": combo! }); + + expect( + resolveShortcut({ event, context: { isMac: true, isDesktop: true }, bindings }).match + ?.action, + ).toBe("sidebar.toggle.left"); + expect( + resolveShortcut({ + event: { key: "x", code: "KeyB", metaKey: true }, + context: { isMac: true, isDesktop: true }, + bindings, + }).match, + ).toBeNull(); + }); + it("stops matching a shortcut the user unassigned", () => { const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO }); 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/shift/internal/translate.android.tsx b/packages/app/src/keyboard/shift/internal/translate.android.tsx index 496e7b36ac..2a9e9d581d 100644 --- a/packages/app/src/keyboard/shift/internal/translate.android.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.android.tsx @@ -6,17 +6,20 @@ import { useKeyboardShift } from "./context"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; enabled?: boolean; + /** Space below the view's resting position that the keyboard may fill before the view moves. */ + bottomClearance?: number; } export function KeyboardTranslateView({ children, enabled = true, + bottomClearance = 0, style, ...props }: KeyboardTranslateViewProps) { const { shift } = useKeyboardShift(); const keyboardStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: enabled ? -shift.value : 0 }], + transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }], })); return ( diff --git a/packages/app/src/keyboard/shift/internal/translate.ios.tsx b/packages/app/src/keyboard/shift/internal/translate.ios.tsx index 7a3ba7e992..348fe828d0 100644 --- a/packages/app/src/keyboard/shift/internal/translate.ios.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.ios.tsx @@ -6,19 +6,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; enabled?: boolean; + /** Space below the view's resting position that the keyboard may fill before the view moves. */ + bottomClearance?: number; } export function KeyboardTranslateView({ children, enabled = true, + bottomClearance = 0, style, ...props }: KeyboardTranslateViewProps) { const insets = useSafeAreaInsets(); const { height, progress } = useKeyboardAnimation(); const translateY = useMemo( - () => Animated.add(height, Animated.multiply(progress, insets.bottom)), - [height, insets.bottom, progress], + () => + Animated.add( + Animated.add(height, Animated.multiply(progress, insets.bottom)), + bottomClearance, + ).interpolate({ + inputRange: [-1, 0], + outputRange: [-1, 0], + extrapolateLeft: "extend", + extrapolateRight: "clamp", + }), + [bottomClearance, height, insets.bottom, progress], ); const keyboardStyle = useMemo( () => ({ transform: [{ translateY: enabled ? translateY : 0 }] }), diff --git a/packages/app/src/keyboard/shift/internal/translate.tsx b/packages/app/src/keyboard/shift/internal/translate.tsx index d866fc943c..6a0506c301 100644 --- a/packages/app/src/keyboard/shift/internal/translate.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.tsx @@ -4,11 +4,13 @@ import { View, type ViewProps } from "react-native"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; enabled?: boolean; + bottomClearance?: number; } export function KeyboardTranslateView({ children, enabled: _enabled, + bottomClearance: _bottomClearance, ...props }: KeyboardTranslateViewProps) { return {children}; 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/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 60cbaa30e3..9ffd81725f 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -88,6 +88,7 @@ interface WebOverlayEntry { getScope: () => HTMLElement | null; getKeyHandler: () => WebOverlayKeyHandler; restoreFocus: HTMLElement | null; + manageFocus: boolean; } const webOverlayEntries: WebOverlayEntry[] = []; @@ -140,7 +141,7 @@ function focusFirstElement(scope: HTMLElement): void { function handleWebOverlayFocus(event: FocusEvent): void { const top = getTopWebOverlay(); const scope = top?.getScope(); - if (!scope || scope.contains(event.target as Node)) return; + if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return; if (webOverlayFocusCheckQueued) return; // React can autofocus a child before its parent scope ref attaches. Defer @@ -149,8 +150,10 @@ function handleWebOverlayFocus(event: FocusEvent): void { webOverlayFocusCheckQueued = true; queueMicrotask(() => { webOverlayFocusCheckQueued = false; - const currentScope = getTopWebOverlay()?.getScope(); - if (!currentScope || currentScope.contains(document.activeElement)) return; + const currentTop = getTopWebOverlay(); + const currentScope = currentTop?.getScope(); + if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement)) + return; focusFirstElement(currentScope); }); } @@ -169,7 +172,7 @@ export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean { const scope = top?.getScope(); if (!top || !scope) return false; - if (event.key === "Tab") { + if (top.manageFocus && event.key === "Tab") { const focusable = getFocusableElements(scope); const first = focusable[0]; const last = focusable[focusable.length - 1]; @@ -214,7 +217,12 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio const focusFrame = window.requestAnimationFrame(() => { const scope = entry.getScope(); - if (getTopWebOverlay() === entry && scope && !scope.contains(document.activeElement)) { + if ( + entry.manageFocus && + getTopWebOverlay() === entry && + scope && + !scope.contains(document.activeElement) + ) { focusFirstElement(scope); } }); @@ -225,6 +233,7 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio if (index !== -1) webOverlayEntries.splice(index, 1); detachWebOverlayListeners(); if ( + entry.manageFocus && options?.restoreFocus !== false && entry.restoreFocus && document.contains(entry.restoreFocus) @@ -239,17 +248,20 @@ interface WebOverlayRegistration { layer: number; onKeyDown: WebOverlayKeyHandler; restoreFocusRef?: React.RefObject; + /** Hover surfaces claim overlay keys while preserving the user's existing focus. */ + manageFocus?: boolean; } /** * Registers a focus scope in the same relative layer model used for painting. - * Only the highest painted overlay receives keyboard input; focus is trapped - * there and restored to the opener when that overlay closes. + * Only the highest painted overlay receives keyboard input. By default, focus is trapped + * there and restored to the opener when it closes; hover surfaces preserve focus instead. */ export function useWebOverlayRegistration({ active, layer, onKeyDown, + manageFocus = true, restoreFocusRef: preferredRestoreFocusRef, }: WebOverlayRegistration) { const idRef = useRef(Symbol("web-overlay")); @@ -305,10 +317,11 @@ export function useWebOverlayRegistration({ getScope: () => scopeRef.current, getKeyHandler: () => keyHandlerRef.current, restoreFocus: capturedRestoreFocusRef.current, + manageFocus, }; removeEntryRef.current = addWebOverlay(entry); registeredRef.current = true; - }, []); + }, [manageFocus]); const setScope = useCallback( (node: unknown) => { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2d8a605c3f..f857bbb349 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -27,6 +27,11 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { RetainedChatContent } from "./retained-chat-content"; import { Composer } from "@/composer"; +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "@/composer/editing-session"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { resolveComposerTrackControlClearance, @@ -1153,6 +1158,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); + // The workspace sheet is for looking; Open as tab brings the agent back with its composer. + const showsComposer = usePaneContext().host !== "sheet"; + // The dock lays out fullscreen for the same session the composer edits in. + const composerEditingSession = useComposerEditingSession("chat"); + const isComposerFullscreen = useComposerFullscreen(composerEditingSession); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const tasks = useSessionStore((state): TodoEntry[] | undefined => state.sessions[serverId]?.agentTasks.get(agentId), @@ -1216,7 +1226,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ composerState, ], ); - const composerSection = ( + const composerSection = showsComposer ? ( - ); + ) : null; const streamContent = ( @@ -1290,7 +1301,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ); const dock = ( - + {dockContent} {composerSection} {dockOverlay} @@ -1321,13 +1332,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ function ChatSurface({ children, disabled, + fullscreen, }: { children: [ReactNode, ReactNode, ReactNode]; disabled: boolean; + fullscreen: boolean; }) { return ( - {children} + {children} ); } @@ -1471,6 +1484,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd, isSubmitLoading, agentInputDraft, + editingSession, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, @@ -1484,6 +1498,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; + editingSession: ComposerEditingSession; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; @@ -1507,6 +1522,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd={cwd} isSubmitLoading={isSubmitLoading} agentInputDraft={agentInputDraft} + editingSession={editingSession} onAttentionInputFocus={onAttentionInputFocus} onAttentionPromptSend={onAttentionPromptSend} onComposerHeightChange={onComposerHeightChange} @@ -1522,6 +1538,7 @@ function ActiveAgentComposer({ cwd, isSubmitLoading, agentInputDraft, + editingSession, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, @@ -1533,6 +1550,7 @@ function ActiveAgentComposer({ cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; + editingSession: ComposerEditingSession; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; @@ -1629,6 +1647,7 @@ function ActiveAgentComposer({ onChangeAttachments={agentInputDraft.setAttachments} cwd={cwd} clearDraft={agentInputDraft.clear} + editingSession={editingSession} autoFocus autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)} isSubmitLoading={isSubmitLoading} @@ -1721,6 +1740,8 @@ const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", flexShrink: 1, + // Content-sized inline; fills the dock's fullscreen slot. + flexGrow: 1, }, }); diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 962e095b89..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 690bd439af..6c13ad3d83 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -9,6 +9,7 @@ import { buildSelectedTriggerLabel, filterAndRankModelRows, matchesModelSearch, + resolveEffectiveComposerModeId, resolveSelectedModelLabel, resolveSubmissionReadiness, } from "./provider-selection"; @@ -441,3 +442,56 @@ function getAllModelLabels(providers: ReturnType { + const selection = { + provider: "pi", + modelId: "", + thinkingOptionId: "", + availableModels: [], + }; + + it("drops a remembered mode when the provider offers no modes", () => { + expect( + resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, false), + ).toBe(""); + }); + + it("submits the mode the picker shows when the remembered mode is not offered", () => { + expect( + resolveEffectiveComposerModeId( + { + ...selection, + modeId: "plan", + modeOptions: [ + { id: "build", label: "Build" }, + { id: "ask", label: "Ask" }, + ], + }, + false, + ), + ).toBe("build"); + }); + + it("keeps a selected mode the provider offers", () => { + expect( + resolveEffectiveComposerModeId( + { + ...selection, + modeId: "ask", + modeOptions: [ + { id: "build", label: "Build" }, + { id: "ask", label: "Ask" }, + ], + }, + false, + ), + ).toBe("ask"); + }); + + it("keeps the selected mode while the provider's modes are still loading", () => { + expect( + resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, true), + ).toBe("build"); + }); +}); diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index ae00dd9528..f66a1b1476 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -272,6 +272,23 @@ export function resolveEffectiveComposerModelId(selection: ProviderSelectionStat ); } +// The mode picker shows the first offered mode when the selected one isn't +// offered (e.g. a remembered mode the provider no longer advertises), so the +// submitted mode must match that display. A provider with no modes gets none. +// While the provider is still loading its modes aren't known yet, so the +// selected mode is kept. +export function resolveEffectiveComposerModeId( + selection: ProviderSelectionState, + isModelLoading: boolean, +): string { + const selectedModeId = selection.modeId.trim(); + if (isModelLoading) { + return selectedModeId; + } + const modeIds = selection.modeOptions.map((mode) => mode.id); + return modeIds.includes(selectedModeId) ? selectedModeId : (modeIds[0] ?? ""); +} + export function resolveEffectiveComposerThinkingOptionId( selection: ProviderSelectionState, effectiveModelId: string, diff --git a/packages/app/src/review/comment-sheet.tsx b/packages/app/src/review/comment-sheet.tsx new file mode 100644 index 0000000000..d54ec0ec96 --- /dev/null +++ b/packages/app/src/review/comment-sheet.tsx @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { FormTextInput } from "@/components/ui/form-field"; +import { Button } from "@/components/ui/button"; +import type { InlineReviewActions, InlineReviewEditorState } from "./geometry"; + +export function ReviewCommentSheet({ + editor, + reviewActions, + onChangeBody, + enabled, +}: { + enabled: boolean | undefined; + editor: InlineReviewEditorState | null; + reviewActions: InlineReviewActions; + onChangeBody: (body: string) => void; +}) { + if (enabled === false || !editor) return null; + return ( + + ); +} + +function OpenReviewCommentSheet({ + editor, + reviewActions, + onChangeBody, +}: { + editor: InlineReviewEditorState; + reviewActions: InlineReviewActions; + onChangeBody: (body: string) => void; +}) { + const { t } = useTranslation(); + const context = `${editor.target.filePath} · ${editor.target.side === "old" ? "−" : "+"}${editor.target.lineNumber}`; + const header = useMemo( + () => ({ + title: t("review.comment.label"), + subtitle: {context}, + }), + [context, t], + ); + const { onSaveEditor, onCancelEditor } = reviewActions; + const handleSave = useCallback(() => onSaveEditor(editor.body), [onSaveEditor, editor.body]); + const canSave = editor.body.trim().length > 0; + const footer = useMemo( + () => ( + + + + + ), + [canSave, handleSave, onCancelEditor, t], + ); + return ( + + + + ); +} + +const COMMENT_SNAP_POINTS = ["50%", "90%"]; + +const styles = StyleSheet.create((theme) => ({ + context: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, + input: { flex: 1, minHeight: 0, textAlignVertical: "top" }, + actions: { flex: 1, flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2] }, +})); diff --git a/packages/app/src/review/editor/index.test.ts b/packages/app/src/review/editor/index.test.ts new file mode 100644 index 0000000000..6735adef34 --- /dev/null +++ b/packages/app/src/review/editor/index.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { createReviewEditor, createReviewEditorScope, type ReviewCommentWriter } from "./index"; +import type { ReviewDraftComment } from "../state"; +import type { ReviewableDiffTarget } from "@/utils/diff-layout"; + +const target: ReviewableDiffTarget = { + key: "src/example.ts:new:2", + filePath: "src/example.ts", + side: "new", + lineNumber: 2, + hunkHeader: "@@ -1,2 +1,2 @@", + hunkIndex: 0, + lineIndex: 2, + oldLineNumber: null, + newLineNumber: 2, + lineType: "add", + content: "const value = next;", +}; + +function memoryWriter() { + const drafts = new Map(); + let sequence = 0; + const writer: ReviewCommentWriter = { + saveComment: ({ key, id, comment }) => { + const current = drafts.get(key) ?? []; + if (id && current.some((saved) => saved.id === id)) { + drafts.set( + key, + current.map((saved) => (saved.id === id ? { ...saved, ...comment } : saved)), + ); + } else { + const saved = { + ...comment, + id: String(++sequence), + createdAt: "2026-10-09T00:00:00Z", + updatedAt: "2026-10-09T00:00:00Z", + }; + drafts.set(key, [...current, saved]); + } + }, + deleteComment: ({ key, id }) => { + drafts.set( + key, + (drafts.get(key) ?? []).filter((comment) => comment.id !== id), + ); + }, + }; + return { writer, comments: (key: string) => drafts.get(key) ?? [] }; +} + +describe("review editor", () => { + it("saves trimmed comments, edits the same record, and cancels without altering saved comments", () => { + const storage = memoryWriter(); + const editor = createReviewEditor("first", storage.writer); + editor.start(target); + editor.setBody(" First comment "); + editor.save(editor.getState()!.body); + expect(editor.getState()).toBeNull(); + const saved = storage.comments("first")[0]!; + expect(saved).toMatchObject({ + filePath: target.filePath, + side: "new", + lineNumber: 2, + body: "First comment", + }); + editor.edit(target, saved); + editor.setBody("Discarded edit"); + editor.cancel(); + expect(storage.comments("first")).toEqual([saved]); + editor.edit(target, saved); + editor.save(" Updated comment "); + expect(storage.comments("first")).toEqual([{ ...saved, body: "Updated comment" }]); + editor.start(target); + editor.setBody("Discarded new comment"); + editor.cancel(); + expect(storage.comments("first")).toHaveLength(1); + }); + + it("keeps an open edit saveable after its submitted record is acknowledged", () => { + const storage = memoryWriter(); + const editor = createReviewEditor("first", storage.writer); + editor.start(target); + editor.save("Submitted body"); + const saved = storage.comments("first")[0]!; + editor.edit(target, saved); + editor.setBody("Typed before acknowledgement"); + storage.writer.deleteComment({ key: "first", id: saved.id }); + expect(editor.getState()!.body).toBe("Typed before acknowledgement"); + editor.save(editor.getState()!.body); + expect(storage.comments("first")).toMatchObject([ + { + body: "Typed before acknowledgement", + filePath: target.filePath, + lineNumber: target.lineNumber, + }, + ]); + }); + + it("rejects empty saves and repeated saves after the editor closes", () => { + const storage = memoryWriter(); + const editor = createReviewEditor("first", storage.writer); + editor.start(target); + editor.save(" \n "); + expect(editor.getState()).toEqual({ target, commentId: null, body: "" }); + expect(storage.comments("first")).toEqual([]); + editor.save("Saved once"); + editor.save("Duplicate"); + expect(storage.comments("first").map((comment) => comment.body)).toEqual(["Saved once"]); + }); + + it("isolates draft ownership and closes an editor when its saved comment is deleted", () => { + const storage = memoryWriter(); + const first = createReviewEditor("first", storage.writer); + const second = createReviewEditor("second", storage.writer); + first.start(target); + first.save("First workspace"); + const saved = storage.comments("first")[0]!; + first.edit(target, saved); + expect(second.getState()).toBeNull(); + second.start(target); + second.save("Second workspace"); + first.delete(saved.id); + expect(first.getState()).toBeNull(); + expect(storage.comments("first")).toEqual([]); + expect(storage.comments("second").map((comment) => comment.body)).toEqual(["Second workspace"]); + }); +}); + +it("retains an open editor through remounts without exposing it on another surface or draft", async () => { + const storage = memoryWriter(); + const scope = createReviewEditorScope(storage.writer); + const editor = scope.forDraft("first", "diff"); + const unsubscribe = editor.subscribe(() => {}); + editor.start(target); + editor.setBody("Unsaved wide body"); + unsubscribe(); + await Promise.resolve(); + const compact = scope.forDraft("first", "diff"); + expect(compact.getState()!.body).toBe("Unsaved wide body"); + expect(scope.forDraft("first", "combined").getState()).toBeNull(); + expect(scope.forDraft("second", "diff").getState()).toBeNull(); + compact.save(compact.getState()!.body); + expect(storage.comments("first")[0]!.body).toBe("Unsaved wide body"); + expect(scope.forDraft("first", "diff").getState()).toBeNull(); +}); diff --git a/packages/app/src/review/editor/index.ts b/packages/app/src/review/editor/index.ts new file mode 100644 index 0000000000..6a7741eceb --- /dev/null +++ b/packages/app/src/review/editor/index.ts @@ -0,0 +1,84 @@ +import type { ReviewableDiffTarget } from "@/utils/diff-layout"; +import type { InlineReviewEditorState } from "../geometry"; +import type { ReviewDraftComment } from "../state"; + +export interface ReviewCommentWriter { + saveComment: (input: { + key: string; + id: string | null; + comment: Pick; + }) => void; + deleteComment: (input: { key: string; id: string }) => void; +} + +/** One editor belongs to one review draft; replacing the draft creates a fresh editor. */ +export function createReviewEditor(key: string, writer: ReviewCommentWriter) { + return buildReviewEditor(key, writer, () => {}); +} + +function buildReviewEditor(key: string, writer: ReviewCommentWriter, onUnused: () => void) { + let editor: InlineReviewEditorState | null = null; + const listeners = new Set<() => void>(); + function publish(next: InlineReviewEditorState | null) { + editor = next; + listeners.forEach((listener) => listener()); + if (!editor && listeners.size === 0) onUnused(); + } + return { + getState: () => editor, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + queueMicrotask(() => { + if (!editor && listeners.size === 0) onUnused(); + }); + }; + }, + start: (target: ReviewableDiffTarget) => publish({ target, commentId: null, body: "" }), + edit: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => + publish({ target, commentId: comment.id, body: comment.body }), + setBody: (body: string) => { + if (editor) publish({ ...editor, body }); + }, + cancel: () => publish(null), + save: (body: string) => { + const trimmedBody = body.trim(); + if (!editor || !trimmedBody) return; + writer.saveComment({ + key, + id: editor.commentId, + comment: { + filePath: editor.target.filePath, + side: editor.target.side, + lineNumber: editor.target.lineNumber, + body: trimmedBody, + }, + }); + publish(null); + }, + delete: (id: string) => { + writer.deleteComment({ key, id }); + if (editor?.commentId === id) publish(null); + }, + }; +} + +/** An open draft survives presentation remounts; each presentation owns its editor for that draft. */ +export function createReviewEditorScope(writer: ReviewCommentWriter) { + const editors = new Map>(); + return { + forDraft(key: string, presentation: string) { + const owner = JSON.stringify([key, presentation]); + let model = editors.get(owner); + if (!model) { + const created = buildReviewEditor(key, writer, () => { + if (editors.get(owner) === created) editors.delete(owner); + }); + model = created; + editors.set(owner, model); + } + return model; + }, + }; +} diff --git a/packages/app/src/review/feedback/index.test.ts b/packages/app/src/review/feedback/index.test.ts new file mode 100644 index 0000000000..b423855b75 --- /dev/null +++ b/packages/app/src/review/feedback/index.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "vitest"; +import { + createReviewFeedback, + createReviewFeedbackScope, + type ReviewFeedbackPorts, + type ReviewFeedbackSnapshot, + type FeedbackAttachment, + type FeedbackRecipient, +} from "./index"; +import type { ReviewDraftComment } from "../state"; + +function makeComment(id: string, body = id): ReviewDraftComment { + return { + id, + body, + filePath: "sample.ts", + side: "new", + lineNumber: 2, + createdAt: "2026-10-09T00:00:00Z", + updatedAt: "2026-10-09T00:00:00Z", + }; +} +function makeRecipient(agentId: string): FeedbackRecipient { + return { + agentId, + tab: { + key: `agent_${agentId}`, + tabId: `agent_${agentId}`, + kind: "agent", + target: { kind: "agent", agentId }, + }, + }; +} +function makeAttachment(comments: readonly ReviewDraftComment[]): FeedbackAttachment | null { + if (!comments.length) return null; + const line = { + type: "add" as const, + content: "const value = 1", + oldLineNumber: null, + newLineNumber: 2, + }; + return { + kind: "review", + reviewDraftKey: "draft", + commentCount: comments.length, + attachment: { + type: "review", + mimeType: "application/paseo-review", + cwd: "/workspace", + mode: "uncommitted", + baseRef: null, + comments: comments.map((comment) => ({ + filePath: comment.filePath, + side: comment.side, + lineNumber: comment.lineNumber, + body: comment.body, + context: { hunkHeader: "@@ -1 +1 @@", targetLine: line, lines: [line] }, + })), + }, + }; +} +function setup(agentIds: string[], unavailableIds: string[] = [], create = createReviewFeedback) { + let comments = [makeComment("first")]; + let recipients = agentIds.map(makeRecipient); + let connected = true; + let rejectSend: ((error: Error) => void) | null = null; + let resolveSend: (() => void) | null = null; + const sends: Array<{ recipient: FeedbackRecipient; attachment: FeedbackAttachment }> = []; + const read = (): ReviewFeedbackSnapshot => ({ + comments, + recipients, + connected, + attachment: makeAttachment(comments.filter((comment) => !unavailableIds.includes(comment.id))), + }); + const ports: ReviewFeedbackPorts = { + read, + send: (recipient, attachment) => { + sends.push({ recipient, attachment }); + return new Promise((resolve, reject) => { + resolveSend = resolve; + rejectSend = reject; + }); + }, + clearSent: (sent) => { + comments = comments.filter( + (current) => + !sent.some( + (prior) => + prior.id === current.id && + prior.updatedAt === current.updatedAt && + prior.body === current.body, + ), + ); + }, + failedMessage: () => "Failed to send", + }; + const model = create(ports); + return { + model, + ports, + sends, + comments: () => comments, + setComments: (next: ReviewDraftComment[]) => { + comments = next; + model.refresh(); + }, + setRecipients: (ids: string[]) => { + recipients = ids.map(makeRecipient); + model.refresh(); + }, + disconnect: () => { + connected = false; + model.refresh(); + }, + succeed: () => resolveSend!(), + fail: () => rejectSend!(new Error("Host rejected feedback")), + }; +} + +describe("review feedback", () => { + it("counts only attached comments, freezes the sending count, and retains orphans", async () => { + const feedback = setup(["one"], ["orphan"]); + feedback.setComments([makeComment("first"), makeComment("orphan")]); + expect(feedback.model.getState()).toMatchObject({ commentCount: 2, sendableCommentCount: 1 }); + const pending = feedback.model.press(); + feedback.setComments([makeComment("first"), makeComment("orphan"), makeComment("added")]); + expect(feedback.model.getState()).toMatchObject({ + phase: { kind: "sending", commentCount: 1 }, + sendableCommentCount: 2, + }); + expect(feedback.sends[0]!.attachment.commentCount).toBe(1); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([makeComment("orphan"), makeComment("added")]); + }); + it("retains an entirely orphaned draft without claiming any comments are sendable", async () => { + const feedback = setup(["one"], ["first"]); + expect(feedback.model.getState()).toMatchObject({ + commentCount: 1, + sendableCommentCount: 0, + disabledReason: "no-context", + canSend: false, + }); + await feedback.model.press(); + expect(feedback.sends).toEqual([]); + expect(feedback.comments()).toEqual([makeComment("first")]); + }); + it("retains comments and explains why no workspace agent can receive them", async () => { + const feedback = setup([]); + await feedback.model.press(); + expect(feedback.model.getState()).toMatchObject({ + canSend: false, + disabledReason: "no-agents", + commentCount: 1, + }); + expect(feedback.sends).toEqual([]); + expect(feedback.comments()).toEqual([makeComment("first")]); + }); + it("sends once to the only recipient and clears only sent versions", async () => { + const feedback = setup(["one"]); + const pending = feedback.model.press(); + await feedback.model.press(); + await feedback.model.send("one"); + expect(feedback.sends).toEqual([ + { recipient: makeRecipient("one"), attachment: makeAttachment([makeComment("first")]) }, + ]); + feedback.setComments([makeComment("first", "Edited during send"), makeComment("added")]); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([ + makeComment("first", "Edited during send"), + makeComment("added"), + ]); + expect(feedback.model.getState().phase).toEqual({ + kind: "sent", + recipient: makeRecipient("one"), + }); + }); + it("clears unchanged sent comments while preserving additions", async () => { + const feedback = setup(["one"]); + const pending = feedback.model.press(); + feedback.setComments([makeComment("first"), makeComment("added")]); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([makeComment("added")]); + }); + it("asks for a recipient when several tabs exist and validates the selection again", async () => { + const feedback = setup(["one", "two"]); + await feedback.model.press(); + expect(feedback.model.getState().phase).toEqual({ kind: "choosing" }); + expect(feedback.sends).toEqual([]); + feedback.setRecipients(["one"]); + await feedback.model.send("two"); + expect(feedback.sends).toEqual([]); + const pending = feedback.model.send("one"); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([]); + }); + it("retains comments on failure and retries using the latest saved version", async () => { + const feedback = setup(["one"]); + const failed = feedback.model.press(); + feedback.fail(); + await failed; + expect(feedback.model.getState().phase).toEqual({ + kind: "failed", + message: "Host rejected feedback", + }); + expect(feedback.comments()).toEqual([makeComment("first")]); + feedback.setComments([makeComment("first", "Corrected before retry")]); + const retried = feedback.model.press(); + feedback.succeed(); + await retried; + expect(feedback.sends).toHaveLength(2); + expect(feedback.sends[1]!.attachment.attachment.comments[0]!.body).toBe( + "Corrected before retry", + ); + expect(feedback.comments()).toEqual([]); + }); + it("keeps workspace drafts independent and prevents disconnected sends", async () => { + const first = setup(["one"]); + const second = setup(["other"]); + first.disconnect(); + await first.model.press(); + expect(first.model.getState().disabledReason).toBe("disconnected"); + const pending = second.model.press(); + second.succeed(); + await pending; + expect(first.comments()).toEqual([makeComment("first")]); + expect(first.sends).toEqual([]); + expect(second.comments()).toEqual([]); + }); +}); + +describe("shared review submission ownership", () => { + it("blocks another surface and reconnects to the pending draft while other drafts remain usable", async () => { + const scope = createReviewFeedbackScope(); + const first = setup(["one"], [], (ports) => scope.connect("workspace-one", ports)); + const second = scope.connect("workspace-one", first.ports); + const other = setup(["other"], [], (ports) => scope.connect("workspace-other", ports)); + const unsubscribe = first.model.subscribe(() => {}); + const pending = first.model.press(); + unsubscribe(); + await Promise.resolve(); + const reconnected = scope.connect("workspace-one", first.ports); + expect(second.getState()).toMatchObject({ canSend: false, phase: { kind: "sending" } }); + await second.press(); + await reconnected.send("one"); + expect(first.sends).toHaveLength(1); + const otherPending = other.model.press(); + expect(other.sends).toHaveLength(1); + first.fail(); + await pending; + expect(second.getState().phase).toEqual({ kind: "failed", message: "Host rejected feedback" }); + const retry = second.press(); + first.succeed(); + await retry; + expect(first.sends).toHaveLength(2); + other.succeed(); + await otherPending; + }); + it("opens only the active surface's recipient chooser", async () => { + const scope = createReviewFeedbackScope(); + const first = setup(["one", "two"], [], (ports) => scope.connect("same", ports)); + const second = scope.connect("same", first.ports); + await first.model.press(); + expect(first.model.getState().phase).toEqual({ kind: "choosing" }); + expect(second.getState().phase).toEqual({ kind: "idle" }); + first.model.closeMenu(); + expect(first.model.getState().phase).toEqual({ kind: "idle" }); + }); +}); diff --git a/packages/app/src/review/feedback/index.ts b/packages/app/src/review/feedback/index.ts new file mode 100644 index 0000000000..69e0592fe8 --- /dev/null +++ b/packages/app/src/review/feedback/index.ts @@ -0,0 +1,177 @@ +import type { ComposerAttachment } from "@/attachments/types"; +import type { ReviewDraftComment } from "../state"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +export interface FeedbackRecipient { + agentId: string; + tab: WorkspaceTabDescriptor; +} +export type FeedbackAttachment = Extract; +export interface ReviewFeedbackSnapshot { + comments: readonly ReviewDraftComment[]; + attachment: FeedbackAttachment | null; + recipients: readonly FeedbackRecipient[]; + connected: boolean; +} +export interface ReviewFeedbackPorts { + read: () => ReviewFeedbackSnapshot; + send: (recipient: FeedbackRecipient, attachment: FeedbackAttachment) => Promise; + clearSent: (comments: readonly ReviewDraftComment[]) => void; + failedMessage: () => string; +} +export type FeedbackPhase = + | { kind: "idle" } + | { kind: "choosing" } + | { kind: "sending"; recipient: FeedbackRecipient; commentCount: number } + | { kind: "failed"; message: string } + | { kind: "sent"; recipient: FeedbackRecipient }; +export interface ReviewFeedbackState { + phase: FeedbackPhase; + commentCount: number; + sendableCommentCount: number; + recipients: readonly FeedbackRecipient[]; + disabledReason: "no-agents" | "disconnected" | "no-context" | null; + canSend: boolean; +} + +export function createReviewFeedback(ports: ReviewFeedbackPorts) { + return createFeedbackView(ports, createFeedbackWorkflow()); +} + +/** Surfaces share submission ownership while keeping their chooser and live inputs local. */ +export function createReviewFeedbackScope() { + const workflows = new Map>(); + return { + connect(key: string, ports: ReviewFeedbackPorts) { + let workflow = workflows.get(key); + if (!workflow) { + const created = createFeedbackWorkflow(() => { + if (workflows.get(key) === created) workflows.delete(key); + }); + workflow = created; + workflows.set(key, workflow); + } + return createFeedbackView(ports, workflow); + }, + }; +} + +function createFeedbackWorkflow(onUnused: () => void = () => {}) { + let phase: FeedbackPhase = { kind: "idle" }; + const observers = new Set<() => void>(); + function releaseIfUnused() { + queueMicrotask(() => { + if (observers.size === 0 && phase.kind !== "sending") onUnused(); + }); + } + return { + getPhase: () => phase, + changePhase(next: FeedbackPhase) { + phase = next; + observers.forEach((observer) => observer()); + releaseIfUnused(); + }, + subscribe(observer: () => void) { + observers.add(observer); + return () => { + observers.delete(observer); + releaseIfUnused(); + }; + }, + }; +} + +function createFeedbackView( + ports: ReviewFeedbackPorts, + workflow: ReturnType, +) { + let choosing = false; + const choosingPhase: FeedbackPhase = { kind: "choosing" }; + const getPhase = () => (choosing ? choosingPhase : workflow.getPhase()); + let state = deriveState(ports.read(), getPhase()); + const listeners = new Set<() => void>(); + function publish() { + state = deriveState(ports.read(), getPhase()); + listeners.forEach((listener) => listener()); + } + async function send(agentId: string) { + const snapshot = ports.read(); + const current = deriveState(snapshot, workflow.getPhase()); + const recipient = snapshot.recipients.find((candidate) => candidate.agentId === agentId); + if (!current.canSend || !recipient || !snapshot.attachment) return; + const attachment = snapshot.attachment; + const sentComments = snapshot.comments.filter((comment) => + attachment.attachment.comments.some( + (sent) => + sent.filePath === comment.filePath && + sent.side === comment.side && + sent.lineNumber === comment.lineNumber && + sent.body === comment.body, + ), + ); + choosing = false; + workflow.changePhase({ kind: "sending", recipient, commentCount: attachment.commentCount }); + publish(); + try { + await ports.send(recipient, attachment); + ports.clearSent(sentComments); + workflow.changePhase({ kind: "sent", recipient }); + } catch (error) { + workflow.changePhase({ + kind: "failed", + message: error instanceof Error ? error.message : ports.failedMessage(), + }); + } + publish(); + } + return { + getState: () => { + if (state.phase !== getPhase()) state = deriveState(ports.read(), getPhase()); + return state; + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + const unsubscribe = workflow.subscribe(() => { + choosing = false; + publish(); + }); + return () => { + listeners.delete(listener); + unsubscribe(); + }; + }, + refresh: publish, + press: async () => { + const snapshot = ports.read(); + if (!deriveState(snapshot, workflow.getPhase()).canSend) return; + if (snapshot.recipients.length === 1) { + await send(snapshot.recipients[0]!.agentId); + } else { + choosing = true; + publish(); + } + }, + closeMenu: () => { + if (choosing) { + choosing = false; + publish(); + } + }, + send, + }; +} + +function deriveState(snapshot: ReviewFeedbackSnapshot, phase: FeedbackPhase): ReviewFeedbackState { + let disabledReason: ReviewFeedbackState["disabledReason"] = null; + if (snapshot.recipients.length === 0) disabledReason = "no-agents"; + else if (!snapshot.connected) disabledReason = "disconnected"; + else if (!snapshot.attachment) disabledReason = "no-context"; + return { + phase, + commentCount: snapshot.comments.length, + sendableCommentCount: snapshot.attachment?.commentCount ?? 0, + recipients: snapshot.recipients, + disabledReason, + canSend: snapshot.comments.length > 0 && disabledReason === null && phase.kind !== "sending", + }; +} diff --git a/packages/app/src/review/feedback/surface.tsx b/packages/app/src/review/feedback/surface.tsx new file mode 100644 index 0000000000..7f87f46d88 --- /dev/null +++ b/packages/app/src/review/feedback/surface.tsx @@ -0,0 +1,284 @@ +import { useCallback, useMemo, type ReactNode } from "react"; +import { Text, View } from "react-native"; +import { Send } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native-unistyles"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { useMenuContext } from "@/components/ui/menu"; +import { Button } from "@/components/ui/button"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { SPACING } from "@/styles/theme"; +import { FloatingActionButton } from "@/components/ui/floating-action-button"; +import { + WorkspaceTabPresentationResolver, + WorkspaceTabIcon, + type WorkspaceTabPresentation, +} from "@/screens/workspace/workspace-tab-presentation"; +import type { FeedbackRecipient, ReviewFeedbackState, createReviewFeedback } from "./index"; + +type FeedbackModel = ReturnType; +interface ReviewFeedbackActionProps { + bottomOffset: number; + model: FeedbackModel; + state: ReviewFeedbackState; + serverId: string; + workspaceId: string; +} + +export function ReviewFeedbackAction({ + enabled, + hasDiff, + ...props +}: Omit & { + workspaceId?: string | null; + enabled: boolean | undefined; + hasDiff: boolean; +}) { + if (enabled === false || !hasDiff) return null; + return ; +} + +function OpenReviewFeedbackAction({ + model, + state, + serverId, + workspaceId, + bottomOffset, +}: ReviewFeedbackActionProps) { + const { t } = useTranslation(); + const { bottom } = useSafeAreaInsets(); + const isCompact = useIsCompactFormFactor(); + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) model.closeMenu(); + }, + [model], + ); + const noteStyle = useMemo( + () => [ + styles.note, + !isCompact && styles.centered, + { + bottom: + bottom + + bottomOffset + + (isCompact ? 88 : buttonControlHeight.sm + SPACING[4] + SPACING[2]), + }, + ], + [bottom, bottomOffset, isCompact], + ); + const sending = state.phase.kind === "sending"; + const label = t(sending ? "review.feedback.sending" : "review.feedback.send", { + count: state.phase.kind === "sending" ? state.phase.commentCount : state.sendableCommentCount, + }); + const unavailableNotice = + state.disabledReason && state.commentCount > 0 ? ( + {t(`review.feedback.${state.disabledReason}`)} + ) : null; + let notice: ReactNode = unavailableNotice; + if (state.phase.kind === "failed") + notice = {state.phase.message}; + else if (state.phase.kind === "sent") + notice = ( + <> + + {unavailableNotice} + + ); + return ( + <> + {state.commentCount > 0 ? ( + + + + {state.recipients.map((recipient) => ( + + ))} + + + ) : null} + {notice ? ( + + {notice} + + ) : null} + + ); +} + +function FeedbackTrigger({ + model, + label, + disabled, + bottomInset, +}: { + model: FeedbackModel; + label: string; + disabled: boolean; + bottomInset: number; +}) { + const { triggerRef } = useMenuContext("FeedbackTrigger"); + const isCompact = useIsCompactFormFactor(); + const press = useCallback(() => { + void model.press(); + }, [model]); + if (!isCompact) { + return ( + + + + ); + } + return ( + + ); +} + +function RecipientOption({ + recipient, + model, + serverId, + workspaceId, +}: { + recipient: FeedbackRecipient; + model: FeedbackModel; + serverId: string; + workspaceId: string; +}) { + const choose = useCallback(() => { + void model.send(recipient.agentId); + }, [model, recipient.agentId]); + const render = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + ), + [choose], + ); + return ( + + {render} + + ); +} + +function RecipientRow({ + presentation, + onSelect, +}: { + presentation: WorkspaceTabPresentation; + onSelect: () => void; +}) { + const leading = useMemo( + () => , + [presentation], + ); + return ( + + {presentation.label} + + ); +} + +function SentNotice({ + recipient, + serverId, + workspaceId, +}: { + recipient: FeedbackRecipient; + serverId: string; + workspaceId: string; +}) { + const { t } = useTranslation(); + const render = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + {t("review.feedback.sent", { recipient: presentation.label })} + + ), + [t], + ); + return ( + + {render} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + wideAction: { position: "absolute", left: 0, right: 0, alignItems: "center" }, + centered: { alignItems: "center" }, + note: { + position: "absolute", + right: theme.spacing[4], + left: theme.spacing[4], + alignItems: "flex-end", + }, + text: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, +})); diff --git a/packages/app/src/review/feedback/use-feedback.ts b/packages/app/src/review/feedback/use-feedback.ts new file mode 100644 index 0000000000..e2b151953e --- /dev/null +++ b/packages/app/src/review/feedback/use-feedback.ts @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { + useReviewDraftComments, + getReviewDraftComments, + clearSentReviewDraftComments, +} from "../store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useAppSettings } from "@/hooks/use-settings"; +import { dispatchComposerAgentMessage } from "@/composer/actions"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; +import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store"; +import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { SPACING } from "@/styles/theme"; +import { i18n } from "@/i18n/i18next"; +import { + createReviewFeedbackScope, + type FeedbackAttachment, + type FeedbackRecipient, +} from "./index"; + +const feedbackScope = createReviewFeedbackScope(); + +export function useReviewFeedback(input: { + reviewDraftKey: string; + serverId: string; + workspaceId?: string; + attachment: FeedbackAttachment | null; +}) { + const { serverId, reviewDraftKey, workspaceId } = input; + const isCompact = useIsCompactFormFactor(); + const workspaceKey = workspaceId + ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) + : null; + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const { settings } = useAppSettings(); + const comments = useReviewDraftComments(reviewDraftKey); + const layout = useWorkspaceLayoutStore((store) => + workspaceKey ? store.layoutByWorkspace[workspaceKey] : undefined, + ); + // The adapter keeps live host and diff inputs available to commands without reconstructing the workflow. + const live = useRef({ + client, + connected, + attachment: input.attachment, + sendBehavior: settings.sendBehavior, + }); + live.current = { + client, + connected, + attachment: input.attachment, + sendBehavior: settings.sendBehavior, + }; + const model = useMemo( + () => + feedbackScope.connect(reviewDraftKey, { + read: () => ({ + comments: getReviewDraftComments(reviewDraftKey) ?? [], + attachment: live.current.attachment, + connected: live.current.connected, + recipients: readRecipients(workspaceKey), + }), + send: async (recipient, attachment) => { + const { client: targetClient, sendBehavior } = live.current; + if (!targetClient) throw new Error(i18n.t("workspace.terminal.hostDisconnected")); + const activeTurnBehavior = sendBehavior === "steer" ? "steer" : "interrupt"; + await dispatchComposerAgentMessage({ + client: targetClient, + agentId: recipient.agentId, + text: i18n.t("review.feedback.prompt"), + attachments: [attachment], + encodeImages: async () => [], + submission: createMessageSubmissionWriter(serverId), + activeTurnBehavior, + activeTurnId: + activeTurnBehavior === "steer" + ? (selectAgentTurnPresentation( + useSessionStore.getState().sessions[serverId], + recipient.agentId, + ).turnId ?? undefined) + : undefined, + }); + }, + clearSent: (sentComments) => + clearSentReviewDraftComments({ key: reviewDraftKey, comments: sentComments }), + failedMessage: () => i18n.t("review.feedback.failed"), + }), + [reviewDraftKey, serverId, workspaceKey], + ); + useEffect(() => { + model.refresh(); + }, [model, comments, layout, connected, input.attachment]); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + return { + model, + state, + clearance: + state.commentCount > 0 + ? (isCompact ? FLOATING_ACTION_BUTTON_CLEARANCE : buttonControlHeight.sm + SPACING[4]) + 44 + : 0, + }; +} + +function readRecipients(workspaceKey: string | null): FeedbackRecipient[] { + const tabs = workspaceKey + ? useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey) + : []; + const descriptors = deriveWorkspaceTabModel({ tabs }).tabs; + const recipients: FeedbackRecipient[] = []; + for (const { descriptor } of descriptors) { + if (descriptor.target.kind === "agent") + recipients.push({ agentId: descriptor.target.agentId, tab: descriptor }); + } + return recipients; +} diff --git a/packages/app/src/review/geometry.ts b/packages/app/src/review/geometry.ts index 819c1d29b1..46bbf88ecc 100644 --- a/packages/app/src/review/geometry.ts +++ b/packages/app/src/review/geometry.ts @@ -19,6 +19,7 @@ export interface InlineReviewActions { onStartComment: (target: ReviewableDiffTarget) => void; onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void; onCancelEditor: () => void; + onChangeEditorBody: (body: string) => void; onSaveEditor: (body: string) => void; onDeleteComment: (id: string) => void; } diff --git a/packages/app/src/review/index.ts b/packages/app/src/review/index.ts index 014447090b..308f955259 100644 --- a/packages/app/src/review/index.ts +++ b/packages/app/src/review/index.ts @@ -31,3 +31,8 @@ export { SMALL_ACTION_HIT_SLOP, useInlineReviewController, } from "./surface"; + +export { ReviewCommentSheet } from "./comment-sheet"; + +export { useReviewFeedback } from "./feedback/use-feedback"; +export { ReviewFeedbackAction } from "./feedback/surface"; diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index 344b28b61d..222a3f57f3 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest"; import type { StateStorage } from "zustand/middleware"; import type { ParsedDiffFile } from "@/git/use-diff-query"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; -import { buildReviewAttachmentSnapshot, buildReviewDraftKey } from "./store"; +import { + buildReviewAttachmentSnapshot, + buildReviewDraftKey, + addReviewDraftComment, + saveReviewDraftComment, + clearSentReviewDraftComments, + getReviewDraftComments, + resetReviewDraftStore, + useReviewDraftStore, +} from "./store"; import { addCommentToState, clearReviewInState, @@ -304,3 +313,49 @@ describe("buildReviewAttachmentSnapshot", () => { }); }); }); + +describe("successful feedback cleanup", () => { + it("clears sent versions only, preserving edits, additions and other workspace drafts", () => { + resetReviewDraftStore(); + const first = addReviewDraftComment({ key: "first", comment: makeComment() }); + const edited = addReviewDraftComment({ + key: "first", + comment: makeComment({ id: "edited", body: "Before send" }), + }); + const other = addReviewDraftComment({ key: "other", comment: makeComment({ id: "other" }) }); + useReviewDraftStore.getState().updateComment({ + key: "first", + id: edited.id, + updates: { body: "After send" }, + updatedAt: edited.updatedAt, + }); + const added = addReviewDraftComment({ key: "first", comment: makeComment({ id: "added" }) }); + clearSentReviewDraftComments({ key: "first", comments: [first, edited] }); + expect(getReviewDraftComments("first")).toEqual([{ ...edited, body: "After send" }, added]); + expect(getReviewDraftComments("other")).toEqual([other]); + resetReviewDraftStore(); + }); +}); + +it("saves an acknowledged open edit as a new version and keeps subsequent edits on that record", () => { + resetReviewDraftStore(); + const submitted = addReviewDraftComment({ key: "first", comment: makeComment() }); + clearSentReviewDraftComments({ key: "first", comments: [submitted] }); + saveReviewDraftComment({ + key: "first", + id: submitted.id, + comment: { ...submitted, body: "Open edit after acknowledgement" }, + }); + const restored = getReviewDraftComments("first")![0]!; + expect(restored.body).toBe("Open edit after acknowledgement"); + expect(restored.id).not.toBe(submitted.id); + clearSentReviewDraftComments({ key: "first", comments: [submitted] }); + expect(getReviewDraftComments("first")).toEqual([restored]); + saveReviewDraftComment({ + key: "first", + id: restored.id, + comment: { ...restored, body: "Saved again" }, + }); + expect(getReviewDraftComments("first")).toMatchObject([{ id: restored.id, body: "Saved again" }]); + resetReviewDraftStore(); +}); diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index bac1659751..614572190a 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -320,3 +320,43 @@ export function useReviewAttachmentSnapshot(input: { [comments, input.key, input.cwd, input.mode, input.baseRef, input.diffFiles], ); } + +/** Acknowledgement removes only the versions included in that submission. */ +export function clearSentReviewDraftComments(input: { + key: string; + comments: readonly ReviewDraftComment[]; +}): void { + const sentById = new Map(input.comments.map((comment) => [comment.id, comment])); + useReviewDraftStore.setState((state) => { + const current = state.drafts[input.key]; + if (!current) return state; + const remaining = current.filter((comment) => { + const sent = sentById.get(comment.id); + return !sent || sent.updatedAt !== comment.updatedAt || sent.body !== comment.body; + }); + if (remaining.length === current.length) return state; + return { drafts: { ...state.drafts, [input.key]: remaining } }; + }); +} + +/** Acknowledgement can remove the original while the user still has an edit open. */ +export function saveReviewDraftComment(input: { + key: string; + id: string | null; + comment: Pick; +}): void { + const store = useReviewDraftStore.getState(); + if (input.id && store.drafts[input.key]?.some((comment) => comment.id === input.id)) { + store.updateComment({ key: input.key, id: input.id, updates: { body: input.comment.body } }); + } else { + store.addComment({ + key: input.key, + comment: { + filePath: input.comment.filePath, + side: input.comment.side, + lineNumber: input.comment.lineNumber, + body: input.comment.body, + }, + }); + } +} diff --git a/packages/app/src/review/surface.test.tsx b/packages/app/src/review/surface.test.tsx index e23f321d38..925ebe6b24 100644 --- a/packages/app/src/review/surface.test.tsx +++ b/packages/app/src/review/surface.test.tsx @@ -9,45 +9,24 @@ import { useReviewDraftStore, type ReviewDraftComment } from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { getInlineReviewThreadState, - getInlineReviewThreadViewportStyle, getSplitInlineReviewThreadState, + type InlineReviewActions, +} from "./geometry"; +import { + getInlineReviewThreadViewportStyle, groupInlineReviewCommentsByTarget, InlineReviewEditor, InlineReviewGutterCell, InlineReviewThread, SMALL_ACTION_HIT_SLOP, useInlineReviewController, - type InlineReviewActions, -} from "./index"; +} from "./surface"; void testI18n; -const { theme, pressablePropsByLabel } = vi.hoisted(() => { +const { pressablePropsByLabel } = vi.hoisted(() => { Object.assign(globalThis, { __DEV__: false }); - return { - theme: { - spacing: { 1: 4, 2: 8, 3: 12 }, - borderWidth: { 1: 1 }, - borderRadius: { base: 4, md: 6, lg: 8, xl: 12, full: 999 }, - opacity: { 50: 0.5 }, - fontSize: { xs: 11, sm: 13 }, - fontWeight: { normal: "400", medium: "500" }, - lineHeight: { diff: 18 }, - colors: { - accent: "#0a84ff", - accentForeground: "#fff", - border: "#555", - destructive: "#ff453a", - foreground: "#fff", - foregroundMuted: "#aaa", - surface1: "#111", - surface2: "#222", - surface3: "#333", - palette: { white: "#fff" }, - }, - }, - pressablePropsByLabel: new Map>(), - }; + return { pressablePropsByLabel: new Map>() }; }); vi.mock("react-native", async (importOriginal) => { @@ -88,13 +67,16 @@ vi.mock("react-native", async (importOriginal) => { }; }); -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), - }, - withUnistyles: (component: T) => component, - useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), -})); +vi.mock("react-native-unistyles", async () => { + const { darkTheme: theme } = await import("@/styles/theme"); + return { + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + withUnistyles: (component: T) => component, + useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), + }; +}); vi.mock("@/constants/platform", () => ({ getIsElectron: () => false, @@ -103,19 +85,6 @@ vi.mock("@/constants/platform", () => ({ isWeb: true, })); -vi.mock("lucide-react-native", () => { - const createIcon = (name: string) => (props: Record) => - React.createElement("span", { ...props, "data-icon": name }); - return { - Check: createIcon("Check"), - CircleDot: createIcon("CircleDot"), - Pencil: createIcon("Pencil"), - Plus: createIcon("Plus"), - Trash2: createIcon("Trash2"), - X: createIcon("X"), - }; -}); - function target(overrides: Partial = {}): ReviewableDiffTarget { return { filePath: "src/example.ts", @@ -147,6 +116,7 @@ function buildReviewActions(overrides: Partial = {}): Inlin onStartComment: vi.fn(), onEditComment: vi.fn(), onCancelEditor: vi.fn(), + onChangeEditorBody: () => {}, onSaveEditor: vi.fn(), onDeleteComment: vi.fn(), ...overrides, @@ -312,8 +282,9 @@ describe("git diff inline review helpers", () => { it("keeps the line number visible and only floats the plus for line hover", () => { const reviewTarget = target(); - const { container, queryByText, rerender } = render( + const { queryByTestId, queryByText, rerender } = render( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + expect(queryByTestId("add-comment-glyph")).toBeNull(); rerender( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + expect(queryByTestId("add-comment-glyph")).toBeNull(); rerender( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + expect(queryByTestId("add-comment-glyph")).toBeNull(); rerender( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeTruthy(); + expect(queryByTestId("add-comment-glyph")).toBeTruthy(); }); }); @@ -384,7 +358,7 @@ describe("InlineReviewEditor", () => { const onCancel = vi.fn(); const onSave = vi.fn(); const { getByTestId } = render( - { const onCancel = vi.fn(); const onSave = vi.fn(); const { getByTestId } = render( - { removeEventListener: vi.fn(), }); const { getByTestId, queryByText } = render( - { expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1"); }); }); + +function ReviewEditorFixture({ + initialBody, + ...props +}: Omit, "body" | "onChangeBody"> & { + initialBody: string; +}) { + const [body, onChangeBody] = React.useState(initialBody); + return ; +} diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index 43b6af914a..7466052456 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -1,4 +1,11 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Pencil, Plus, Trash2 } from "lucide-react-native"; @@ -17,10 +24,17 @@ import { EditingTextInput as TextInput, type EditingTextInputHandle, } from "@/components/ui/text-input"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { createReviewEditorScope } from "./editor"; import { isWeb } from "@/constants/platform"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { Theme } from "@/styles/theme"; -import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; +import { + useReviewDraftComments, + useReviewDraftStore, + saveReviewDraftComment, + type ReviewDraftComment, +} from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { INLINE_REVIEW_EDITOR_HEIGHT, @@ -98,92 +112,42 @@ export function groupInlineReviewCommentsByTarget( return grouped; } -export function useInlineReviewController(input: { reviewDraftKey: string }): InlineReviewActions { +const reviewEditors = createReviewEditorScope({ + saveComment: saveReviewDraftComment, + deleteComment: (input) => useReviewDraftStore.getState().deleteComment(input), +}); + +export function useInlineReviewController(input: { + reviewDraftKey: string; + presentation?: string; +}): InlineReviewActions & { + sheetEditor: InlineReviewEditorState | null; + onChangeEditorBody: (body: string) => void; +} { + const isCompact = useIsCompactFormFactor(); const reviewComments = useReviewDraftComments(input.reviewDraftKey); const commentsByTarget = useMemo( () => groupInlineReviewCommentsByTarget(reviewComments), [reviewComments], ); - const [editor, setEditor] = useState(null); - const addComment = useReviewDraftStore((state) => state.addComment); - const updateComment = useReviewDraftStore((state) => state.updateComment); - const deleteComment = useReviewDraftStore((state) => state.deleteComment); - - useEffect(() => { - setEditor(null); - }, [input.reviewDraftKey]); - - const handleStartComment = useCallback((target: ReviewableDiffTarget) => { - setEditor({ target, commentId: null, body: "" }); - }, []); - - const handleEditComment = useCallback( - (target: ReviewableDiffTarget, comment: ReviewDraftComment) => { - setEditor({ target, commentId: comment.id, body: comment.body }); - }, - [], + const model = useMemo( + () => reviewEditors.forDraft(input.reviewDraftKey, input.presentation ?? "diff"), + [input.reviewDraftKey, input.presentation], ); - - const handleCancelEditor = useCallback(() => { - setEditor(null); - }, []); - - const handleSaveEditor = useCallback( - (body: string) => { - const trimmedBody = body.trim(); - if (!editor || trimmedBody.length === 0) { - return; - } - - if (editor.commentId) { - updateComment({ - key: input.reviewDraftKey, - id: editor.commentId, - updates: { body: trimmedBody }, - }); - } else { - addComment({ - key: input.reviewDraftKey, - comment: { - filePath: editor.target.filePath, - side: editor.target.side, - lineNumber: editor.target.lineNumber, - body: trimmedBody, - }, - }); - } - setEditor(null); - }, - [addComment, editor, input.reviewDraftKey, updateComment], - ); - - const handleDeleteComment = useCallback( - (id: string) => { - deleteComment({ key: input.reviewDraftKey, id }); - setEditor((current) => (current?.commentId === id ? null : current)); - }, - [deleteComment, input.reviewDraftKey], - ); - - return useMemo( + const editor = useSyncExternalStore(model.subscribe, model.getState, model.getState); + return useMemo( () => ({ commentsByTarget, - editor, - onStartComment: handleStartComment, - onEditComment: handleEditComment, - onCancelEditor: handleCancelEditor, - onSaveEditor: handleSaveEditor, - onDeleteComment: handleDeleteComment, + editor: isCompact ? null : editor, + sheetEditor: isCompact ? editor : null, + onChangeEditorBody: model.setBody, + onStartComment: model.start, + onEditComment: model.edit, + onCancelEditor: model.cancel, + onSaveEditor: model.save, + onDeleteComment: model.delete, }), - [ - commentsByTarget, - editor, - handleCancelEditor, - handleDeleteComment, - handleEditComment, - handleSaveEditor, - handleStartComment, - ], + [commentsByTarget, editor, isCompact, model], ); } @@ -321,7 +285,8 @@ export function InlineReviewThread({ const editorElement = editor ? ( void; onCancel: () => void; onSave: (body: string) => void; testID?: string; }) { const { t } = useTranslation(); const inputRef = useRef(null); - const [body, setBody] = useState(initialBody); const [isFocused, setIsFocused] = useState(false); const trimmedBody = body.trim(); const canSave = trimmedBody.length > 0; @@ -506,7 +472,7 @@ export function InlineReviewEditor({ placeholderTextColor={styles.placeholderColor.color} multiline initialValue={body} - onChangeText={setBody} + onChangeText={onChangeBody} onFocus={handleFocus} onBlur={handleBlur} style={inputStyle} diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 22c6c4f040..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 a3ea10f48b..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(); @@ -3919,6 +3950,53 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("authenticates Remote SSH probes with the entered daemon password and stores it verbatim", async () => { + const store = createPairingStore({ + password: " s3cret ", + serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""), + }); + answerHostConfirmations(store, false); + + await store.boot(); + await store.probeAndUpsertRemoteSshConnection({ + host: "deploy@example.com", + password: " s3cret ", + }); + + const host = store.getHosts().find((entry) => entry.serverId === "srv_ssh"); + expect(host?.password).toBe(" s3cret "); + expect(host?.connections[0]).toMatchObject({ + type: "remoteSsh", + host: "deploy@example.com", + }); + + // The fix promises the saved password is sent on every connection: a later + // cycle must authenticate from the stored profile alone. + await store.runProbeCycleNow("srv_ssh"); + await waitForHostOnline(store, "srv_ssh"); + store.syncHosts([]); + }); + + it("rejects Remote SSH probes without the daemon password, the way the daemon does", async () => { + const store = createPairingStore({ + password: "s3cret", + serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""), + }); + answerHostConfirmations(store, false); + + await store.boot(); + const failure = await store + .probeAndUpsertRemoteSshConnection({ host: "deploy@example.com" }) + .then( + () => null, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(DaemonAuthenticationError); + expect((failure as DaemonAuthenticationError).reason).toBe("password_required"); + expect(store.getHosts().find((entry) => entry.serverId === "srv_ssh")).toBeUndefined(); + store.syncHosts([]); + }); + it("preserves the existing host label when re-pairing an existing relay host", async () => { const store = createPairingStore({ hostname: "mbp" }); answerHostConfirmations(store, true); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 1e0fdb7cad..2ecbe60448 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1916,10 +1916,14 @@ export class HostRuntimeStore { host: string; sshPort?: number; daemonPort?: number; + password?: string; label?: string; }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { return this.probeAndUpsertConnection({ label: input.label, + // The daemon hashes and compares the password verbatim (`paseo daemon + // set-password` keeps what it reads), so whitespace is significant here. + password: input.password ? input.password : undefined, connection: createRemoteSshHostConnection(input), }); } @@ -2422,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", }); }, }) @@ -2844,6 +2853,7 @@ export interface HostMutations { host: string; sshPort?: number; daemonPort?: number; + password?: string; label?: string; }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; beginLinkPairing: () => LinkPairing; 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..6d8fd3de5a 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -17,6 +17,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { ComposerDock } from "@/composer/dock"; +import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -31,8 +32,7 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { SidebarMenuToggle } from "@/components/headers/menu-header"; -import { ScreenHeader } from "@/components/headers/screen-header"; +import { MenuHeader } from "@/components/headers/menu-header"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; @@ -892,7 +892,7 @@ function buildWorkspaceDraftSetupFromComposer(input: { return { provider: input.provider, cwd: input.cwd, - modeId: input.composerState.selectedMode || null, + modeId: input.composerState.effectiveModeId || null, model: input.composerState.effectiveModelId || null, thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, featureValues: input.composerState.featureValues ?? {}, @@ -972,7 +972,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise) => { getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); @@ -1724,7 +1726,7 @@ export function NewWorkspaceScreen({ [terminalComposerValue], ); const terminalTextReplacement = useMemo( - () => ({ key: launchFocusKey, text: terminalComposerValue }), + () => ({ kind: "replace" as const, key: launchFocusKey, text: terminalComposerValue }), [launchFocusKey, terminalComposerValue], ); @@ -2122,6 +2124,7 @@ export function NewWorkspaceScreen({ return; } outcome = "navigated"; + leaveFullscreen(); navigateToWorkspace({ serverId: targetServerId, workspaceId }); }, }); @@ -2172,6 +2175,7 @@ export function NewWorkspaceScreen({ forkDraftSetup, isStillOnCreateScreen, launchTarget, + leaveFullscreen, selectedServerId, supportsForgeSearch, t, @@ -2228,6 +2232,7 @@ export function NewWorkspaceScreen({ return; } outcome = "navigated"; + leaveFullscreen(); navigateToWorkspace({ serverId: targetServerId, workspaceId, target }); }, }); @@ -2251,6 +2256,7 @@ export function NewWorkspaceScreen({ t, terminalPromptText, toast, + leaveFullscreen, updateFormPreferences, withConnectedClient, ]); @@ -2368,13 +2374,14 @@ export function NewWorkspaceScreen({ }, }); - const screenHeaderLeft = useMemo(() => , []); const importSession = useImportSession({ serverId: selectedServerId }); + const isComposerFullscreen = composerEditing.isFullscreen(isTerminalLaunch); const composer = isTerminalLaunch ? ( - - - - - {composer} - {errorMessage ? {errorMessage} : null} - - + + {composer} + {errorMessage ? {errorMessage} : null} + {importSession.sheet} ); } +/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */ +function useNewWorkspaceComposerEditing() { + const terminal = useComposerEditingSession("terminal"); + const chat = useComposerEditingSession("chat"); + const isTerminalFullscreen = useComposerFullscreen(terminal); + const isChatFullscreen = useComposerFullscreen(chat); + // The sessions outlive the screen (its draft keeps them), so a launch that leaves + // the screen also leaves fullscreen; the next draft starts inline. + const leaveFullscreen = useCallback(() => { + terminal.setFullscreen(false); + chat.setFullscreen(false); + }, [chat, terminal]); + return { + terminal, + chat, + leaveFullscreen, + isFullscreen: (isTerminalLaunch: boolean) => + isTerminalLaunch ? isTerminalFullscreen : isChatFullscreen, + }; +} + function NewWorkspaceLayout({ isCompact, + fullscreen, title, formStack, onImportSession, children, }: { isCompact: boolean; + /** The composer covers the screen; the header leaves and the dock fills the content. */ + fullscreen: boolean; title: string; formStack: ReactNode; onImportSession: () => void; @@ -2474,13 +2503,19 @@ function NewWorkspaceLayout({ ); return ( - - {setupFields} - <> - {children} - {isCompact ? null : importSessionButton} - - + <> + {fullscreen ? null : } + + + + {setupFields} + <> + {children} + {isCompact || fullscreen ? null : importSessionButton} + + + + ); } diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 6e98819323..2dfea09fd0 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -241,7 +241,7 @@ interface FontFamilyRowProps { draft: string; withBorder: boolean; onChangeDraft: (value: string) => void; - onCommit: (value: string) => void; + onCommit: (value: string) => string; } function FontFamilyRow({ @@ -255,13 +255,16 @@ function FontFamilyRow({ onChangeDraft, onCommit, }: FontFamilyRowProps) { + const inputRef = useRef(null); const handleCommit = useCallback(() => { - onCommit(draft); + const committed = onCommit(draft); + inputRef.current?.replaceText(committed); }, [draft, onCommit]); // Resync from the committed value when it changes elsewhere. useEffect(() => { onChangeDraft(value); + inputRef.current?.replaceText(value); // Only resync on external value changes, not on local keystrokes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); @@ -273,6 +276,7 @@ function FontFamilyRow({ {hint} { await vi.importActual("react-native"); return { BottomSheetBackdrop: () => null, + BottomSheetHandle: () => null, BottomSheetModal: ReactModule.forwardRef(() => null), BottomSheetScrollView: ScrollView, BottomSheetTextInput: TextInput, diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 4495acba83..4fb0a43278 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -328,7 +328,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const isConnected = useHostRuntimeIsConnected(serverId); const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval"); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); - const { patchConfig } = useDaemonConfig(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); const openProviderSettings = useProviderSettingsStore((state) => state.open); const [pendingProviderId, setPendingProviderId] = useState(null); const [removingProviderId, setRemovingProviderId] = useState(null); @@ -338,6 +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..ef5b6aa779 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,7 @@ +import { + WorkspaceComposerEditingSessionsProvider, + useComposerTabFullscreen, +} 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 +18,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,7 +76,9 @@ import { getFocusedBrowserId, FOCUSED_PANE_PLACEMENT, selectExplorerSidebarPaneId, + selectPaneFocusSuspended, type WorkspaceLayout, + type WorkspaceSheet, type WorkspaceTabPlacement, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, @@ -115,30 +124,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 +165,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 +190,6 @@ import { import { findAdjacentPane } from "@/utils/split-navigation"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; -import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; @@ -208,6 +208,7 @@ import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; +const NO_SHEETS: readonly WorkspaceSheet[] = []; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; @@ -246,12 +247,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 +299,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 +321,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 +448,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 +455,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 +471,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 +496,16 @@ function WorkspaceHeaderTitleBar({ )} - {isMobile ? ( - - ) : ( - - )} - {isMobile && workspaceScripts.length > 0 ? ( - - ) : null} + ); @@ -1293,7 +739,7 @@ function shouldInspectWorkspaceRecovery( function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { return ( <> - + {children} ); @@ -1313,17 +759,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 +825,20 @@ function WorkspaceDocumentTitleEffectSlot({ ); } +function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean { + return supportsStreamTopOverlay && activeTab?.kind === "agent"; +} + +/** A fullscreen composer covers the compact header; the column lays out without it. */ +function resolveCompactHeaderLayout(input: { + header: ReactNode; + activeTab: WorkspaceTabDescriptor | null; + composerFullscreen: boolean; +}): { header: ReactNode; floating: boolean } { + if (input.composerFullscreen) return { header: null, floating: false }; + return { header: input.header, floating: floatsHeaderOverContent(input.activeTab) }; +} + function shouldShowWorkspaceScreenHeader(input: { isFocusModeEnabled: boolean; isMobile: boolean; @@ -1819,6 +1283,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 +1324,13 @@ function WorkspaceScreenContent({ const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const sheets = useWorkspaceLayoutStore((state) => + persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS, + ); + // Only the top sheet is on screen. + const sheetTab = useWorkspaceLayoutStore((state) => + persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null, + ); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState); @@ -1929,6 +1403,7 @@ function WorkspaceScreenContent({ const visibleAgentIds = useVisibleAgentIds({ layout: workspaceLayout, tabs: uiTabs, + sheetTab, routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, }); @@ -2230,7 +1705,6 @@ function WorkspaceScreenContent({ } const tabId = openPreferredWorkspaceTarget({ - isCompact: isMobile, workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), source: "chatFiles", @@ -2300,14 +1774,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 +1819,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 +2088,12 @@ function WorkspaceScreenContent({ const handleCloseTabById = useCallback( async (tabId: string) => { + if (persistenceKey && sheets.some((sheet) => sheet.tab.tabId === tabId)) { + if (await confirmDiscardModifiedTab(tabId)) { + closeWorkspaceTab(persistenceKey, tabId); + } + return; + } const tab = allTabDescriptorsById.get(tabId); if (!tab) { return; @@ -2667,10 +2113,13 @@ function WorkspaceScreenContent({ }, [ allTabDescriptorsById, + closeWorkspaceTab, confirmDiscardModifiedTab, handleCloseAgentTab, handleClosePassiveTab, handleCloseTerminalTab, + persistenceKey, + sheets, ], ); @@ -3504,17 +2953,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 +2984,6 @@ function WorkspaceScreenContent({ onOpenPreferredTarget: (target, source) => { if (!persistenceKey) return; const tabId = openPreferredWorkspacePreview({ - isCompact: isMobile, workspaceKey: persistenceKey, serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -3595,7 +3045,6 @@ function WorkspaceScreenContent({ canRenderDesktopPaneSplits, openImportSheet, openInSidePane, - isMobile, requestFileNavigation, revealWorkspaceChildTab, persistenceKey, @@ -3639,6 +3088,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 +3114,7 @@ function WorkspaceScreenContent({ focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, - paneFocusSuspended: Boolean(unfocusedPaneId), + paneFocusSuspended, onFocusPane: handleFocusPane, buildMobilePaneContentModel, }); @@ -3759,7 +3219,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 +3341,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 +3356,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 +3541,51 @@ function WorkspaceScreenContent({ const desktopContent = desktopSplitContent ?? content; const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null; - const workspacePanelContent = ( - + const workspacePanelTabIds = useMemo( + () => [...uiTabs.map((tab) => tab.tabId), ...sheets.map((sheet) => sheet.tab.tabId)], + [uiTabs, sheets], ); + const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`; + const compactHeaderLayout = resolveCompactHeaderLayout({ + header: compactWorkspaceHeader, + activeTab: activeTabDescriptor, + composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId), + }); - const workspaceCenterColumn = ( + // 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 +3722,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 +3780,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 +3894,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..a46ea2c1c7 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-sheet-host.tsx @@ -0,0 +1,326 @@ +import BottomSheet, { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + type BottomSheetHandleProps, +} from "@gorhom/bottom-sheet"; +import { useIsFocused } from "@react-navigation/native"; +import { + type ComponentType, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + SheetBackground, + SheetHandle, + SheetHeaderView, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { systemBackPress } from "@/components/ui/isolated-bottom-sheet-modal"; +import { RetainedPanelActivity } from "@/components/retained-panel"; +import { Button } from "@/components/ui/button"; +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 NO_SHEETS: readonly WorkspaceSheet[] = []; + +function PanelIcon({ + icon: Icon, + size, + color, +}: { icon: ComponentType } & PanelIconProps) { + return ; +} + +const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +// The workspace sheet is a Gorhom sheet rendered in place rather than a portaled modal, so the +// backdrop closes the sheet itself instead of dismissing the top of the modal stack. +function renderBackdrop(props: BottomSheetBackdropProps) { + return ( + + ); +} + +// Stacked sheets share the bottom sheet's dim rather than darkening it again, but still catch the +// tap so it closes the top sheet. The backdrop is clear rather than transparent: iOS skips views +// under 0.01 opacity when hit testing, which would hand the tap to the sheet below. +function renderStackedBackdrop(props: BottomSheetBackdropProps) { + return ( + + ); +} + +interface WorkspaceSheetHandleValue { + header: SheetHeader; + onClose: () => void; +} + +const WorkspaceSheetHandleContext = createContext(null); + +// Gorhom renders `handleComponent` as a component type, so it stays one module-level component and +// reads the header from context; a per-render function would remount the header on every change. +function WorkspaceSheetHandle(props: BottomSheetHandleProps) { + const value = useContext(WorkspaceSheetHandleContext); + if (!value) throw new Error("WorkspaceSheetHandle must render inside a workspace sheet"); + 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; + /** Only the bottom sheet dims the workspace; the ones above share its dim. */ + isBottom: boolean; + /** Only the top sheet answers Android Back; the ones below wait their turn. */ + isTop: boolean; + onClose: (tabId: string) => Promise; + onOpenAsTab: (tabId: string) => void; +} + +/** + * Hosts the workspace's sheets over the compact tab strip, one Gorhom sheet per entry, top last. + * They render in the workspace tree, not through a portal, so each panel keeps every context an + * ordinary pane has. The shared sheet parts (`SheetBackground`, `SheetHandle`, `SheetHeaderView`) + * keep them looking and dragging like every other sheet. + */ +export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) { + const sheets = useWorkspaceLayoutStore((state) => + workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, + ); + const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); + const handleOpenAsTab = useCallback( + (tabId: string) => { + if (workspaceKey) openSheetAsTab(workspaceKey, tabId); + }, + [openSheetAsTab, workspaceKey], + ); + if (sheets.length === 0) return null; + return ( + + {sheets.map((sheet, index) => ( + + ))} + + ); +} + +function WorkspaceSheetPresentation({ + sheet, + isBottom, + isTop, + serverId, + workspaceId, + isWorkspaceFocused, + buildContent, + onClose, + onOpenAsTab, +}: WorkspaceSheetPresentationProps) { + const { t } = useTranslation(); + const { tab } = sheet; + const sheetRef = useRef(null); + const isScreenFocused = useIsFocused(); + ensurePanelsRegistered(); + const registration = getPanelRegistration(tab.target.kind); + if (!registration) { + throw new Error(`No panel registration for kind: ${tab.target.kind}`); + } + const descriptor = registration.useDescriptor(tab.target as never, { + serverId, + workspaceId, + tabId: tab.tabId, + }); + + const handleClose = useCallback(() => onClose(tab.tabId), [onClose, tab.tabId]); + 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 handleClose().then(() => sheetRef.current?.snapToIndex(0)); + }, + [handleClose], + ); + // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back. + const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused; + useEffect(() => { + if (!ownsBackPress) return; + return systemBackPress.subscribe(() => { + void handleClose(); + return true; + }); + }, [handleClose, ownsBackPress]); + + const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); + const header = useMemo( + () => ({ + title: descriptor.label, + leading: , + actions: sheet.policy.openAsTab ? ( + + ) : undefined, + }), + [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], + ); + const handleHeaderClose = useCallback(() => { + void handleClose(); + }, [handleClose]); + const handleValue = useMemo( + () => ({ header, onClose: handleHeaderClose }), + [handleHeaderClose, header], + ); + + return ( + // Gorhom renders the handle beside the content, so the dialog wraps the whole sheet. + + {/* The header is the sheet's handle: the panel scrolls on its own, so dragging the content + would fight it, and the header is where people reach to pull the sheet down. */} + + + {/* A covered sheet keeps its state but stops its live work until it is on top again. */} + + + + + + + ); +} + +function WorkspaceSheetBody({ + tab, + isPaneFocused, + isWorkspaceFocused, + buildContent, +}: { + tab: WorkspaceSheet["tab"]; + /** Only the top sheet's pane is focused; the ones below are covered. */ + isPaneFocused: boolean; + isWorkspaceFocused: boolean; + buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; +}) { + const tabDescriptor = useMemo( + () => ({ + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + state: tab.state, + }), + [tab], + ); + const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]); + // The sheet reaches the bottom of the screen, so it owns the system navigation inset. + const { bottom: bottomInset } = useSafeAreaInsets(); + const contentStyle = useMemo( + () => [styles.content, { paddingBottom: bottomInset }], + [bottomInset], + ); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + host: { + ...StyleSheet.absoluteFillObject, + }, + clearBackdrop: { + backgroundColor: "transparent", + }, + sheetLayer: { + ...StyleSheet.absoluteFillObject, + }, + content: { + flex: 1, + minHeight: 0, + }, +}); diff --git a/packages/app/src/stores/download-store.test.ts b/packages/app/src/stores/download-store.test.ts new file mode 100644 index 0000000000..d3657b4c34 --- /dev/null +++ b/packages/app/src/stores/download-store.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { defaultHostAppearance } from "@/hosts/appearance"; +import { i18n } from "@/i18n/i18next"; +import { + MAX_SESSION_DOWNLOAD_BYTES, + useDownloadStore, + type Download, + type DownloadedBytes, +} from "@/stores/download-store"; +import type { + DirectTcpHostConnection, + HostProfile, + RelayHostConnection, +} from "@/types/host-connection"; + +const FILE_BYTES = new TextEncoder().encode("relay download payload"); +const FILE_PATH = "reports/données été.bin"; +const FILE_NAME = "données été.bin"; +const MIME_TYPE = "application/octet-stream"; + +const relayConnection: RelayHostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "cHVibGljLWtleQ==", +}; + +// The #3753 shape: the relay hostname stored as a directTcp entry that nothing can reach. +const staleTcpConnection: DirectTcpHostConnection = { + id: "direct:relay.paseo.sh:443", + type: "directTcp", + endpoint: "relay.paseo.sh:443", +}; + +const localTcpConnection: DirectTcpHostConnection = { + id: "direct:localhost:6767", + type: "directTcp", + endpoint: "localhost:6767", +}; + +function profileWith(connections: HostProfile["connections"]): HostProfile { + return { + serverId: "srv_remote", + label: "Remote", + appearance: defaultHostAppearance(), + lifecycle: {}, + connections, + preferredConnectionId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function createTokenRequest(size: number | null = FILE_BYTES.byteLength) { + const requested: string[] = []; + return { + requested, + request: async (path: string) => { + requested.push(path); + return { token: "tok", fileName: FILE_NAME, mimeType: MIME_TYPE, size, error: null }; + }, + }; +} + +function createFileReader(read: () => Promise<{ bytes: Uint8Array; size: number }>) { + const requested: string[] = []; + return { + requested, + read: async (path: string) => { + requested.push(path); + return read(); + }, + }; +} + +function createFileSaver() { + const savedFiles: DownloadedBytes[] = []; + return { + savedFiles, + save: async (file: DownloadedBytes) => { + savedFiles.push(file); + return null; + }, + }; +} + +interface StartInput { + profile: HostProfile; + activeConnectionId: string | null; + token?: ReturnType; + reader?: ReturnType; + saver?: ReturnType; +} + +async function startDownload(input: StartInput): Promise { + const token = input.token ?? createTokenRequest(); + const reader = + input.reader ?? + createFileReader(async () => ({ bytes: FILE_BYTES, size: FILE_BYTES.byteLength })); + const saver = input.saver ?? createFileSaver(); + await useDownloadStore.getState().startDownload({ + serverId: "srv_remote", + scopeId: "workspace-1", + fileName: FILE_NAME, + path: FILE_PATH, + daemonProfile: input.profile, + activeConnectionId: input.activeConnectionId, + requestFileDownloadToken: token.request, + readFile: reader.read, + saveFile: saver.save, + }); + const { activeDownloadId, downloads } = useDownloadStore.getState(); + return activeDownloadId ? downloads.get(activeDownloadId) : undefined; +} + +describe("download store transport selection", () => { + beforeEach(() => { + useDownloadStore.setState({ downloads: new Map(), activeDownloadId: null }); + }); + + it("downloads over the session when the active connection is the relay", async () => { + const token = createTokenRequest(); + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + token, + reader, + saver, + }); + + expect(download).toMatchObject({ fileName: FILE_NAME, status: "complete" }); + expect(token.requested).toEqual([FILE_PATH]); + expect(reader.requested).toEqual([FILE_PATH]); + expect(saver.savedFiles).toEqual([ + { bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE }, + ]); + }); + + it("bounds the session read even when the token reports a smaller file", async () => { + const readRequests: Array<{ path: string; maxBytes: number }> = []; + const saver = createFileSaver(); + await useDownloadStore.getState().startDownload({ + serverId: "srv_remote", + scopeId: "workspace-1", + fileName: FILE_NAME, + path: FILE_PATH, + daemonProfile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + requestFileDownloadToken: createTokenRequest(1).request, + readFile: async (path: string, maxBytes: number) => { + readRequests.push({ path, maxBytes }); + return { bytes: FILE_BYTES, size: FILE_BYTES.byteLength }; + }, + saveFile: saver.save, + }); + expect(readRequests).toEqual([{ path: FILE_PATH, maxBytes: MAX_SESSION_DOWNLOAD_BYTES }]); + expect(saver.savedFiles).toEqual([ + { bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE }, + ]); + }); + + it("ignores a directTcp entry that is not the active connection", async () => { + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([staleTcpConnection, relayConnection]), + activeConnectionId: relayConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ status: "complete" }); + expect(reader.requested).toEqual([FILE_PATH]); + expect(saver.savedFiles).toHaveLength(1); + }); + + it("keeps the HTTP token download when the active connection is directTcp", async () => { + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([localTcpConnection, relayConnection]), + activeConnectionId: localTcpConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ status: "complete" }); + expect(reader.requested).toEqual([]); + expect(saver.savedFiles).toEqual([]); + }); + + it("fails the download and saves nothing when the session read fails", async () => { + const reader = createFileReader(async () => { + throw new Error("Connection lost"); + }); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ status: "error", message: "Connection lost" }); + expect(saver.savedFiles).toEqual([]); + }); + + it("refuses a file over the session size limit before reading it", async () => { + const token = createTokenRequest(MAX_SESSION_DOWNLOAD_BYTES + 1); + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + token, + reader, + saver, + }); + + expect(download).toMatchObject({ + status: "error", + message: i18n.t("composer.errors.fileTooLarge", { fileName: FILE_NAME, size: "64 MB" }), + }); + expect(download?.message).toContain("64 MB"); + expect(reader.requested).toEqual([]); + expect(saver.savedFiles).toEqual([]); + }); + + it("fails instead of saving an empty file when the daemon returns no content", async () => { + const reader = createFileReader(async () => ({ bytes: new Uint8Array(), size: 6 })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ + status: "error", + message: "File transfer incomplete: expected 6 bytes, received 0.", + }); + expect(saver.savedFiles).toEqual([]); + }); +}); diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index 6d13b3ed3c..790fbd7dee 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -16,6 +16,16 @@ interface DownloadProgress { eta: number; } +// The session path holds the file in memory two to three times over (client chunks, the +// concatenated result, then the Blob or native write). Lift once the client streams chunks to disk. +export const MAX_SESSION_DOWNLOAD_BYTES = 64 * 1024 * 1024; + +export interface DownloadedBytes { + bytes: Uint8Array; + fileName: string; + mimeType: string | null; +} + export interface Download { id: string; serverId: string; @@ -37,12 +47,18 @@ interface DownloadState { fileName: string; path: string; daemonProfile: HostProfile | undefined; + /** Connection the host runtime is using right now; `null` while disconnected. */ + activeConnectionId: string | null; requestFileDownloadToken: (path: string) => Promise<{ token: string | null; fileName: string | null; mimeType: string | null; + size: number | null; error: string | null; }>; + /** Reads the whole file over the session socket; used when no direct HTTP endpoint is active. */ + readFile: (path: string, maxBytes: number) => Promise<{ bytes: Uint8Array; size: number }>; + saveFile?: typeof saveDownloadedBytes; }) => Promise; updateProgress: (id: string, progress: DownloadProgress) => void; @@ -66,7 +82,10 @@ export const useDownloadStore = create()((set, get) => ({ fileName, path, daemonProfile, + activeConnectionId, requestFileDownloadToken, + readFile, + saveFile = saveDownloadedBytes, }) => { const id = generateDownloadId(); const download: Download = { @@ -89,12 +108,42 @@ export const useDownloadStore = create()((set, get) => ({ throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed")); } - const downloadTarget = resolveDaemonDownloadTarget(daemonProfile); + const resolvedFileName = tokenResponse.fileName ?? fileName; + const downloadTarget = resolveDaemonDownloadTarget(daemonProfile, activeConnectionId); + if (!downloadTarget) { + // Relay, SSH, socket and pipe hosts have no HTTP endpoint: read the file over the + // session socket that is already connected (#543). + if ( + typeof tokenResponse.size === "number" && + tokenResponse.size > MAX_SESSION_DOWNLOAD_BYTES + ) { + throw new Error( + i18n.t("composer.errors.fileTooLarge", { fileName: resolvedFileName, size: "64 MB" }), + ); + } + const { bytes, size } = await readFile(path, MAX_SESSION_DOWNLOAD_BYTES); + // A daemon without a binary channel answers `encoding: "none"` for a binary file: no + // content, real size. Never save that as a complete download. + if (bytes.byteLength !== size) { + throw new Error( + `File transfer incomplete: expected ${size} bytes, received ${bytes.byteLength}.`, + ); + } + const savedUri = await saveFile({ + bytes, + fileName: resolvedFileName, + mimeType: tokenResponse.mimeType, + }); + get().completeDownload(id); + if (savedUri !== null) { + await shareSavedFile(savedUri, tokenResponse.mimeType, resolvedFileName); + } + return; + } if (!downloadTarget.baseUrl) { throw new Error(i18n.t("downloads.hostUnavailable")); } - const resolvedFileName = tokenResponse.fileName ?? fileName; const downloadUrl = buildDownloadUrl( downloadTarget.baseUrl, tokenResponse.token, @@ -146,14 +195,7 @@ export const useDownloadStore = create()((set, get) => ({ get().completeDownload(id); - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(result.uri, { - mimeType: tokenResponse.mimeType ?? undefined, - dialogTitle: resolvedFileName - ? i18n.t("downloads.shareFileNamed", { fileName: resolvedFileName }) - : i18n.t("downloads.shareFile"), - }); - } + await shareSavedFile(result.uri, tokenResponse.mimeType, resolvedFileName); } catch (error) { const message = error instanceof Error ? error.message : i18n.t("downloads.failed"); if (isWeb) { @@ -244,10 +286,18 @@ interface DownloadTarget { authCredentials: { username: string; password: string } | null; } -function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget { - const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null; - if (!connection) { - return { baseUrl: null, authHeader: null, authCredentials: null }; +/** + * The HTTP download route is only reachable over a direct TCP connection. Returns `null` when + * the connection in use is anything else (relay, SSH, socket, pipe, or none yet), so the caller + * reads the file over the session socket instead. + */ +function resolveDaemonDownloadTarget( + daemon: HostProfile | undefined, + activeConnectionId: string | null, +): DownloadTarget | null { + const connection = daemon?.connections.find((conn) => conn.id === activeConnectionId); + if (connection?.type !== "directTcp") { + return null; } let parsed: URL; @@ -299,6 +349,47 @@ function buildDownloadUrl( return url.toString(); } +/** + * Saves bytes read over the session. Web and Electron hand them to the browser's download + * manager and return `null`; native writes the file and returns its URI for the share sheet. + */ +async function saveDownloadedBytes({ + bytes, + fileName, + mimeType, +}: DownloadedBytes): Promise { + if (isWeb) { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const url = URL.createObjectURL( + new Blob([buffer], { type: mimeType ?? "application/octet-stream" }), + ); + triggerBrowserDownload(url, fileName); + // The browser may still be reading the blob while its save prompt is open. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return null; + } + // Resolve and write in one tick so two downloads of the same name cannot pick the same path. + const targetFile = resolveDownloadTargetFile(fileName); + targetFile.write(bytes); + return targetFile.uri; +} + +async function shareSavedFile( + uri: string, + mimeType: string | null, + fileName: string, +): Promise { + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(uri, { + mimeType: mimeType ?? undefined, + dialogTitle: fileName + ? i18n.t("downloads.shareFileNamed", { fileName }) + : i18n.t("downloads.shareFile"), + }); + } +} + function triggerBrowserDownload(url: string, fileName: string) { if (typeof document === "undefined") { if (typeof window !== "undefined") { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 0d59b1c6b5..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..fc973f442d 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,675 @@ it("persists the once-only PR add after closing, and clears it when purging the expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBeUndefined(); expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBe("pull_request"); }); + +describe("compact workspace sheet", () => { + const workspaceKey = "server-1:sheet-ws"; + const fileTarget = { kind: "file" as const, path: "src/index.ts" }; + + function createCompactStore() { + const store = createWorkspaceLayoutStore(workspaceLayoutIds); + store.setState({ layoutByWorkspace: {}, sheetsByWorkspace: {} }); + store.getState().setLayoutMode("compact"); + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + return store; + } + + function topSheet(store: ReturnType) { + return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1); + } + + function sheetTargets(store: ReturnType) { + return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map( + (sheet) => sheet.tab.target, + ); + } + + function tabKinds(store: ReturnType): string[] { + const layout = store.getState().layoutByWorkspace[workspaceKey]; + const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab])); + return (findPaneById(layout.root, "main")?.tabIds ?? []).map( + (tabId) => tabsById.get(tabId)?.target.kind ?? "missing", + ); + } + + function seedAgents(agents: Array>): void { + const timestamp = new Date("2026-04-21T10:00:00.000Z"); + useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); + useSessionStore.getState().setAgents( + "server-1", + new Map( + agents.map((agent) => [ + agent.id, + { + serverId: "server-1", + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: timestamp, + updatedAt: timestamp, + lastUserMessageAt: null, + lastActivityAt: timestamp, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: undefined, + lastUsage: undefined, + lastError: null, + title: agent.id, + cwd: "/repo", + model: null, + features: undefined, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + labels: {}, + projectPlacement: null, + ...agent, + } satisfies Agent, + ]), + ), + ); + } + + it("reveals a file in the sheet and leaves the tab strip unchanged", () => { + const store = createCompactStore(); + const before = tabKinds(store); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(topSheet(store)?.tab).toEqual({ + tabId, + target: fileTarget, + createdAt: expect.any(Number), + }); + expect(tabKinds(store)).toEqual(before); + }); + + it("focuses a target that is already open as a tab instead of using the sheet", () => { + const store = createCompactStore(); + store.getState().setLayoutMode("split-panes"); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + store.getState().setLayoutMode("compact"); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(tabId).toBe(fileTabId); + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId); + }); + + it("stacks the next sheet target over the open sheet", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(tabKinds(store)).toEqual(["terminal"]); + + store.getState().closeTab(workspaceKey, nextTabId ?? ""); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("closes a lower sheet and keeps the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + store.getState().closeTab(workspaceKey, fileTabId ?? ""); + + expect(sheetTargets(store)).toEqual([nextTarget]); + expect(tabKinds(store)).toEqual(["terminal"]); + }); + + it("brings a stacked target back to the top by dropping the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(tabId).toBe(fileTabId); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("opens the top sheet as a tab and dismisses the sheets that would cover it", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + store.getState().openSheetAsTab(workspaceKey, nextTabId ?? ""); + + expect(sheetTargets(store)).toEqual([]); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(nextTabId); + }); + + it("closes the sheet when a tab target is revealed, and keeps it for background opens", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + store.getState().openTab({ + workspaceKey, + target: { kind: "file", path: "src/background.ts" }, + intent: "background", + }); + expect(topSheet(store)?.tab.target).toEqual(fileTarget); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + expect(topSheet(store)).toBeUndefined(); + }); + + it("moves the sheet tab into the focused pane with the same tab id and state", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + if (!sheetTabId) throw new Error("Expected a sheet tab"); + store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 }); + + const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId); + + expect(tabId).toBe(sheetTabId); + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + const pane = findPaneContainingTab(layout.root, sheetTabId); + expect(pane?.id).toBe(layout.focusedPaneId); + expect(pane?.focusedTabId).toBe(sheetTabId); + expect(collectAllTabs(layout.root).find((tab) => tab.tabId === sheetTabId)?.state).toEqual({ + scrollTop: 120, + }); + }); + + it("closes the sheet through the ordinary close and ignores focus requests for it", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + if (!sheetTabId) throw new Error("Expected a sheet tab"); + const layoutBefore = store.getState().layoutByWorkspace[workspaceKey]; + + store.getState().focusTab(workspaceKey, sheetTabId); + expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore); + + store.getState().closeTab(workspaceKey, sheetTabId); + expect(topSheet(store)).toBeUndefined(); + expect(tabKinds(store)).toEqual(["terminal"]); + }); + + it("suspends pane focus while the sheet covers the panes", () => { + const store = createCompactStore(); + expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false); + + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(true); + + store.getState().closeTab(workspaceKey, sheetTabId ?? ""); + expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false); + }); + + it("never uses the sheet on wide layouts", () => { + const store = createCompactStore(); + store.getState().setLayoutMode("split-panes"); + + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(store.getState().sheetsByWorkspace).toEqual({}); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + }); + + it("keeps every stacked sheet as a tab when the layout widens, focusing the top one", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + store.getState().setLayoutMode("split-panes"); + + expect(store.getState().sheetsByWorkspace).toEqual({}); + expect(tabKinds(store)).toEqual(["terminal", "file", "file"]); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId); + }); + + it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + { id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" }, + ]); + const store = createCompactStore(); + + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + expect(topSheet(store)).toMatchObject({ + tab: { target: { kind: "agent", agentId: "child" } }, + policy: { openAsTab: true }, + }); + + store.getState().openTab({ + workspaceKey, + target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" }, + intent: "reveal", + }); + expect(topSheet(store)).toMatchObject({ + tab: { target: { kind: "provider_subagent", subagentId: "task-1" } }, + policy: { openAsTab: false }, + }); + + for (const agentId of ["root", "other-workspace-child"]) { + store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" }); + } + expect(topSheet(store)).toBeUndefined(); + expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); + }); + + it("gives a sheet target opened from inside the sheet the sheet's own parent", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const rootTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" }); + const childSheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + parentTabId: rootTabId ?? undefined, + }); + + const fileSheetTabId = store.getState().openTab({ + workspaceKey, + target: fileTarget, + intent: "reveal", + parentTabId: childSheetTabId ?? undefined, + }); + expect(topSheet(store)?.parentTabId).toBe(rootTabId); + + store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? ""); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId); + }); + + it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + { id: "second-root", parentAgentId: null, workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const rootTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" }); + const openChildSheet = () => + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + parentTabId: rootTabId ?? undefined, + }) ?? undefined; + + const terminalTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + parentTabId: openChildSheet(), + }); + expect(topSheet(store)).toBeUndefined(); + let layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId); + + const secondRootTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "second-root" }, + intent: "reveal", + parentTabId: openChildSheet(), + }); + expect(topSheet(store)).toBeUndefined(); + layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId); + expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId); + expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); + }); + + describe("unsaved sheet work", () => { + const sheetPanel = (tabId: string) => ({ + serverId: "server-1", + workspaceId: "sheet-ws", + tabId, + }); + let modifiedTabId: string | null = null; + + function openModifiedFileSheet(store: ReturnType): string { + const tabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + if (!tabId) throw new Error("Expected a sheet tab"); + store.getState().setTabState(workspaceKey, tabId, { scrollTop: 40 }); + setPanelInstanceAttributes(sheetPanel(tabId), { modified: true }); + modifiedTabId = tabId; + return tabId; + } + + function stripTab(store: ReturnType, tabId: string) { + const layout = store.getState().layoutByWorkspace[workspaceKey]; + return collectAllTabs(layout.root).find((tab) => tab.tabId === tabId) ?? null; + } + + function focusedTabId(store: ReturnType): string | null { + const layout = store.getState().layoutByWorkspace[workspaceKey]; + return findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null; + } + + afterEach(() => { + if (modifiedTabId) setPanelInstanceAttributes(sheetPanel(modifiedTabId), { modified: false }); + modifiedTabId = null; + }); + + function sheetTabId(store: ReturnType): string | null { + return topSheet(store)?.tab.tabId ?? null; + } + + it("stacks the next sheet target over a modified sheet", () => { + const store = createCompactStore(); + openModifiedFileSheet(store); + + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + expect(sheetTabId(store)).toBe(nextTabId); + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.state).toEqual({ + scrollTop: 40, + }); + }); + + it("brings a covered sheet back to the top and keeps a modified sheet above it beneath", () => { + const store = createCompactStore(); + const otherTarget = { kind: "file" as const, path: "src/other.ts" }; + const otherTabId = store + .getState() + .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); + const modifiedSheetTabId = openModifiedFileSheet(store); + const focusedBefore = focusedTabId(store); + + const tabId = store + .getState() + .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); + + expect(tabId).toBe(otherTabId); + expect(sheetTargets(store)).toEqual([fileTarget, otherTarget]); + expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.tabId).toBe( + modifiedSheetTabId, + ); + expect(stripTab(store, tabId ?? "")).toBeNull(); + expect(focusedTabId(store)).toBe(focusedBefore); + }); + + it("opens a tab target under a modified sheet without closing it", () => { + const store = createCompactStore(); + const modifiedSheetTabId = openModifiedFileSheet(store); + + const terminalTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(focusedTabId(store)).toBe(terminalTabId); + }); + + it("keeps a modified sheet open when another tab is chosen", () => { + const store = createCompactStore(); + const secondTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? ""; + const modifiedSheetTabId = openModifiedFileSheet(store); + + store.getState().focusTab(workspaceKey, firstTabId); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(focusedTabId(store)).toBe(firstTabId); + + store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? ""); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(focusedTabId(store)).toBe(secondTabId); + + store.getState().closeTab(workspaceKey, modifiedSheetTabId); + expect(sheetTabId(store)).toBeNull(); + expect(tabKinds(store)).toEqual(["terminal", "terminal"]); + }); + + it("closes an unmodified sheet when another tab is chosen", () => { + const store = createCompactStore(); + const secondTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? ""; + + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().focusTab(workspaceKey, firstTabId); + expect(sheetTabId(store)).toBeNull(); + expect(focusedTabId(store)).toBe(firstTabId); + + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? ""); + expect(sheetTabId(store)).toBeNull(); + expect(focusedTabId(store)).toBe(secondTabId); + expect(tabKinds(store)).toEqual(["terminal", "terminal"]); + }); + }); + + it("closes the sheet when its agent leaves the workspace", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + const snapshot = { + terminalsHydrated: false, + activeAgentIds: ["root"], + autoOpenAgentIds: [], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }; + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false }); + expect(topSheet(store)).toBeDefined(); + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true }); + expect(topSheet(store)).toBeUndefined(); + }); + + it("drops a departed agent's sheet from the middle of the stack", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + store.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: false, + activeAgentIds: ["root"], + autoOpenAgentIds: [], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + }); + + it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const sheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + const snapshot = { + agentsHydrated: true, + terminalsHydrated: false, + activeAgentIds: ["root", "child"], + autoOpenAgentIds: ["child"], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }; + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] }); + expect(topSheet(store)?.tab.tabId).toBe(sheetTabId); + + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: null, workspaceId: "sheet-ws" }, + ]); + store.getState().reconcileTabs(workspaceKey, snapshot); + + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect( + collectAllTabs(layout.root).filter( + (tab) => tab.target.kind === "agent" && tab.target.agentId === "child", + ), + ).toHaveLength(1); + }); + + it("re-resolves the sheet policy when the sheet tab gets another target", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const sheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" }, + intent: "reveal", + }); + + const replacedTabId = store + .getState() + .replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" }); + + expect(replacedTabId).toBe(sheetTabId); + expect(topSheet(store)).toMatchObject({ + tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } }, + policy: { openAsTab: true }, + }); + }); + + it("moves the sheet tab into the tab strip when it gets a target the sheet does not host", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + const replacedTabId = store.getState().replaceTab(workspaceKey, sheetTabId ?? "", { + kind: "terminal", + terminalId: "term-2", + }); + + expect(topSheet(store)).toBeUndefined(); + expect(replacedTabId).toBe(sheetTabId); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId); + expect(tabKinds(store)).toEqual(["terminal", "terminal"]); + }); + + it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => { + const store = createCompactStore(); + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const received: string[][] = []; + const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store); + + const sheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + store.getState().closeTab(workspaceKey, sheetTabId ?? ""); + stop(); + + expect(received).toEqual([[], ["child"], []]); + }); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 5de128b631..ba186b655a 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,27 @@ 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; + /** + * Each workspace's open sheets, bottom to top; only the top one is on screen. A workspace with + * no sheet has no entry. Not persisted: a reload returns to the tab strip. + */ + sheetsByWorkspace: Record; layoutByWorkspace: Record; splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; @@ -116,7 +141,18 @@ interface WorkspaceLayoutStore { sidePaneIdByWorkspace: Record; /** Workspaces where PR detection already added its tab once; a closed tab never returns. */ pullRequestTabAutoOpenedByWorkspace: Record; + /** + * The one entry point for showing a target. On compact layouts a target whose panel declares + * `compactSheet` opens in 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; + /** Moves a sheet's tab, keeping its tab id, into the focused pane. */ + openSheetAsTab: (workspaceKey: string, tabId: string) => string | null; /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ autoOpenPullRequestTab: ( workspaceKey: string, @@ -472,6 +508,17 @@ export function selectExplorerSidebarPaneId( return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]); } +/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */ +export function selectPaneFocusSuspended( + state: Pick, + workspaceKey: string, +): boolean { + return ( + Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) || + workspaceKey in state.sheetsByWorkspace + ); +} + /** Whether the Explorer sidebar pane is currently on screen. */ export function selectIsExplorerSidebarVisible( state: ExplorerSidebarState, @@ -637,6 +684,245 @@ function attachParentTab(input: { }); } +const NO_SHEETS: readonly WorkspaceSheet[] = []; + +function getSheets( + state: Pick, + workspaceKey: string, +): readonly WorkspaceSheet[] { + return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS; +} + +function findSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): WorkspaceSheet | undefined { + return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId); +} + +function withSheets( + state: Pick, + workspaceKey: string, + sheets: readonly WorkspaceSheet[], +): Pick { + if (sheets.length > 0) { + return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } }; + } + if (!(workspaceKey in state.sheetsByWorkspace)) { + return { sheetsByWorkspace: state.sheetsByWorkspace }; + } + const { [workspaceKey]: _removed, ...sheetsByWorkspace } = state.sheetsByWorkspace; + return { sheetsByWorkspace }; +} + +function withoutSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheet.tab.tabId !== tabId), + ); +} + +function withSheetTab( + state: Pick, + workspaceKey: string, + tabId: string, + update: (sheet: WorkspaceSheet) => WorkspaceSheet, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).map((sheet) => + sheet.tab.tabId === tabId ? update(sheet) : sheet, + ), + ); +} + +function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) { + if (!layout) return false; + const resourceKey = panelResourceKey(target); + return collectAllTabs(layout.root).some((tab) => panelResourceKey(tab.target) === resourceKey); +} + +function resolveSheetPolicy( + state: Pick, + workspaceKey: string, + target: WorkspaceTabTarget, + intent: WorkspaceTabOpenIntent, +): CompactSheetPolicy | null { + if (state.layoutMode !== "compact" || intent === "background") return null; + if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) { + return null; + } + return resolveCompactSheetPolicy(workspaceKey, target); +} + +/** + * The parent a newly opened tab links to. A sheet tab is not in the layout, so a target opened + * from inside a sheet inherits that sheet's own parent instead. + */ +function resolveOpenParentTabId( + state: Pick, + workspaceKey: string, + parentTabId: string | undefined, +): string | null { + if (!parentTabId) return null; + return findSheet(state, workspaceKey, parentTabId)?.parentTabId ?? parentTabId; +} + +function placeTargetInSheet(input: { + /** The sheet whose tab the target keeps, if any. */ + kept: 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 one sheet's tab into the workspace's focused pane, keeping its tab id and state. The + * other sheets stay; callers that bring the tab to the front dismiss them. + */ +function promoteSheetToTab( + state: WorkspaceLayoutStore, + workspaceKey: string, + tabId: string, +): { tabId: string; patch: Partial } | null { + const sheet = findSheet(state, workspaceKey, tabId); + if (!sheet) return null; + const { tab } = sheet; + const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT); + const input = { + ...placement, + target: tab.target, + now: tab.createdAt, + createTabId: () => tab.tabId, + state: tab.state, + }; + const result = isTargetOpenAsTab(placement.layout, tab.target) + ? revealTargetInLayout(input) + : createTabInLayout(input); + if (!result) return null; + const nextLayout = attachParentTab({ + layout: keepWorkspaceFocusOutOfExplorerSidebar( + result.layout, + placement.explorerSidebarPaneId, + placement.layout.focusedPaneId, + ), + childTabId: result.tabId, + parentTabId: sheet.parentTabId, + }); + return { + tabId: result.tabId, + patch: { + ...withoutSheet(state, workspaceKey, tab.tabId), + ...withoutFocusRestoration(state, workspaceKey), + hiddenAgentIdsByWorkspace: + tab.target.kind === "agent" + ? removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + workspaceKey, + tab.target.agentId, + ) + : state.hiddenAgentIdsByWorkspace, + layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: nextLayout }, + }, + }; +} + +/** + * A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the + * panel and drop the work. Only the explicit, guarded close removes it. + */ +function sheetHoldsUnsavedWork(workspaceKey: string, sheet: WorkspaceSheet): boolean { + return isSheetTabModified(workspaceKey, sheet.tab.tabId); +} + +/** + * Where an open lands in the sheet stack, or null when it opens as a tab. A new target goes on + * top. A reveal of a resource a sheet already shows keeps that sheet's tab and brings it back to + * the top; the sheets above it close, except ones holding unsaved work, which stay beneath it. + */ +function resolveSheetPlacement( + state: WorkspaceLayoutStore, + workspaceKey: string, + target: WorkspaceTabTarget, + intent: WorkspaceTabOpenIntent, +): { policy: CompactSheetPolicy; keptIndex: number | null } | null { + const policy = resolveSheetPolicy(state, workspaceKey, target, intent); + if (!policy) return null; + if (intent !== "reveal") return { policy, keptIndex: null }; + const sheets = getSheets(state, workspaceKey); + const resourceKey = panelResourceKey(target); + const index = sheets.findIndex((sheet) => panelResourceKey(sheet.tab.target) === resourceKey); + return { policy, keptIndex: index === -1 ? null : index }; +} + +/** Closes the sheets because navigation moved on underneath them, except ones with unsaved work. */ +function dismissSheets( + state: Pick, + workspaceKey: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheetHoldsUnsavedWork(workspaceKey, sheet)), + ); +} + +/** Moves a sheet's tab to the front of the tab strip, dismissing the sheets that would cover it. */ +function openSheetTabInFront( + state: WorkspaceLayoutStore, + workspaceKey: string, + tabId: string, +): { tabId: string; patch: Partial } | null { + const promoted = promoteSheetToTab(state, workspaceKey, tabId); + if (!promoted) return null; + const next = { ...state, ...promoted.patch }; + return { + tabId: promoted.tabId, + patch: { ...promoted.patch, ...dismissSheets(next, workspaceKey) }, + }; +} + +/** + * A sheet agent closes once the workspace no longer has it (archived or gone), like its tab would, + * and once it stops being a subagent: as a root agent, reconcile gives it its own tab, and the sheet + * would hold a second tab with the same id. + */ +function sheetAgentNoLongerFits( + workspaceKey: string, + sheet: WorkspaceSheet, + 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 +962,8 @@ export function createWorkspaceLayoutStore( } return tabId; }, + layoutMode: "split-panes", + sheetsByWorkspace: {}, layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, @@ -691,6 +979,40 @@ export function createWorkspaceLayoutStore( if (!normalizedWorkspaceKey || !normalizedTarget) { return null; } + const parentTabId = resolveOpenParentTabId( + get(), + normalizedWorkspaceKey, + input.parentTabId, + ); + const sheetPlacement = resolveSheetPlacement( + get(), + normalizedWorkspaceKey, + normalizedTarget, + input.intent, + ); + if (sheetPlacement) { + const sheets = getSheets(get(), normalizedWorkspaceKey); + const { keptIndex } = sheetPlacement; + const sheet = placeTargetInSheet({ + kept: keptIndex === null ? undefined : sheets[keptIndex], + target: normalizedTarget, + intent: input.intent, + policy: sheetPlacement.policy, + parentTabId, + state: input.state, + }); + const below = + keptIndex === null + ? sheets + : [ + ...sheets.slice(0, keptIndex), + ...sheets + .slice(keptIndex + 1) + .filter((above) => sheetHoldsUnsavedWork(normalizedWorkspaceKey, above)), + ]; + set((state) => withSheets(state, normalizedWorkspaceKey, [...below, sheet])); + return sheet.tab.tabId; + } const placement = getOpenTabPlacement( get(), normalizedWorkspaceKey, @@ -734,6 +1056,8 @@ export function createWorkspaceLayoutStore( const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent"; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + // A tab brought to the front dismisses the sheet over it. + ...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace @@ -751,17 +1075,53 @@ export function createWorkspaceLayoutStore( : state.pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: input.parentTabId + [normalizedWorkspaceKey]: parentTabId ? attachParentTab({ layout: nextLayout, childTabId: result.tabId, - parentTabId: input.parentTabId, + parentTabId, }) : nextLayout, }, })); return result.tabId; }, + opensInSheet: (input) => { + const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey); + const normalizedTarget = normalizeWorkspaceTabTarget(input.target); + if (!normalizedWorkspaceKey || !normalizedTarget) return false; + return ( + resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !== + null + ); + }, + setLayoutMode: (mode) => { + set((state) => { + if (state.layoutMode === mode) return state; + if (mode === "compact") return { layoutMode: mode }; + // Widening keeps whatever the user had open in sheets as ordinary tabs. + const next: WorkspaceLayoutStore = { ...state, layoutMode: mode }; + for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) { + for (const sheet of sheets) { + const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId); + Object.assign( + next, + promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId), + ); + } + } + return next; + }); + }, + openSheetAsTab: (workspaceKey, tabId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + if (!normalizedWorkspaceKey || !normalizedTabId) return null; + const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId); + if (!promoted) return null; + set(promoted.patch); + return promoted.tabId; + }, showExplorerSidebar: (workspaceKey) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -881,6 +1241,9 @@ export function createWorkspaceLayoutStore( } set((state) => { + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withoutSheet(state, normalizedWorkspaceKey, normalizedTabId); + } const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); const explorerSidebarPaneId = resolveExplorerSidebarPaneId( layout, @@ -988,6 +1351,8 @@ export function createWorkspaceLayoutStore( return { ...withoutFocusRestoration(state, normalizedWorkspaceKey), + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1011,6 +1376,8 @@ export function createWorkspaceLayoutStore( }); return nextLayout ? { + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1024,6 +1391,29 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null; + if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) { + const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget); + const next: WorkspaceLayoutStore = { + ...get(), + ...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({ + ...sheet, + policy: policy ?? sheet.policy, + tab: { + ...sheet.tab, + target: normalizedTarget, + ...(tabState !== undefined ? { state: tabState } : {}), + }, + })), + }; + if (policy) { + set({ sheetsByWorkspace: next.sheetsByWorkspace }); + return normalizedTabId; + } + // A target sheets do not host becomes the tab it would have opened as. + const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId); + set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId)); + return promoted?.tabId ?? null; + } const result = replaceTabTargetInLayout({ layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), tabId: normalizedTabId, @@ -1054,6 +1444,15 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); if (!normalizedWorkspaceKey || !normalizedTabId) return; set((state) => { + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withSheetTab(state, normalizedWorkspaceKey, normalizedTabId, (sheet) => { + const { state: _previous, ...tab } = sheet.tab; + return { + ...sheet, + tab: tabState === undefined ? tab : { ...tab, state: tabState }, + }; + }); + } const layout = setTabStateInLayout({ layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), tabId: normalizedTabId, @@ -1148,15 +1547,22 @@ export function createWorkspaceLayoutStore( ); } } + const sheets = getSheets(state, normalizedWorkspaceKey); + const fittingSheets = sheets.filter( + (sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot), + ); + const sheetAgentGone = fittingSheets.length !== sheets.length; if ( state.layoutByWorkspace[normalizedWorkspaceKey] && nextLayout === rawLayout && - pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace + pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace && + !sheetAgentGone ) { return state; } return { + ...(sheetAgentGone ? withSheets(state, normalizedWorkspaceKey, fittingSheets) : {}), pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, @@ -1661,7 +2067,8 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.focusRestorationByWorkspace || normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || normalizedWorkspaceKey in state.sidePaneIdByWorkspace || - normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace; + normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace || + normalizedWorkspaceKey in state.sheetsByWorkspace; if (!hasAny) { return state; } @@ -1690,6 +2097,7 @@ export function createWorkspaceLayoutStore( const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } = state.sidePaneIdByWorkspace; return { + ...withSheets(state, normalizedWorkspaceKey, NO_SHEETS), pullRequestTabAutoOpenedByWorkspace, layoutByWorkspace, splitSizesByWorkspace, @@ -1795,10 +2203,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 +2214,21 @@ export function observeOpenWorkspaceAgentIds( store = useWorkspaceLayoutStore, ): () => void { let previous: string[] | undefined; - const publish = (state: Pick) => { + const publish = ( + state: Pick, + ) => { const ids = new Set(); - for (const [workspaceKey, layout] of Object.entries(state.layoutByWorkspace)) { + const tabsByWorkspace = [ + ...Object.entries(state.layoutByWorkspace).map( + ([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const, + ), + ...Object.entries(state.sheetsByWorkspace).map( + ([workspaceKey, sheets]) => [workspaceKey, sheets.map((sheet) => sheet.tab)] as const, + ), + ]; + for (const [workspaceKey, tabs] of tabsByWorkspace) { if (!workspaceKey.startsWith(`${serverId}:`)) continue; - for (const tab of collectAllTabs(layout.root)) { + for (const tab of tabs) { if (tab.target.kind === "agent") ids.add(tab.target.agentId); } } @@ -1821,7 +2239,12 @@ export function observeOpenWorkspaceAgentIds( listener(next); }; const unsubscribe = store.subscribe((state, before) => { - if (state.layoutByWorkspace !== before.layoutByWorkspace) publish(state); + if ( + state.layoutByWorkspace !== before.layoutByWorkspace || + state.sheetsByWorkspace !== before.sheetsByWorkspace + ) { + publish(state); + } }); publish(store.getState()); return unsubscribe; diff --git a/packages/app/src/styles/density.ts b/packages/app/src/styles/density.ts new file mode 100644 index 0000000000..2383685d24 --- /dev/null +++ b/packages/app/src/styles/density.ts @@ -0,0 +1,49 @@ +import { isCompactBreakpoint } from "@/constants/layout"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +/** + * Density sizes for surfaces that opt into touch sizing: rows and icons grow on compact + * layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay + * desktop-sized everywhere, so a surface migrates by swapping its sizes for these. + * + * Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes + * are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping. + */ + +export type DensityIconSize = keyof typeof ICON_SIZE; + +const COMPACT_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; + +// Compact layouts end where `md` begins; see `isCompactBreakpoint`. +function byLayout(compact: T, desktop: T) { + return { xs: compact, md: desktop }; +} + +export const density = { + /** A list row such as a sidebar entry. */ + rowHeight: byLayout(44, 36), + /** A row in a tight group, such as the sidebar's navigation rows. */ + tightRowHeight: byLayout(36, 28), + /** A square icon-only button that sits in a row or toolbar. */ + iconButton: byLayout(44, 28), +}; + +/** The width or height of a slot that holds an icon of `size`. */ +export function densityIconBox(size: DensityIconSize) { + return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]); +} + +export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number { + return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size]; +} + +/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */ +export function densityIconProps

( + size: DensityIconSize, + mapping: (theme: Theme) => P, +) { + return (theme: Theme, rt: { breakpoint?: string }) => ({ + ...mapping(theme), + size: densityIconSize(rt, size), + }); +} diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts index 707085e7c9..461d980ecd 100644 --- a/packages/app/src/styles/theme.test.ts +++ b/packages/app/src/styles/theme.test.ts @@ -68,9 +68,9 @@ describe("Pure black theme", () => { }); describe("Sidebar interaction surfaces", () => { - it("keeps Light selection distinct from the sidebar surface", () => { - expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface1); - expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface3); + it("keeps Light hover and selection darker than the sidebar surface", () => { + expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3); + expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4); expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar); }); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index d5c1678749..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)", @@ -595,7 +595,6 @@ export const ICON_SIZE = { sm: 14, md: 16, lg: 20, - xl: 24, } as const; export const ICON_STROKE_WIDTH = 1.5; 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/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx index a31ae89478..0199c08ff2 100644 --- a/packages/app/src/usage/agent-usage.tsx +++ b/packages/app/src/usage/agent-usage.tsx @@ -27,7 +27,12 @@ export function AgentUsage({ <> {view.kind === "ready" ? ( - + ) : ( {view.kind === "loading" ? usageCopy.loading : view.message} @@ -39,10 +44,12 @@ export function AgentUsage({ function AgentUsageCards({ serverId, + agentId, reports, refreshable, }: { serverId: string; + agentId: string; reports: readonly UsageReportEntry[]; refreshable: boolean; }) { @@ -51,6 +58,7 @@ function AgentUsageCards({ `${login.harness}: ${reportContent(login.report).message}`, + ); + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. + return [ + entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem), + ]; +} + const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); export function UsageCard({ serverId, + agentId, entry, display, pinnable, @@ -52,6 +65,7 @@ export function UsageCard({ compact = false, }: { serverId: string; + agentId?: string; entry: UsageReportEntry; display: UsageDisplay; /** Whether each window row pins the window to the sidebar. */ @@ -61,13 +75,14 @@ export function UsageCard({ compact?: boolean; }) { const isCompact = useIsCompactFormFactor(); - const { refresh, refreshState } = useReportRefresh(serverId, entry.id); + const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId); // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip. const showsFreshnessInline = isNative || isCompact || !refreshable; const usage = entry.report; const status = statusText(usage); const footer = entry.account.label ?? null; - const { windows, balances, details, message } = reportContent(usage); + const { windows, balances, details } = reportContent(usage); + const messages = reportMessages(entry); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -110,9 +125,9 @@ export function UsageCard({ ) : null} - {message ? ( - - {message} + {messages.length > 0 ? ( + + {messages.join("\n")} ) : null} diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index ccd58fb63e..20297c9df0 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -3,6 +3,7 @@ import { StyleSheet } from "react-native-unistyles"; import { HostFilter } from "@/components/hosts/host-filter"; import { useMemo, type ReactElement } from "react"; import { UsageRefreshButton } from "./refresh-button"; +import { UsageOptions } from "./options"; import type { UsageHost } from "./model"; import { useHostUsage } from "./queries"; import type { UsageView } from "./types"; @@ -16,7 +17,7 @@ export interface UsageHostSelection { /** * The controls on the right of every usage title row: the host filter when there is more than one - * host, and Refresh all. A host that cannot report usage keeps only the host filter. + * host, Refresh all, and the Settings cog. A host that cannot report usage keeps only the host filter. */ export function UsageControls({ view, @@ -30,7 +31,7 @@ export function UsageControls({ const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); return ( - {hostSelection && hostSelection.hosts.length > 1 ? ( + {hasHostChoice(hostSelection) ? ( ) : null} {view.kind === "unavailable" ? null : ( - + <> + + + )} ); } +function hasHostChoice( + hostSelection: UsageHostSelection | undefined, +): hostSelection is UsageHostSelection { + return hostSelection !== undefined && hostSelection.hosts.length > 1; +} + function usageHostOptionTestID(serverId: string): string { return `usage-host-filter-item-${serverId}`; } @@ -61,18 +71,22 @@ const styles = StyleSheet.create((theme) => ({ })); /** - * One host's usage and the title-row controls that go with it, for the Usage screen and the - * compact usage sheet. + * One host’s usage and title-row controls for the Usage modal. + * Returns null when there is nothing to control, so headers leave no empty island. */ export function useHostUsageWithControls(hostSelection: UsageHostSelection): { view: UsageView; refresh: () => void; - controls: ReactElement; + controls: ReactElement | null; } { const { view, refresh } = useHostUsage(hostSelection.serverId); + const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable"; const controls = useMemo( - () => , - [hostSelection, refresh, view], + () => + hasControls ? ( + + ) : null, + [hasControls, hostSelection, refresh, view], ); return { view, refresh, controls }; } diff --git a/packages/app/src/usage/hosts.ts b/packages/app/src/usage/hosts.ts index 450e0a6157..dba21dc316 100644 --- a/packages/app/src/usage/hosts.ts +++ b/packages/app/src/usage/hosts.ts @@ -6,7 +6,7 @@ import { } from "@/stores/navigation-active-workspace-store"; import { resolveUsageHostId, - resolveUsageScreenHostId, + resolveUsageModalHostId, type UsageHost, type UsageHostChoice, } from "./model"; @@ -35,7 +35,7 @@ export function useUsageHostId(): string | null { } /** - * The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. A pick + * The host the Usage modal shows, and the hosts to pick from. A pick * is saved on the device, so the sidebar Usage row and later visits show the same host. */ export function useUsageHostSelection(): { @@ -55,5 +55,5 @@ export function useUsageHostSelection(): { }, [updateSettings], ); - return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select }; + return { serverId: resolveUsageModalHostId(choice), connectedHosts, select }; } diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index ab1e28b38e..45599aba2d 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -1,5 +1,9 @@ export { AgentUsage } from "./agent-usage"; export { HostUsageSection } from "./host-usage-section"; export { useHostReportsUsage } from "./queries"; -export { UsageScreen } from "./usage-screen"; -export { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "./sidebar-item"; +export { + UsageSidebarItem, + UsageSidebarRoot, + useHasUsageSummary, + useOpenSidebarUsage, +} from "./sidebar-item"; diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 1276157120..ad293bb23e 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -6,7 +6,7 @@ import { replaceReport, resolveUsageRefresh, resolveUsageHostId, - resolveUsageScreenHostId, + resolveUsageModalHostId, resolveUsageView, settleReports, upsertReport, @@ -117,35 +117,35 @@ describe("resolveUsageHostId", () => { }); }); -describe("resolveUsageScreenHostId", () => { +describe("resolveUsageModalHostId", () => { it("shows the picked host while it stays connected, even one that cannot report usage", () => { - expect(resolveUsageScreenHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( + expect(resolveUsageModalHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( "old", ); - expect( - resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }), - ).toBe("b"); + expect(resolveUsageModalHostId({ pickedServerId: "offline", activeServerId: "b", hosts })).toBe( + "b", + ); }); it("otherwise shows the host the sidebar row reads", () => { for (const pickedServerId of [null, "a", "offline"]) { for (const activeServerId of ["b", null, "old", "offline"]) { const choice = { pickedServerId, activeServerId, hosts }; - expect(resolveUsageScreenHostId(choice)).toBe(resolveUsageHostId(choice)); + expect(resolveUsageModalHostId(choice)).toBe(resolveUsageHostId(choice)); } } }); - it("shows the first connected host when none reports usage, so the screen says to update it", () => { + it("shows the first connected host when none reports usage, so the modal says to update it", () => { expect( - resolveUsageScreenHostId({ + resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: hosts.slice(0, 2), }), ).toBe("old"); expect( - resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), + resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), ).toBeNull(); }); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 2b2e4c54cf..a32da7ea4d 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -176,11 +176,11 @@ export function resolveUsageHostId(choice: UsageHostChoice): string | null { } /** - * The host the Usage screen shows: the picked host while it is connected, even one that cannot - * report usage so the screen says to update it; else the sidebar row's host; else the first + * The host the Usage modal shows: the picked host while it is connected, even one that cannot + * report usage so the modal says to update it; else the sidebar row's host; else the first * connected host. */ -export function resolveUsageScreenHostId(choice: UsageHostChoice): string | null { +export function resolveUsageModalHostId(choice: UsageHostChoice): string | null { const connected = choice.hosts.filter((host) => host.isConnected); const picked = connected.find((host) => host.serverId === choice.pickedServerId); return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index cf2666f897..886c21e190 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -1,14 +1,15 @@ import { View } from "react-native"; -import { - SettingsCard, - SettingsCollapsibleRow, - SettingsRow, - SettingsSwitch, -} from "@/components/settings"; +import { Settings } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { SettingsRow, SettingsSwitch } from "@/components/settings"; import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; import { settingsStyles } from "@/styles/settings"; import { usageCopy } from "./copy"; -import type { UsageDisplay } from "./display"; +import { useUsagePreferences } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; import type { UsageDisplayAs } from "./preferences"; @@ -17,16 +18,33 @@ const DISPLAY_AS_OPTIONS: SegmentedControlOption[] = [ { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" }, ]; -/** - * The usage options, folded above the reports: whether the sidebar shows the summary (the same - * switch as Settings > Sidebar) and whether percents read as used or remaining. - */ -export function UsageOptions({ display }: { display: UsageDisplay }) { +const ThemedSettings = withUnistyles(Settings); + +/** The shared usage preferences, opened from a cog as a popover or compact sheet. */ +export function UsageOptions() { + const compact = useIsCompactFormFactor(); + const { display } = useUsagePreferences(); const { inSidebar, setInSidebar } = useUsageInSidebar(); return ( - - - + + + + + + - - - - - - + + + + + + + + ); } diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index f054d6d082..7b6f210543 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -98,10 +98,14 @@ async function getReport( serverId: string, reportId: string, forceRefresh = false, + agentId?: string, ): Promise { return ( - (await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh })) - .reports[0] ?? null + ( + await requireClient(serverId).listUsageReports( + agentId === undefined ? { reportIds: [reportId], forceRefresh } : { agentId, forceRefresh }, + ) + ).reports.find((report) => report.id === reportId) ?? null ); } @@ -223,20 +227,22 @@ export function useUsageHosts(): UsageHost[] { } /** - * Forces the source to fetch one report, and only that report. The result replaces the report - * in every list of its host that holds it, the host's and each agent's, so every surface showing - * it moves together; until then the previous report stays on screen. + * Refreshes a host report, or the agent's session reports. The result replaces the card + * in the requesting view. Host and agent views can use different logins of the same account. */ export function useReportRefresh( serverId: string, reportId: string, + agentId?: string, ): { refresh: () => void; refreshState: UsageRefresh } { const queryClient = useQueryClient(); const mutation = useMutation({ - mutationFn: () => getReport(serverId, reportId, true), + mutationFn: () => getReport(serverId, reportId, true, agentId), onSuccess: (report) => { - queryClient.setQueriesData( - { queryKey: hostUsageQueryKey(serverId) }, + queryClient.setQueryData( + agentId === undefined + ? usageReportsQueryKey(serverId) + : agentUsageQueryKey(serverId, agentId), (reports) => (reports ? replaceReport(reports, reportId, report) : reports), ); }, diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx index bf6dc787b2..48754c4e19 100644 --- a/packages/app/src/usage/refresh-button.tsx +++ b/packages/app/src/usage/refresh-button.tsx @@ -9,7 +9,10 @@ import { usageCopy } from "./copy"; const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -/** Refreshes every report on the host; spins while a refresh is in flight. */ +/** + * Refreshes every report on the host; spins while a refresh is in flight. On compact it sits in the + * header's island, so its highlight is round. + */ export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) { const compact = useIsCompactFormFactor(); const iconSize = paneContentToolbarIconSize(compact); diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 7b719bdb59..d90013d8bf 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -1,5 +1,12 @@ -import { router } from "expo-router"; -import { Fragment, useCallback, useMemo, useState } from "react"; +import { + createContext, + Fragment, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; import { Pressable, @@ -9,14 +16,9 @@ import { type PressableStateCallbackType, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; -import { usePanelStore } from "@/stores/panel-store"; -import { buildUsageRoute } from "@/utils/host-routes"; -import { useHostUsageWithControls } from "./controls"; -import { useUsagePreferences, type UsageDisplay } from "./display"; -import { useUsageHostId, useUsageHostSelection } from "./hosts"; +import { useUsagePreferences } from "./display"; +import { useUsageHostId } from "./hosts"; import { useUsageHostReports } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import { UsageMeter } from "./meter"; @@ -28,9 +30,7 @@ import { type PinnedUsageLayout, type PinnedUsageSource, } from "./pinned"; -import type { UsageHost } from "./model"; -import { UsageOptions } from "./options"; -import { UsageBody } from "./usage-section"; +import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ function useUsageSummary(): readonly PinnedUsageSource[] { @@ -46,109 +46,48 @@ export function useHasUsageSummary(): boolean { /** * The sidebar footer's usage entry: each summary window's source icon and percent, and nothing - * while no summary window has data, since the footer's Usage icon already opens the screen. - * Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead. + * while no summary window has data, since the footer's Usage icon already opens Usage. + * Pressing it opens the Usage modal. */ export function UsageSidebarItem() { - const { display } = useUsagePreferences(); - const sources = useUsageSummary(); - if (sources.length === 0) return null; - return ; -} - -/** Opens the Usage screen, over the sidebar on compact layouts. */ -export function useOpenUsageScreen(): () => void { - const isCompact = useIsCompactFormFactor(); - const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - return useCallback(() => { - if (isCompact) showMobileAgent(); - router.push(buildUsageRoute()); - }, [isCompact, showMobileAgent]); -} - -function UsageEntry({ - sources, - display, -}: { - sources: readonly PinnedUsageSource[]; - display: UsageDisplay; -}) { const { t } = useTranslation(); const label = t(builtinSidebarNavLabelKey("usage")); - const isCompact = useIsCompactFormFactor(); - const openUsageScreen = useOpenUsageScreen(); - const [open, setOpen] = useState(false); - // The sheet mounts on first open; the summary already owns the report query. - const [sheetMounted, setSheetMounted] = useState(false); - const handlePress = useCallback(() => { - if (!isCompact) { - openUsageScreen(); - return; - } - setSheetMounted(true); - setOpen(true); - }, [isCompact, openUsageScreen]); - - const trigger = ; - if (!isCompact) return trigger; - return ( - - {trigger} - {sheetMounted ? : null} - - ); + const openUsage = useOpenSidebarUsage(); + const sources = useUsageSummary(); + if (sources.length === 0) return null; + return ; } +const OpenSidebarUsageContext = createContext<(() => void) | null>(null); + /** - * The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls - * in its title row. + * One owner for the footer icon and summary: both open the Usage modal over the current screen. + * The modal mounts on first open and unmounts once it has finished closing, so its host's reports + * load only while it is shown. */ -function UsageSheet({ title, display }: { title: string; display: UsageDisplay }) { - const { serverId, connectedHosts, select } = useUsageHostSelection(); - if (!serverId) return null; +export function UsageSidebarRoot({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const openUsage = useCallback(() => { + setMounted(true); + setOpen(true); + }, []); + const closeUsage = useCallback(() => setOpen(false), []); + const unmountUsage = useCallback(() => setMounted(false), []); + return ( - + + {children} + {mounted ? : null} + ); } -function HostUsageSheet({ - title, - serverId, - hosts, - onSelectHost, - display, -}: { - title: string; - serverId: string; - hosts: UsageHost[]; - onSelectHost: (serverId: string) => void; - display: UsageDisplay; -}) { - const hostSelection = useMemo( - () => ({ hosts, serverId, onSelect: onSelectHost }), - [hosts, onSelectHost, serverId], - ); - const { view, refresh, controls } = useHostUsageWithControls(hostSelection); - return ( - - - {view.kind === "unavailable" ? null : } - - - - ); +/** Both sidebar entry points send the same open command. */ +export function useOpenSidebarUsage(): () => void { + const openUsage = useContext(OpenSidebarUsageContext); + if (!openUsage) throw new Error("Sidebar Usage must be inside UsageSidebarRoot."); + return openUsage; } function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string { @@ -342,8 +281,4 @@ const styles = StyleSheet.create((theme) => ({ windowLabel: { color: theme.colors.foregroundMuted, }, - sheetBody: { - padding: theme.spacing[3], - gap: theme.spacing[3], - }, })); diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx new file mode 100644 index 0000000000..8305c3f878 --- /dev/null +++ b/packages/app/src/usage/usage-modal.tsx @@ -0,0 +1,93 @@ +import { useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { useHostUsageWithControls } from "./controls"; +import { usageCopy } from "./copy"; +import { useUsagePreferences } from "./display"; +import { useUsageHostSelection } from "./hosts"; +import type { UsageHost } from "./model"; +import { UsageBody, UsageMessage } from "./usage-section"; + +interface UsageModalProps { + visible: boolean; + onClose: () => void; + onDismiss: () => void; +} + +/** + * Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It + * shows the usage host's reports with pins, and the host filter, Refresh all, and Settings in its + * title row. + */ +export function UsageModal(props: UsageModalProps) { + const { serverId, connectedHosts, select } = useUsageHostSelection(); + if (!serverId) { + return ( + + + + ); + } + // Keyed by host so a refresh in flight on one host never shows on another's cards. + return ( + + ); +} + +function HostUsageModal({ + serverId, + hosts, + onSelectHost, + ...props +}: UsageModalProps & { + serverId: string; + hosts: UsageHost[]; + onSelectHost: (serverId: string) => void; +}) { + const { display } = useUsagePreferences(); + const hostSelection = useMemo( + () => ({ hosts, serverId, onSelect: onSelectHost }), + [hosts, onSelectHost, serverId], + ); + const { view, refresh, controls } = useHostUsageWithControls(hostSelection); + return ( + + + + + + ); +} + +function UsageModalFrame({ + visible, + onClose, + onDismiss, + actions, + children, +}: UsageModalProps & { actions?: ReactNode; children: ReactNode }) { + const { t } = useTranslation(); + const title = t(builtinSidebarNavLabelKey("usage")); + const header = useMemo(() => ({ title, actions }), [actions, title]); + return ( + + {children} + + ); +} diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx deleted file mode 100644 index d8e2ba08dc..0000000000 --- a/packages/app/src/usage/usage-screen.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useIsFocused } from "@react-navigation/native"; -import { router } from "expo-router"; -import { useMemo, type ReactNode } from "react"; -import { View } from "react-native"; -import { PageLayout } from "@/components/page-layout"; -import { useHostUsageWithControls } from "./controls"; -import { usageCopy } from "./copy"; -import { useUsagePreferences } from "./display"; -import { useUsageHostSelection } from "./hosts"; -import type { UsageHost } from "./model"; -import { UsageOptions } from "./options"; -import { UsageBody, UsageMessage } from "./usage-section"; - -// The screen is reachable by URL, so there may be no history to go back to. -function leaveUsage(): void { - if (router.canGoBack()) { - router.back(); - return; - } - router.replace("/"); -} - -export function UsageScreen() { - const isFocused = useIsFocused(); - if (!isFocused) return {null}; - return ; -} - -function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) { - return ( - - {children} - - ); -} - -function FocusedUsageScreen() { - const { serverId, connectedHosts, select } = useUsageHostSelection(); - if (!serverId) { - return ( - - - - ); - } - return ( - - ); -} - -function HostUsage({ - serverId, - hosts, - onSelectHost, -}: { - serverId: string; - hosts: UsageHost[]; - onSelectHost: (serverId: string) => void; -}) { - const { display } = useUsagePreferences(); - const hostSelection = useMemo( - () => ({ hosts, serverId, onSelect: onSelectHost }), - [hosts, onSelectHost, serverId], - ); - const { view, refresh, controls } = useHostUsageWithControls(hostSelection); - return ( - - - {view.kind === "unavailable" ? null : } - - - - ); -} diff --git a/packages/app/src/utils/assistant-markdown-image-source.test.ts b/packages/app/src/utils/assistant-markdown-image-source.test.ts new file mode 100644 index 0000000000..edc02a4eb7 --- /dev/null +++ b/packages/app/src/utils/assistant-markdown-image-source.test.ts @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { localFileSourceToPath } from "@/attachments/utils"; +import { createAssistantMarkdownParser } from "./assistant-markdown-parser"; +import { resolveAssistantImageSource } from "./assistant-image-source"; + +describe("local assistant Markdown images", () => { + let workspaceRoot: string; + const imageBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ); + + beforeEach(async () => { + workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-")); + await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes); + await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it.each([ + "absolute angle", + "absolute encoded", + "relative angle", + "relative encoded", + "file URI", + "literal percent", + ])("reads the actual image for the %s destination", async (form) => { + const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png"); + const destinations: Record = { + "absolute angle": `<${absolute}>`, + "absolute encoded": encodeURI(absolute), + "relative angle": "", + "relative encoded": encodeURI("My Apps/Мои/test.png"), + "file URI": `file://${encodeURI(absolute)}`, + "literal percent": `${workspaceRoot}/literal%2520name.png`, + }; + const tokens = createAssistantMarkdownParser().parseInline( + `![test](${destinations[form]})`, + {}, + ); + const image = tokens + .flatMap((token) => token.children ?? []) + .find((token) => token.type === "image"); + expect(image).toBeDefined(); + const source = localFileSourceToPath(image!.attrGet("src")!, { decodeHref: true }); + const target = resolveAssistantImageSource({ source, workspaceRoot }); + expect(target?.kind).toBe("file_rpc"); + if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target"); + await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes); + }); +}); diff --git a/packages/app/src/utils/file-mention-autocomplete.test.ts b/packages/app/src/utils/file-mention-autocomplete.test.ts index 4f818e25e2..c74c821522 100644 --- a/packages/app/src/utils/file-mention-autocomplete.test.ts +++ b/packages/app/src/utils/file-mention-autocomplete.test.ts @@ -48,6 +48,74 @@ describe("findActiveFileMention", () => { }); expect(mention).toBeNull(); }); + + it("returns null for the issue #1364 angle-bracket email example", () => { + const text = "user <12345+user@noreply.example.com>"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for email addresses (word char before @)", () => { + const text = "alice@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for email addresses (+ immediately before @)", () => { + // Local part ends with + so the character at index-1 is '+', not a word char. + const text = "noreply+@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for plus-tagged email local parts", () => { + const text = "noreply+tag@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null when query contains angle brackets", () => { + const text = "check @foo end"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.indexOf(" end"), + }); + expect(mention).toBeNull(); + }); + + it("returns null when query ends with a closing angle bracket", () => { + const text = "check @foo>"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("still detects a file mention after an email in the same input", () => { + const text = "user <12345+user@noreply.example.com> and also @file"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toEqual({ + start: text.lastIndexOf("@"), + end: text.length, + query: "file", + }); + }); }); describe("formatQuotedFileMentionPath", () => { diff --git a/packages/app/src/utils/file-mention-autocomplete.ts b/packages/app/src/utils/file-mention-autocomplete.ts index d19424cfe4..fe09b52f03 100644 --- a/packages/app/src/utils/file-mention-autocomplete.ts +++ b/packages/app/src/utils/file-mention-autocomplete.ts @@ -15,7 +15,7 @@ interface ApplyFileMentionReplacementInput { relativePath: string; } -const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/; +const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"'<>]/; export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null { const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length)); @@ -26,6 +26,11 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe atIndex >= 0; atIndex = atIndex === 0 ? -1 : beforeCursor.lastIndexOf("@", atIndex - 1) ) { + // Skip @ that looks like part of an email address (local-part char immediately before @). + if (atIndex > 0 && /[\w+]/.test(beforeCursor[atIndex - 1]!)) { + continue; + } + const query = beforeCursor.slice(atIndex + 1); if (INVALID_MENTION_QUERY_CHARS.test(query)) { continue; diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 1dc3ff48bb..c13d6f25f9 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -428,10 +428,6 @@ export function buildSchedulesRoute() { return "/schedules" as const; } -export function buildUsageRoute() { - return "/usage" as const; -} - export function buildOpenProjectRoute() { return "/open-project" as const; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 6b008e3dc6..b05c930123 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -1,9 +1,11 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import type { SidebarProjectEntry, SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; +import { routeKeyboardShortcut } from "@/keyboard/route-shortcut"; import { buildSidebarShortcutModel, @@ -64,6 +66,46 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb } describe("buildSidebarShortcutModel", () => { + it.each([ + [9, 1, 10], + [10, -1, 9], + [12, 1, 1], + [1, -1, 12], + ] as const)( + "navigates from workspace %i by %i to workspace %i across the full visible sidebar", + (current, delta, next) => { + const workspaces = Array.from({ length: 12 }, (_, index) => + workspace({ + serverId: "s", + workspaceId: `ws-${index + 1}`, + workspaceDirectory: `/repo/w${index + 1}`, + name: `w${index + 1}`, + }), + ); + const model = buildSidebarShortcutModel({ + projects: [project("p", workspaces)], + collapsedProjectKeys: new Set(), + }); + + expect( + routeKeyboardShortcut( + { action: "workspace.navigate.relative", payload: { delta } }, + { + pathname: `/h/s/workspace/ws-${current}`, + isMobile: false, + sidebarShortcutTargets: model.shortcutTargets, + navigationActiveWorkspace: null, + commandCenterOpen: false, + shortcutsDialogOpen: false, + }, + ), + ).toEqual({ kind: "navigate-workspace", serverId: "s", workspaceId: `ws-${next}` }); + expect(model.shortcutIndexByWorkspaceKey.size).toBe(9); + expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9); + expect(model.shortcutIndexByWorkspaceKey.has("s:ws-10")).toBe(false); + }, + ); + it("builds shortcut targets in visual order and excludes collapsed projects", () => { const projects = [ project("p1", [ @@ -110,7 +152,7 @@ describe("buildSidebarShortcutModel", () => { expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined(); }); - it("limits shortcuts to 9", () => { + it("keeps all traversal targets while numbering only the first 9", () => { const workspaces = Array.from({ length: 20 }, (_, index) => workspace({ serverId: "s", @@ -126,9 +168,11 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(), }); - expect(model.shortcutTargets).toHaveLength(9); - expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" }); + expect(model.shortcutTargets).toHaveLength(20); expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" }); + expect(model.shortcutTargets[9]).toEqual({ serverId: "s", workspaceId: "ws-10" }); + expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9); + expect(model.shortcutIndexByWorkspaceKey.get("s:ws-10")).toBeUndefined(); }); it("excludes a collapsed project's workspaces regardless of project kind", () => { @@ -211,6 +255,7 @@ describe("buildStatusSidebarShortcutModel", () => { ["p1", "Project 1"], ["p2", "Project 2"], ]), + i18n.t, ), }); @@ -247,7 +292,7 @@ describe("buildStatusSidebarShortcutModel", () => { ]; const model = buildStatusSidebarShortcutModel({ - groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])), + groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]]), i18n.t), collapsedStatusGroupKeys: new Set(["needs_input"]), }); diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index 86540357e6..d08cba927c 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -70,13 +70,10 @@ export function buildSidebarShortcutSections(input: { } for (const workspace of section.workspaces) { - if (shortcutTargets.length >= maxShortcuts) { - break; - } - - const shortcutNumber = shortcutTargets.length + 1; shortcutTargets.push(createShortcutTarget(workspace)); - shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber); + if (shortcutTargets.length <= maxShortcuts) { + shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutTargets.length); + } } } diff --git a/packages/app/src/utils/workspace-directory.test.ts b/packages/app/src/utils/workspace-directory.test.ts index b40bdd9705..b3973fb7b6 100644 --- a/packages/app/src/utils/workspace-directory.test.ts +++ b/packages/app/src/utils/workspace-directory.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest"; import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory"; describe("resolveWorkspaceDirectory", () => { + it("preserves significant whitespace in a workspace folder name", () => { + expect(resolveWorkspaceDirectory({ workspaceDirectory: "/repo " })).toBe("/repo "); + expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo /" })).toBe("/repo "); + }); it("canonicalizes a workspace directory and returns null when blank", () => { expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe( "C:/repo/app", diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts index 8c778372d9..3cf3134939 100644 --- a/packages/app/src/utils/workspace-identity.ts +++ b/packages/app/src/utils/workspace-identity.ts @@ -13,11 +13,10 @@ export function normalizeWorkspaceOpaqueId(value: string | null | undefined): st } export function normalizeWorkspacePath(value: string | null | undefined): string | null { - const trimmed = trimNonEmpty(value); - if (!trimmed) { + if (typeof value !== "string" || value.trim().length === 0) { return null; } - const withUnixSeparators = trimmed.replace(/\\/g, "/"); + const withUnixSeparators = value.replace(/\\/g, "/"); if (withUnixSeparators === "/") { return withUnixSeparators; } diff --git a/packages/app/src/workspace-labels/manager-modal.tsx b/packages/app/src/workspace-labels/manager-modal.tsx index 0dc8f1c53f..a23e7e0925 100644 --- a/packages/app/src/workspace-labels/manager-modal.tsx +++ b/packages/app/src/workspace-labels/manager-modal.tsx @@ -174,9 +174,6 @@ export function WorkspaceLabelManagerModal({ onClose={onClose} header={header} footer={footer} - // Bound the compact scroller to the live snap height, so the edit view's footer stays on - // screen instead of being pushed past the bottom of the sheet. - sizeContentToCurrentSnapPoint testID="workspace-label-manager" > {draft && editing ? ( diff --git a/packages/app/src/workspace-tabs/compact-sheet.ts b/packages/app/src/workspace-tabs/compact-sheet.ts new file mode 100644 index 0000000000..947d4904c5 --- /dev/null +++ b/packages/app/src/workspace-tabs/compact-sheet.ts @@ -0,0 +1,38 @@ +import { getWorkspacePanelAttributes } from "@/panels/panel-instance-attributes"; +import { panelCompactSheetPolicy, type CompactSheetPolicy } from "@/panels/panel-manifest"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { isWorkspaceRootAgent } from "@/subagents/policies"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; + +function workspaceAgentLookup(workspaceKey: string): (agentId: string) => Agent | null { + const sessions = useSessionStore.getState().sessions; + const serverId = Object.keys(sessions) + .filter((candidate) => workspaceKey.startsWith(`${candidate}:`)) + .sort((left, right) => right.length - left.length)[0]; + const session = serverId ? sessions[serverId] : undefined; + return (agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; +} + +/** + * The single target-aware boundary deciding whether a compact reveal lands in the workspace + * sheet. An agent the client has not loaded yet counts as a root agent and stays a tab. + */ +export function resolveCompactSheetPolicy( + workspaceKey: string, + target: WorkspaceTabTarget, +): CompactSheetPolicy | null { + return panelCompactSheetPolicy(target, { + isWorkspaceRootAgent: (agentId) => { + const getAgent = workspaceAgentLookup(workspaceKey); + const agent = getAgent(agentId); + if (!agent) return true; + const parent = agent.parentAgentId ? (getAgent(agent.parentAgentId) ?? undefined) : undefined; + return isWorkspaceRootAgent(agent, parent); + }, + }); +} + +/** Whether the sheet's tab holds unsaved work, which must never be discarded with the sheet. */ +export function isSheetTabModified(workspaceKey: string, tabId: string): boolean { + return getWorkspacePanelAttributes(workspaceKey, tabId).modified; +} diff --git a/packages/app/src/workspace-tabs/layout-mode-publisher.tsx b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx new file mode 100644 index 0000000000..03df9b695a --- /dev/null +++ b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx @@ -0,0 +1,21 @@ +import { useLayoutEffect } from "react"; +import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; +import { useWorkspaceLayoutStore, type WorkspaceLayoutMode } from "@/stores/workspace-layout-store"; + +function resolveWorkspaceLayoutMode(isCompact: boolean): WorkspaceLayoutMode { + if (isCompact) return "compact"; + return supportsDesktopPaneSplits() ? "split-panes" : "single-pane"; +} + +/** + * Publishes the shell's workspace layout to the layout store, which decides where opened targets + * land. Render it before the routes: React runs an earlier sibling's layout effects before a later + * sibling's subtree, so a route sees the mode even from its first layout effect. + */ +export function WorkspaceLayoutModePublisher(): null { + const isCompact = useIsCompactFormFactor(); + useLayoutEffect(() => { + useWorkspaceLayoutStore.getState().setLayoutMode(resolveWorkspaceLayoutMode(isCompact)); + }, [isCompact]); + return null; +} diff --git a/packages/app/src/workspace-tabs/open-beside.test.ts b/packages/app/src/workspace-tabs/open-beside.test.ts new file mode 100644 index 0000000000..9e80745db8 --- /dev/null +++ b/packages/app/src/workspace-tabs/open-beside.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined), + }, +})); + +import { DEFAULT_APP_SETTINGS } from "@/hooks/use-settings"; +import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; +import { + collectAllPanes, + collectAllTabs, + findPaneById, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { + openPreferredWorkspacePreview, + openPreferredWorkspaceTarget, + openWorkspaceTargetBeside, +} from "@/workspace-tabs/open-beside"; + +const WORKSPACE_KEY = "server-1:workspace-1"; + +beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + sheetsByWorkspace: {}, + layoutByWorkspace: {}, + explorerSidebarPaneIdByWorkspace: {}, + sidePaneIdByWorkspace: {}, + }); +}); + +describe("openPreferredWorkspacePreview", () => { + it("opens a compact file preview in the sheet instead of replacing the active file tab", () => { + const store = useWorkspaceLayoutStore.getState(); + const fileTabId = store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/open.ts" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + + const tabId = openPreferredWorkspacePreview({ + workspaceKey: WORKSPACE_KEY, + serverId: "server-1", + workspaceId: "workspace-1", + explorerSidebarPaneId: "explorer", + lastMainPaneId: "main", + target: { kind: "file", path: "src/preview.ts" }, + source: "explorerFiles", + preferences: DEFAULT_APP_SETTINGS.openInSidePane, + }); + + const state = useWorkspaceLayoutStore.getState(); + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.at(-1)?.tab).toMatchObject({ + tabId, + target: { kind: "file", path: "src/preview.ts" }, + }); + const root = state.layoutByWorkspace[WORKSPACE_KEY].root; + expect(findPaneById(root, "main")?.tabIds).toEqual([fileTabId]); + expect(collectAllTabs(root).find((tab) => tab.tabId === fileTabId)?.target).toEqual({ + kind: "file", + path: "src/open.ts", + }); + }); +}); + +describe("previews under a sheet with unsaved work", () => { + const sheetTarget = { kind: "file" as const, path: "src/draft.ts" }; + let sheetTabId: string | null = null; + + afterEach(() => { + if (sheetTabId) { + setPanelInstanceAttributes( + { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId }, + { modified: false }, + ); + } + sheetTabId = null; + }); + + function previewFile(path: string): string | null { + return openPreferredWorkspacePreview({ + workspaceKey: WORKSPACE_KEY, + serverId: "server-1", + workspaceId: "workspace-1", + explorerSidebarPaneId: "explorer", + lastMainPaneId: "main", + target: { kind: "file", path }, + source: "explorerFiles", + preferences: DEFAULT_APP_SETTINGS.openInSidePane, + }); + } + + it("stacks link previews over the sheet instead of dropping its unsaved work", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + sheetTabId = store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: sheetTarget, + intent: "reveal", + }); + setPanelInstanceAttributes( + { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId ?? "" }, + { modified: true }, + ); + + previewFile("src/first.ts"); + previewFile("src/second.ts"); + + const state = useWorkspaceLayoutStore.getState(); + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.map((sheet) => sheet.tab.target)).toEqual([ + sheetTarget, + { kind: "file", path: "src/first.ts" }, + { kind: "file", path: "src/second.ts" }, + ]); + const root = state.layoutByWorkspace[WORKSPACE_KEY].root; + expect(findPaneById(root, "main")?.tabIds).toHaveLength(1); + }); +}); + +describe("opening beside", () => { + const sidePreference = { ...DEFAULT_APP_SETTINGS.openInSidePane, subagents: true }; + + function openSubagentWithSidePreference(): string | null { + useWorkspaceLayoutStore.getState().openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/open.ts" }, + intent: "reveal", + }); + return openPreferredWorkspaceTarget({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/subagent.ts" }, + source: "subagents", + preferences: sidePreference, + }); + } + + function paneIdHolding(tabId: string | null): string | undefined { + const root = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root; + return collectAllPanes(root).find((pane) => pane.tabIds.includes(tabId ?? ""))?.id; + } + + it("follows the side preference where the workspace renders split panes", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("split-panes"); + + const tabId = openSubagentWithSidePreference(); + + const sidePaneId = useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]; + expect(sidePaneId).toEqual(expect.any(String)); + expect(paneIdHolding(tabId)).toBe(sidePaneId); + }); + + it("keeps a single-pane workspace in one pane despite the side preference", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("single-pane"); + + const preferredTabId = openSubagentWithSidePreference(); + const besideTabId = openWorkspaceTargetBeside({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/beside.ts" }, + }); + + expect(useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]).toBeFalsy(); + expect(paneIdHolding(preferredTabId)).toBe("main"); + expect(paneIdHolding(besideTabId)).toBe("main"); + }); +}); diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts index b36d227c3b..0c0c9ebdf5 100644 --- a/packages/app/src/workspace-tabs/open-beside.ts +++ b/packages/app/src/workspace-tabs/open-beside.ts @@ -22,13 +22,11 @@ interface OpenWorkspaceTargetInput { } export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetInput { - isCompact: boolean; source: OpenInSidePaneSource; preferences: OpenInSidePanePreferences; } interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput { - isCompact: boolean; location: WorkspaceTargetOpenLocation; } @@ -39,6 +37,11 @@ interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTarge lastMainPaneId: string | null; } +/** A side pane exists only where the workspace renders split panes. */ +function canOpenBeside(): boolean { + return useWorkspaceLayoutStore.getState().layoutMode === "split-panes"; +} + function resolveMainPane(input: { workspaceKey: string; explorerSidebarPaneId: string | null; @@ -86,7 +89,6 @@ export function openPreferredWorkspaceTarget( input: OpenPreferredWorkspaceTargetInput, ): string | null { return openWorkspaceTargetAtLocation({ - isCompact: input.isCompact, workspaceKey: input.workspaceKey, target: input.target, location: input.preferences[input.source] ? "side" : "main", @@ -105,7 +107,7 @@ export function openWorkspaceTargetAtLocation( layout && collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)), ); - const shouldOpenBeside = !input.isCompact && input.location === "side"; + const shouldOpenBeside = canOpenBeside() && input.location === "side"; let placement: WorkspaceTabPlacement | undefined; if (shouldOpenBeside && !targetAlreadyExists) { const paneId = store.ensureSidePane(input.workspaceKey); @@ -135,7 +137,13 @@ export function openPreferredWorkspacePreview( const existing = collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target), ); - if (existing) return openPreferredWorkspaceTarget(input); + // The sheet is already a single replaceable slot, so it needs no preview tab. + if ( + existing || + store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" }) + ) { + return openPreferredWorkspaceTarget(input); + } const mainPane = resolveMainPane({ workspaceKey: input.workspaceKey, @@ -143,7 +151,7 @@ export function openPreferredWorkspacePreview( lastMainPaneId: input.lastMainPaneId, }); const destinationPaneId = - !input.isCompact && input.preferences[input.source] + canOpenBeside() && input.preferences[input.source] ? store.ensureSidePane(input.workspaceKey) : mainPane?.id; if (!destinationPaneId) return null; @@ -173,9 +181,15 @@ export function openPreferredWorkspacePreview( }); } -/** Explicit Open to Side: creates the side pane and places the target there. */ +/** + * Explicit Open to Side: creates the side pane and places the target there. Without split panes + * the target opens like any other. + */ export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): string | null { if (!input.workspaceKey) return null; + if (!canOpenBeside()) { + return openWorkspaceTargetAtLocation({ ...input, location: "main" }); + } const store = useWorkspaceLayoutStore.getState(); const paneId = store.ensureSidePane(input.workspaceKey); return store.openTab({ diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts index b91bf732c9..bb567d4bd0 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.ts @@ -46,7 +46,6 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | return null; } return openPreferredWorkspaceTarget({ - isCompact: input.isCompact, workspaceKey: input.workspaceKey, target: { kind: "working_diff" }, source: "diffs", @@ -70,7 +69,6 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): return null; } return openWorkspaceTargetAtLocation({ - isCompact: input.isCompact, workspaceKey: input.workspaceKey, target: { kind: "pull_request" }, location: input.destination, diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 784a7569ff..5f3e8c7e8f 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -8,6 +8,7 @@ export default Stub; export const BottomSheetModal = Stub; export const BottomSheetModalProvider = PassThrough; export const BottomSheetBackdrop = Stub; +export const BottomSheetHandle = Stub; export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub; 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 3ae9ccb2cf..4fdcfba018 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.11.0-beta.3", + "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.3", - "@getpaseo/protocol": "0.11.0-beta.3", - "@getpaseo/server": "0.11.0-beta.3", + "@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/attach.ts b/packages/cli/src/commands/agent/attach.ts index 522905c033..a881f2d7f8 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command { .argument("", "Agent ID (or prefix)"); } import { connectToDaemon } from "../../utils/client.js"; +import { waitForStop } from "../../utils/wait-for-stop.js"; import { fetchProjectedTimelineItems, LIVE_HISTORY_FETCH_TIMEOUT_MS, @@ -157,31 +158,10 @@ export async function runAttachCommand( await unsubscribe.ready; console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`); - // Handle Ctrl+C to detach gracefully - let detached = false; - const detach = () => { - if (detached) return; - detached = true; - - console.log("\n\nDetaching from agent..."); - unsubscribe(); - client - .close() - .then(() => { - process.exit(0); - }) - .catch(() => { - process.exit(1); - }); - }; - - process.on("SIGINT", detach); - process.on("SIGTERM", detach); - - // Keep the process alive - await new Promise(() => { - // Wait indefinitely until interrupted - }); + await waitForStop(); + console.log("\n\nDetaching from agent..."); + unsubscribe(); + await client.close(); } catch (err) { await client.close().catch(() => {}); const message = err instanceof Error ? err.message : String(err); 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 6e32690b39..ed77d4be62 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { connectToDaemon } from "../../utils/client.js"; -import type { CommandOptions } from "../../output/index.js"; +import { waitForStop } from "../../utils/wait-for-stop.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; import { fetchProjectedTimelineItems, LIVE_HISTORY_FETCH_TIMEOUT_MS, @@ -31,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( @@ -52,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); @@ -61,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 */ @@ -97,6 +256,7 @@ export async function runLogsCommand( process.exit(1); } + const sinceTimestampMs = parseSinceTimestamp(options.since); const client = await connectToDaemon({ target: options.daemonTarget }); try { @@ -117,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) { @@ -158,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; @@ -171,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); @@ -193,47 +362,47 @@ 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(); } }); await unsubscribe.ready; console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); - // Wait for interrupt - await new Promise((resolve) => { - const cleanup = () => { - unsubscribe(); - resolve(); - }; - - process.on("SIGINT", cleanup); - process.on("SIGTERM", cleanup); - }); - + await waitForStop(); + writer.end(); + unsubscribe(); await client.close(); } diff --git a/packages/cli/src/commands/agent/ls.test.ts b/packages/cli/src/commands/agent/ls.test.ts index c66b34b94d..76b1787ce3 100644 --- a/packages/cli/src/commands/agent/ls.test.ts +++ b/packages/cli/src/commands/agent/ls.test.ts @@ -1,5 +1,60 @@ import { describe, expect, it } from "vitest"; -import { buildAgentLsFetchOptions } from "./ls.js"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { buildAgentLsFetchOptions, listAgents } from "./ls.js"; + +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + +function createPagingClient() { + const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({ + id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`, + provider: "codex", + title: `agent ${n}`, + status: "closed", + archivedAt: null, + cwd: "/tmp/project", + createdAt: `2026-10-0${n}T00:00:00.000Z`, + updatedAt: `2026-10-0${n}T00:00:00.000Z`, + labels: {}, + model: null, + lastUserMessageAt: null, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + })); + + // The fake daemon pages two agents at a time, newest first by the requested key + // (updated_at by default), and agent 1 is updated right after the first page is served. + const fetchAgents = async (options?: { + sort?: { key: string }[]; + page?: { cursor?: string }; + }) => { + const key: "createdAt" | "updatedAt" = + options?.sort?.[0]?.key === "created_at" ? "createdAt" : "updatedAt"; + const cursor = options?.page?.cursor; + const ordered = daemonAgents + .filter((agent) => cursor === undefined || agent[key] < cursor) + .sort((left, right) => right[key].localeCompare(left[key])); + const page = ordered.slice(0, 2); + const hasMore = ordered.length > 2; + if (cursor === undefined) { + daemonAgents[2]!.updatedAt = "2026-10-09T00:00:00.000Z"; + } + return { + entries: page.map((agent) => ({ agent })), + pageInfo: { nextCursor: hasMore ? page[page.length - 1]![key] : null }, + }; + }; + return { fetchAgents, close: async () => {} }; +} describe("buildAgentLsFetchOptions", () => { it("fetches active agents by default", () => { @@ -23,15 +78,21 @@ describe("buildAgentLsFetchOptions", () => { }); }); + it("asks the daemon for internal agents only with --internal", () => { + expect(buildAgentLsFetchOptions({ background: true, global: true })).toEqual({ + filter: { includeBackground: true }, + }); + }); + it("fetches global non-archived agents for -g", () => { expect(buildAgentLsFetchOptions({ global: true })).toEqual({}); }); - it("keeps -a within the active scope", () => { + it("includes archived and background agents with -a", () => { expect(buildAgentLsFetchOptions({ all: true })).toEqual({ - scope: "active", filter: { includeArchived: true, + includeBackground: true, }, }); }); @@ -40,6 +101,7 @@ describe("buildAgentLsFetchOptions", () => { expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({ filter: { includeArchived: true, + includeBackground: true, }, }); }); @@ -59,3 +121,13 @@ describe("buildAgentLsFetchOptions", () => { }); }); }); + +describe("runLsCommand", () => { + it("lists agents on every page, including one updated during the listing", async () => { + const result = await listAgents({ daemonTarget, all: true, global: true }, async () => + createPagingClient(), + ); + + expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]); + }); +}); diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index bb2189c7c4..55674a2f87 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -12,8 +12,9 @@ type FetchAgentsOptions = NonNullable< export function addLsOptions(cmd: Command): Command { return cmd .description("List agents. By default excludes archived agents.") - .option("-a, --all", "Include archived agents") + .option("-a, --all", "Include archived and background agents") .option("-g, --global", "List agents across all directories") + .option("--background", "Include background workspace agents") .option( "--label ", "Filter by label (can be used multiple times)", @@ -109,6 +110,8 @@ export interface AgentLsOptions extends CommandOptions { all?: boolean; /** -g: List agents across all directories */ global?: boolean; + /** --background: Include background workspace agents */ + background?: boolean; /** Filter by specific status */ status?: string; /** Filter by specific cwd */ @@ -133,7 +136,7 @@ function parseLabelFilters(labels: string[] | undefined): Record } export function buildAgentLsFetchOptions( - options: Pick, + options: Pick, ): FetchAgentsOptions { const labelFilters = parseLabelFilters(options.label); const normalizedThinkingOptionId = options.thinking?.trim(); @@ -142,6 +145,9 @@ export function buildAgentLsFetchOptions( if (options.all) { daemonFilter.includeArchived = true; } + if (options.background || options.all) { + daemonFilter.includeBackground = true; + } if (Object.keys(labelFilters).length > 0) { daemonFilter.labels = labelFilters; } @@ -150,7 +156,7 @@ export function buildAgentLsFetchOptions( } const fetchOptions: FetchAgentsOptions = {}; - if (!options.global) { + if (!options.global && !options.all) { fetchOptions.scope = "active"; } if (Object.keys(daemonFilter).length > 0) { @@ -159,18 +165,35 @@ export function buildAgentLsFetchOptions( return fetchOptions; } +interface AgentLsClient { + fetchAgents(options?: FetchAgentsOptions): Promise<{ + entries: { agent: AgentSnapshotPayload }[]; + pageInfo: { nextCursor: string | null }; + }>; + close(): Promise; +} + /** * Agent ls command semantics: * - `paseo agent ls` → active non-archived agents * - `paseo agent ls -g` → global non-archived agents - * - `paseo agent ls -a` → active agents, including archived + * - `paseo agent ls -a` → all agents, including archived and background * - `paseo agent ls -ag` → global agents, including archived */ export async function runLsCommand( options: AgentLsOptions, _command: Command, ): Promise { - const client = await connectToDaemon({ target: options.daemonTarget }); + return listAgents(options); +} + +export async function listAgents( + options: AgentLsOptions, + connect: ( + options: Parameters[0], + ) => Promise = connectToDaemon, +): Promise { + const client = await connect({ target: options.daemonTarget }); try { const normalizedThinkingOptionId = options.thinking?.trim(); @@ -183,8 +206,19 @@ export async function runLsCommand( } const labelFilters = parseLabelFilters(options.label); - const fetchPayload = await client.fetchAgents(buildAgentLsFetchOptions(options)); - let agents = fetchPayload.entries.map((entry) => entry.agent); + const fetchOptions = buildAgentLsFetchOptions(options); + let agents: AgentSnapshotPayload[] = []; + let cursor: string | undefined; + // createdAt never changes, so agents cannot move past the cursor while the pages are read. + do { + const payload = await client.fetchAgents({ + ...fetchOptions, + sort: [{ key: "created_at", direction: "desc" }], + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + agents.push(...payload.entries.map((entry) => entry.agent)); + cursor = payload.pageInfo.nextCursor ?? undefined; + } while (cursor); // By default, exclude archived agents. `-a` includes them. if (!options.all) { diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index b4e3b20bde..5e64ccba81 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; +import { resolveCallerAgentId as resolveRunCallerAgentId } from "../../utils/caller-agent.js"; import { resolveExistingRunWorkspace, - resolveRunCallerAgentId, runRunCommand, + waitsForFinish, type AgentRunOptions, } from "./run"; @@ -21,6 +22,15 @@ function daemonWithAgents(...agentIds: string[]) { }; } +describe("run wait policy", () => { + it("waits by default and stops waiting on --no-wait or a legacy alias", () => { + expect(waitsForFinish({})).toBe(true); + expect(waitsForFinish({ wait: false })).toBe(false); + expect(waitsForFinish({ background: true })).toBe(false); + expect(waitsForFinish({ detach: true })).toBe(false); + }); +}); + describe("managed agent caller context", () => { it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => { await expect( @@ -38,17 +48,20 @@ describe("managed agent caller context", () => { ).resolves.toBeUndefined(); }); - it("fails instead of dropping the caller when the lookup loses its connection", async () => { - const disconnectedDaemon = { - async fetchAgent(): Promise { - throw new DaemonConnectionError("Connection lost before message could be sent"); - }, - }; + it.each(["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"] as const)( + "preserves %s instead of dropping the caller", + async (code) => { + const disconnectedDaemon = { + async fetchAgent(): Promise { + throw new DaemonConnectionError("Caller lookup transport failed", code); + }, + }; - await expect( - resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }), - ).rejects.toBeInstanceOf(DaemonConnectionError); - }); + await expect( + resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }), + ).rejects.toBeInstanceOf(DaemonConnectionError); + }, + ); it("omits blank caller ids", async () => { await expect( diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 82817684c2..86cca5f75c 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -4,7 +4,7 @@ import { StructuredAgentResponseError, } from "@getpaseo/server/agent-response"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; +import { resolveCallerAgentId } from "../../utils/caller-agent.js"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, @@ -27,10 +27,13 @@ export function addRunOptions(cmd: Command): Command { cmd .description("Create and start an agent with a task") .argument("", "The task/prompt for the agent") - .option("-d, --background", "Run in background") + .option("--no-wait", "Return immediately without waiting for completion") + // COMPAT(backgroundRunFlag): --background was the documented spelling of + // --no-wait. Added in v0.2.0; remove after 2027-04-01. + .addOption(new Option("-d, --background", "Legacy alias for --no-wait").hideHelp()) // COMPAT(detachRunFlag): --detach used to mean background execution, not // ownership transfer. Added in v0.2.0; remove after 2027-01-17. - .addOption(new Option("--detach", "Legacy alias for --background").hideHelp()) + .addOption(new Option("--detach", "Legacy alias for --no-wait").hideHelp()) .option("--title ", "Assign a title to the agent") .addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp()) .option( @@ -111,6 +114,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = { }; export interface AgentRunOptions extends CommandOptions { + wait?: boolean; background?: boolean; detach?: boolean; title?: string; @@ -389,19 +393,25 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche } satisfies CommandError; } + if (options.background) { + console.error("Warning: --background (-d) is deprecated. Use --no-wait instead."); + } + validateRunWorkspaceOptions(options); - if (outputSchema && runsInBackground(options)) { + if (outputSchema && !waitsForFinish(options)) { throw { code: "INVALID_OPTIONS", - message: "--output-schema cannot be used with --background", + message: "--output-schema cannot be used with --no-wait", details: "Structured output requires waiting for the agent to finish", } satisfies CommandError; } } -function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean { - return Boolean(options.background || options.detach); +export function waitsForFinish( + options: Pick<AgentRunOptions, "wait" | "background" | "detach">, +): boolean { + return options.wait !== false && !options.background && !options.detach; } function parseWaitTimeoutOption(waitTimeout: string | undefined): number { @@ -557,7 +567,10 @@ async function resolveRunWorkspace( // TODO: thread the run `prompt` as firstAgentContext so workspace-level // title/branch generation picks up the task description (U8/U6 deferred). const source = buildRunWorkspaceSource(options, cwd); - const result = await client.createWorkspace({ source }); + const result = await client.createWorkspace({ + source, + ...(callerAgentId ? { callerAgentId } : {}), + }); if (!result.workspace) { throw { @@ -611,7 +624,7 @@ export async function runRunCommand( const env = parseRunEnv(options.env); const requestEnv = Object.keys(env).length > 0 ? env : undefined; - const callerAgentId = await resolveRunCallerAgentId(client); + const callerAgentId = await resolveCallerAgentId(client); const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId); const workspaceId = workspace.id; const runCwd = workspace.cwd; @@ -707,8 +720,8 @@ export async function runRunCommand( labels: Object.keys(labels).length > 0 ? labels : undefined, }); - // Default run behavior is foreground: wait for completion unless background execution is set. - if (!runsInBackground(options)) { + // Default run behavior is foreground: wait for completion unless --no-wait is set. + if (waitsForFinish(options)) { const state = await client.waitForFinish(agent.id, waitTimeoutMs); await client.close(); @@ -744,29 +757,3 @@ export async function runRunCommand( throw error; } } - -export interface RunCallerLookupClient { - fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>; -} - -// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run -// sent to another daemon (--host or --home) has no caller there, so it runs as -// a top-level agent instead of failing on an unknown caller. -export async function resolveRunCallerAgentId( - client: RunCallerLookupClient, - env: { PASEO_AGENT_ID?: string } = process.env, -): Promise<string | undefined> { - const agentId = env.PASEO_AGENT_ID?.trim(); - if (!agentId) { - return undefined; - } - const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => { - // A daemon without this agent answers with an error. A lost or timed-out - // connection is not an answer, so it must not drop the caller. - if (error instanceof DaemonConnectionError) { - throw error; - } - return null; - }); - return caller?.agent.id === agentId ? agentId : undefined; -} diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index aa9b5a24cc..69e0c36d9d 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -192,7 +192,10 @@ export async function runSendCommand( options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined; // Send the message - await client.sendAgentMessage(agentIdArg, promptInput, { images }); + await client.sendAgentMessage(agentIdArg, promptInput, { + images, + sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined, + }); // If --no-wait, return immediately if (options.wait === false) { diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index cb42c0b0a5..fd7e845ecd 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])( stdio: "ignore", }); const exited = new Promise((resolve) => child!.once("exit", resolve)); + // The lock file exists before its contents identify the supervisor. + let supervisorPid: number | undefined; await expect - .poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 }) - .toBe(true); - const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")); + .poll( + async () => { + supervisorPid = (await readDaemonInstance(home))?.pid; + return supervisorPid; + }, + { timeout: 10_000 }, + ) + .toBeTypeOf("number"); await f.ok(["status", "--home", home]); child.kill("SIGINT"); await exited; - expect(() => process.kill(lock.pid, 0)).toThrow(); + expect(() => process.kill(supervisorPid!, 0)).toThrow(); expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped"); } finally { child?.kill("SIGTERM"); diff --git a/packages/cli/src/commands/permit/allow.ts b/packages/cli/src/commands/permit/allow.ts index 755bfaf4d7..71bee3f73c 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -1,5 +1,8 @@ import type { Command } from "commander"; -import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, +} from "@getpaseo/protocol/agent-types"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; @@ -55,106 +58,136 @@ export interface PermitAllowOptions extends CommandOptions { host?: string; } -export async function runAllowCommand( - agentIdOrPrefix: string, - reqId: string | undefined, - options: PermitAllowOptions, - _command: Command, -): Promise<PermitAllowResult> { - // No validation needed - if no reqId provided, allow all by default +export interface PermitClient { + fetchAgent(options: { agentId: string }): Promise<{ + agent: { id: string; pendingPermissions?: AgentPermissionRequest[] }; + } | null>; + respondToPermissionAndWait( + agentId: string, + requestId: string, + response: AgentPermissionResponse, + ): Promise<unknown>; + close(): Promise<void>; +} - // Parse input JSON if provided - let updatedInput: Record<string, unknown> | undefined; - if (options.input) { - try { - updatedInput = JSON.parse(options.input); - } catch (err) { - const error: CommandError = { - code: "INVALID_JSON", - message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`, - details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'', - }; - throw error; - } - } +export type PermitClientConnector = ( + options: Parameters<typeof connectToDaemon>[0], +) => Promise<PermitClient>; - const client = await connectToDaemon({ target: options.daemonTarget }); +export function createAllowCommand(connect: PermitClientConnector = connectToDaemon) { + return async function runAllowCommand( + agentIdOrPrefix: string, + reqId: string | undefined, + options: PermitAllowOptions, + _command: Command, + ): Promise<PermitAllowResult> { + // No validation needed - if no reqId provided, allow all by default - try { - const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); - if (!fetchResult) { - await client.close(); - const error: CommandError = { - code: "AGENT_NOT_FOUND", - message: `Agent not found: ${agentIdOrPrefix}`, - details: 'Use "paseo ls" to list available agents', - }; - throw error; - } - const agent = fetchResult.agent; - const resolvedAgentId = agent.id; - - // Get pending permissions for this agent - const pendingPermissions = agent.pendingPermissions || []; - if (pendingPermissions.length === 0) { - await client.close(); - const error: CommandError = { - code: "NO_PENDING_PERMISSIONS", - message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, - }; - throw error; - } - - // Determine which permissions to allow - let permissionsToAllow: AgentPermissionRequest[]; - if (!reqId || options.all) { - // Default: allow all pending permissions if no req_id specified - // --all flag is kept as an explicit alias for clarity - permissionsToAllow = pendingPermissions; - } else { - // Find permission by ID prefix - const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId)); - if (!permission) { - await client.close(); + // Parse input JSON if provided + let updatedInput: Record<string, unknown> | undefined; + if (options.input) { + try { + updatedInput = JSON.parse(options.input); + } catch (err) { const error: CommandError = { - code: "PERMISSION_NOT_FOUND", - message: `Permission request not found: ${reqId}`, - details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + code: "INVALID_JSON", + message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`, + details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'', }; throw error; } - permissionsToAllow = [permission]; } - // Allow permissions - const results: PermissionResponseItem[] = await Promise.all( - permissionsToAllow.map(async (permission) => { - await client.respondToPermission(resolvedAgentId, permission.id, { - behavior: "allow", - ...(updatedInput ? { updatedInput } : {}), - }); - return toPermissionResponseItem(resolvedAgentId, permission, "allowed"); - }), - ); + const client = await connect({ target: options.daemonTarget }); - await client.close(); + try { + const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); + if (!fetchResult) { + await client.close(); + const error: CommandError = { + code: "AGENT_NOT_FOUND", + message: `Agent not found: ${agentIdOrPrefix}`, + details: 'Use "paseo ls" to list available agents', + }; + throw error; + } + const agent = fetchResult.agent; + const resolvedAgentId = agent.id; - return { - type: "list", - data: results, - schema: permitResponseSchema, - }; - } catch (err) { - await client.close().catch(() => {}); - // Re-throw CommandErrors - if (err && typeof err === "object" && "code" in err) { - throw err; + // Get pending permissions for this agent + const pendingPermissions = agent.pendingPermissions || []; + if (pendingPermissions.length === 0) { + await client.close(); + const error: CommandError = { + code: "NO_PENDING_PERMISSIONS", + message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, + }; + throw error; + } + + // Determine which permissions to allow + let permissionsToAllow: AgentPermissionRequest[]; + if (!reqId || options.all) { + // Default: allow all pending permissions if no req_id specified + // --all flag is kept as an explicit alias for clarity + permissionsToAllow = pendingPermissions; + } else { + // Find permission by ID prefix + const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId)); + if (!permission) { + await client.close(); + const error: CommandError = { + code: "PERMISSION_NOT_FOUND", + message: `Permission request not found: ${reqId}`, + details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + }; + throw error; + } + permissionsToAllow = [permission]; + } + + // Allow permissions + const results: PermissionResponseItem[] = await Promise.all( + permissionsToAllow.map(async (permission) => { + try { + await client.respondToPermissionAndWait(resolvedAgentId, permission.id, { + behavior: "allow", + ...(updatedInput ? { updatedInput } : {}), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "PERMISSION_RESPONSE_UNCONFIRMED", + message: `Permission response unconfirmed for request ${permission.id}: ${message}`, + details: 'Use "paseo permit ls" to check whether the request is still pending', + }; + throw error; + } + return toPermissionResponseItem(resolvedAgentId, permission, "allowed"); + }), + ); + + await client.close(); + + return { + type: "list", + data: results, + schema: permitResponseSchema, + }; + } catch (err) { + await client.close().catch(() => {}); + // Re-throw CommandErrors + if (err && typeof err === "object" && "code" in err) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "ALLOW_PERMISSION_FAILED", + message: `Failed to allow permission: ${message}`, + }; + throw error; } - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "ALLOW_PERMISSION_FAILED", - message: `Failed to allow permission: ${message}`, - }; - throw error; - } + }; } + +export const runAllowCommand = createAllowCommand(); diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index fe6c619e32..69a4a35a4d 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -6,6 +6,7 @@ import { permitResponseSchema, toPermissionResponseItem, type PermissionResponseItem, + type PermitClientConnector, } from "./allow.js"; export type PermitDenyResult = ListResult<PermissionResponseItem>; @@ -17,98 +18,114 @@ export interface PermitDenyOptions extends CommandOptions { host?: string; } -export async function runDenyCommand( - agentIdOrPrefix: string, - reqId: string | undefined, - options: PermitDenyOptions, - _command: Command, -): Promise<PermitDenyResult> { - // Validate arguments - if (!options.all && !reqId) { - const error: CommandError = { - code: "MISSING_ARGUMENT", - message: "Request ID is required unless --all is specified", - details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all", - }; - throw error; - } - - const client = await connectToDaemon({ target: options.daemonTarget }); - - try { - const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); - if (!fetchResult) { - await client.close(); +export function createDenyCommand(connect: PermitClientConnector = connectToDaemon) { + return async function runDenyCommand( + agentIdOrPrefix: string, + reqId: string | undefined, + options: PermitDenyOptions, + _command: Command, + ): Promise<PermitDenyResult> { + // Validate arguments + if (!options.all && !reqId) { const error: CommandError = { - code: "AGENT_NOT_FOUND", - message: `Agent not found: ${agentIdOrPrefix}`, - details: 'Use "paseo ls" to list available agents', - }; - throw error; - } - const agent = fetchResult.agent; - const resolvedAgentId = agent.id; - - // Get pending permissions for this agent - const pendingPermissions = agent.pendingPermissions || []; - if (pendingPermissions.length === 0) { - await client.close(); - const error: CommandError = { - code: "NO_PENDING_PERMISSIONS", - message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, + code: "MISSING_ARGUMENT", + message: "Request ID is required unless --all is specified", + details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all", }; throw error; } - // Determine which permissions to deny - let permissionsToDeny: AgentPermissionRequest[]; - if (options.all) { - permissionsToDeny = pendingPermissions; - } else { - // Find permission by ID prefix - const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!)); - if (!permission) { + const client = await connect({ target: options.daemonTarget }); + + try { + const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); + if (!fetchResult) { await client.close(); const error: CommandError = { - code: "PERMISSION_NOT_FOUND", - message: `Permission request not found: ${reqId}`, - details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + code: "AGENT_NOT_FOUND", + message: `Agent not found: ${agentIdOrPrefix}`, + details: 'Use "paseo ls" to list available agents', }; throw error; } - permissionsToDeny = [permission]; + const agent = fetchResult.agent; + const resolvedAgentId = agent.id; + + // Get pending permissions for this agent + const pendingPermissions = agent.pendingPermissions || []; + if (pendingPermissions.length === 0) { + await client.close(); + const error: CommandError = { + code: "NO_PENDING_PERMISSIONS", + message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, + }; + throw error; + } + + // Determine which permissions to deny + let permissionsToDeny: AgentPermissionRequest[]; + if (options.all) { + permissionsToDeny = pendingPermissions; + } else { + // Find permission by ID prefix + const permission = pendingPermissions.find( + (p) => p.id === reqId || p.id.startsWith(reqId!), + ); + if (!permission) { + await client.close(); + const error: CommandError = { + code: "PERMISSION_NOT_FOUND", + message: `Permission request not found: ${reqId}`, + details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + }; + throw error; + } + permissionsToDeny = [permission]; + } + + // Deny permissions + const results: PermissionResponseItem[] = await Promise.all( + permissionsToDeny.map(async (permission) => { + try { + await client.respondToPermissionAndWait(resolvedAgentId, permission.id, { + behavior: "deny", + ...(options.message ? { message: options.message } : {}), + ...(options.interrupt ? { interrupt: true } : {}), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "PERMISSION_RESPONSE_UNCONFIRMED", + message: `Permission response unconfirmed for request ${permission.id}: ${message}`, + details: 'Use "paseo permit ls" to check whether the request is still pending', + }; + throw error; + } + return toPermissionResponseItem(resolvedAgentId, permission, "denied"); + }), + ); + + await client.close(); + + return { + type: "list", + data: results, + schema: permitResponseSchema, + }; + } catch (err) { + await client.close().catch(() => {}); + // Re-throw CommandErrors + if (err && typeof err === "object" && "code" in err) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "DENY_PERMISSION_FAILED", + message: `Failed to deny permission: ${message}`, + }; + throw error; } - - // Deny permissions - const results: PermissionResponseItem[] = await Promise.all( - permissionsToDeny.map(async (permission) => { - await client.respondToPermission(resolvedAgentId, permission.id, { - behavior: "deny", - ...(options.message ? { message: options.message } : {}), - ...(options.interrupt ? { interrupt: true } : {}), - }); - return toPermissionResponseItem(resolvedAgentId, permission, "denied"); - }), - ); - - await client.close(); - - return { - type: "list", - data: results, - schema: permitResponseSchema, - }; - } catch (err) { - await client.close().catch(() => {}); - // Re-throw CommandErrors - if (err && typeof err === "object" && "code" in err) { - throw err; - } - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DENY_PERMISSION_FAILED", - message: `Failed to deny permission: ${message}`, - }; - throw error; - } + }; } + +export const runDenyCommand = createDenyCommand(); diff --git a/packages/cli/src/commands/permit/permit-response.test.ts b/packages/cli/src/commands/permit/permit-response.test.ts new file mode 100644 index 0000000000..5328b6ecb8 --- /dev/null +++ b/packages/cli/src/commands/permit/permit-response.test.ts @@ -0,0 +1,177 @@ +import { setImmediate as waitForCommandContinuations } from "node:timers/promises"; +import { Command } from "commander"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, +} from "@getpaseo/protocol/agent-types"; +import { describe, expect, it } from "vitest"; +import { createAllowCommand, type PermitClient } from "./allow.js"; +import { createDenyCommand } from "./deny.js"; + +const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f"; +const requests: AgentPermissionRequest[] = [ + { + id: "permission-write", + provider: "codex", + name: "CodexBash", + kind: "tool", + }, + { id: "permission-edit", provider: "codex", name: "CodexBash", kind: "tool" }, +]; + +class PermissionClient implements PermitClient { + readonly pending = [...requests]; + readonly responses = new Map<string, AgentPermissionResponse>(); + closed = false; + private notifyReceived = () => {}; + private readonly confirmations = new Map< + string, + { + resolve: () => void; + reject: (error: Error) => void; + } + >(); + + async fetchAgent() { + return { agent: { id: agentId, pendingPermissions: this.pending } }; + } + + async respondToPermission( + _agentId: string, + requestId: string, + response: AgentPermissionResponse, + ) { + this.responses.set(requestId, response); + this.notifyReceived(); + } + + async respondToPermissionAndWait( + responseAgentId: string, + requestId: string, + response: AgentPermissionResponse, + ): Promise<void> { + const confirmation = new Promise<void>((resolve, reject) => { + this.confirmations.set(requestId, { resolve, reject }); + }); + await this.respondToPermission(responseAgentId, requestId, response); + await confirmation; + } + + async close() { + this.closed = true; + } + + async waitForResponses(count: number) { + while (this.responses.size < count) { + await new Promise<void>((resolve) => { + this.notifyReceived = resolve; + }); + } + } + + confirm(requestId: string) { + const confirmation = this.confirmations.get(requestId); + if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`); + this.pending.splice( + this.pending.findIndex((request) => request.id === requestId), + 1, + ); + confirmation.resolve(); + } + + fail(requestId: string, error: Error) { + const confirmation = this.confirmations.get(requestId); + if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`); + confirmation.reject(error); + } +} + +const options = { + daemonTarget: { kind: "endpoint" as const, host: "test.invalid" }, +}; +const actions = [ + { name: "allow", create: createAllowCommand, result: "allowed" }, + { name: "deny", create: createDenyCommand, result: "denied" }, +]; +for (const action of actions) { + describe(`permit ${action.name}`, () => { + it("returns success and closes only after confirmation", async () => { + const client = new PermissionClient(); + let completed = false; + const command = action.create(async () => client)( + agentId, + requests[0].id, + options, + new Command(), + ); + void command.then( + () => (completed = true), + () => false, + ); + await client.waitForResponses(1); + await waitForCommandContinuations(); + expect(completed).toBe(false); + expect(client.closed).toBe(false); + expect(client.pending).toEqual(requests); + client.confirm(requests[0].id); + const result = await command; + expect(result.data).toEqual([ + { + requestId: requests[0].id, + agentId, + agentShortId: agentId.slice(0, 7), + name: "CodexBash", + result: action.result, + }, + ]); + expect(client.closed).toBe(true); + expect(client.pending).toEqual([requests[1]]); + }); + + it("waits for every confirmation with --all", async () => { + const client = new PermissionClient(); + let completed = false; + const command = action.create(async () => client)( + agentId, + undefined, + { ...options, all: true }, + new Command(), + ); + void command.then( + () => (completed = true), + () => false, + ); + await client.waitForResponses(2); + client.confirm(requests[0].id); + // Wait beyond the first reply's promise continuations while withholding the second. + await waitForCommandContinuations(); + expect(completed).toBe(false); + expect(client.closed).toBe(false); + client.confirm(requests[1].id); + const result = await command; + expect(result.data.map((item) => item.result)).toEqual([action.result, action.result]); + expect(client.closed).toBe(true); + expect(client.pending).toEqual([]); + }); + + it("rejects an unconfirmed response without returning success", async () => { + const client = new PermissionClient(); + const command = action.create(async () => client)( + agentId, + requests[0].id, + options, + new Command(), + ); + const failure = expect(command).rejects.toMatchObject({ + code: "PERMISSION_RESPONSE_UNCONFIRMED", + message: expect.stringContaining(requests[0].id), + details: 'Use "paseo permit ls" to check whether the request is still pending', + }); + await client.waitForResponses(1); + client.fail(requests[0].id, new Error("Timeout waiting for message (15000ms)")); + await failure; + expect(client.closed).toBe(true); + expect(client.pending).toEqual(requests); + }); + }); +} diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index bad3bc0436..8ea6de9dfb 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -255,12 +255,12 @@ describe("plugin management commands", () => { const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const command = createPluginCommand(); - await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], { + await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], { from: "user", }); expect(installPluginSource).toHaveBeenCalledWith({ - source: "owner/monorepo:plugins/review", + source: "git:owner/monorepo:plugins/review", }); stderr.mockRestore(); }); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 993c690f68..86dc5889b8 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -1,9 +1,12 @@ +import path from "node:path"; +import { expandUserPath } from "../../classify.js"; import { createInterface } from "node:readline/promises"; import { reviewPluginUpdates, type UpdateOutcome } from "./update.js"; import { Command } from "commander"; import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages"; import { formatPluginSourceReference, + parsePluginSourceReference, formatPluginIdentity, } from "@getpaseo/protocol/plugin-source-reference"; import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; @@ -125,7 +128,18 @@ export async function runPluginInstallCommand( process.stderr.write( "Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n", ); - const sourceReference = formatPluginSourceReference(source, options.path); + const reference = parsePluginSourceReference(source); + let resolvedSource = source; + if (reference.kind === "directory") { + const directory = expandUserPath(reference.source); + // An absolute path can target a daemon on a different operating system. + const absoluteDirectory = + path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory) + ? directory + : path.resolve(directory); + resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath); + } + const sourceReference = formatPluginSourceReference(resolvedSource, options.path); const data = await withPluginSourceClient(options.daemonTarget, (client) => client.installPluginSource({ source: sourceReference, @@ -215,10 +229,12 @@ export function createPluginCommand(): Command { plugin .command("install") .alias("add") - .description("Trust and install a plugin from a directory, Git repository, or npm package") + .description( + "Trust and install a plugin from a registry, directory, Git repository, or npm package", + ) .argument( "<source>", - "Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path", + "Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source", ) .option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref <ref>", "Git branch, tag, or commit") diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index df0c13295b..a3350ecb8c 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; @@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js"; const directories: string[] = []; const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc"); async function typecheckPlugin(directory: string): Promise<void> { @@ -28,6 +29,47 @@ afterEach(async () => { }); describe("plugin scaffold", () => { + it("creates an author overview beside the manifest", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "hello-plugin"); + await scaffoldPluginDirectory(directory); + + const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8"); + const comments = overview.match(/<!--[\s\S]*?-->/g) ?? []; + expect(comments).toHaveLength(1); + expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe(""); + expect(overview).toContain("required to list"); + expect(overview).toContain("README"); + expect(overview).toContain("choosing a plugin in Paseo"); + expect(overview).toContain("Length follows complexity"); + expect(overview).toContain("1. Describe what your plugin is and does"); + expect(overview).toContain("2. Explain how it works only when it is not obvious"); + expect(overview).toContain("3. Explain setup when needed"); + expect(overview).toContain("4. Explain capabilities and settings"); + expect(overview).toContain("permissions"); + expect(overview).toContain("known limits"); + expect(overview).not.toContain("This plugin entry was imported"); + const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + expect(packageJson.files).toContain("OVERVIEW.md"); + expect(await readdir(directory)).toContain("paseo-plugin.json"); + }); + + it("includes the author overview in the packed npm artifact", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "packed-plugin"); + await scaffoldPluginDirectory(directory); + + const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", { + cwd: directory, + }); + const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[]; + expect(artifact.files.map((file) => file.path)).toEqual( + expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]), + ); + }); + it( "creates a standalone split-runtime project that typechecks", { timeout: 15_000 }, @@ -51,6 +93,8 @@ describe("plugin scaffold", () => { expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( { id: "hello-plugin", + $comment: expect.stringContaining('"icon": "assets/icon.png"'), + media: [], requirements: { paseo: `>=${cliPackageJson.version}` }, }, ); @@ -60,6 +104,7 @@ describe("plugin scaffold", () => { version: "0.0.0", files: [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index 448aa956fa..b1eb53a6ef 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -3,6 +3,34 @@ import path from "node:path"; import { PluginIdSchema } from "@getpaseo/protocol/messages"; import { resolveCliVersion } from "../../version.js"; +const OVERVIEW = `<!-- +OVERVIEW.md is required to list your plugin in the registry. Keep it beside +paseo-plugin.json in your repository at the pinned commit and in your npm package. +Replace this comment with useful facts before publishing. + +This overview is for people choosing a plugin in Paseo, on the plugin page under +its install command. README assumes a GitHub audience and includes technical +details, installation instructions, and badges. Long, AI-generated READMEs leave +people reading past that material to understand what the plugin does. + +Write these parts in order, including only what helps someone decide to install: + +1. Describe what your plugin is and does in plain terms first. +2. Explain how it works only when it is not obvious. +3. Explain setup when needed: settings, accounts, tokens, providers, tools, or + other plugins. Include applicable daemon version and operating system + requirements. Setup guidance is allowed; installation instructions are not. +4. Explain capabilities and settings worth understanding, what each option does, + what the plugin reads or sends and where, permissions, and known limits. + +Length follows complexity; a theme needs one paragraph. Choose headings only +when they help. Use sentence case and plain factual language, with no em dashes. +Omit installation commands, badges, changelog, contributing or license sections, +marketing, and unsupported claims. Avoid implementation filler such as empty +cleanup functions, theme-token field lists, or lists of absent features. +--> +`; + const TSCONFIG = { compilerOptions: { target: "ES2020", @@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory( version: "0.0.0", files: [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", @@ -177,8 +206,19 @@ export async function scaffoldPluginDirectory( const files = new Map<string, string>([ [ "paseo-plugin.json", - `${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`, + `${JSON.stringify( + { + id, + $comment: + 'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.', + media: [], + requirements: { paseo: `>=${version}` }, + }, + null, + 2, + )}\n`, ], + ["OVERVIEW.md", OVERVIEW], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], ["index.client.tsx", CLIENT_ENTRY], diff --git a/packages/cli/src/commands/usage/index.test.ts b/packages/cli/src/commands/usage/index.test.ts new file mode 100644 index 0000000000..9c2e3e8bbf --- /dev/null +++ b/packages/cli/src/commands/usage/index.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from "vitest"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { render } from "../../output/index.js"; +import { createCli } from "../../cli.js"; +import { runUsageCommand } from "./index.js"; + +const options = { daemonTarget: { kind: "endpoint" as const, host: "example.test:1234" } }; +const reports: UsageReportEntry[] = [ + { + id: "plugin.source:work.account", + sourceId: "plugin.source", + sourceLabel: "Plugin Source", + account: { label: "Work" }, + fetchedAt: "2026-10-09T12:00:00Z", + icon: "<svg />", + report: { + status: "available", + planLabel: "Pro", + windows: [ + { + id: "5h", + label: "Five hours", + usedPct: 12.5, + remainingPct: 87.5, + resetsAt: "2026-10-09T17:00:00Z", + runsOutAt: "2026-10-09T16:00:00Z", + shortfallPct: 4.25, + }, + { id: "week", label: "Weekly", usedPct: null, remainingPct: 0 }, + { id: "extra", label: "Extra", usedPct: 0 }, + ], + balances: [ + { + id: "credit", + label: "Credit", + used: 0, + remaining: 2.5, + limit: 10, + unit: "usd", + resetsAt: "2026-11-01T00:00:00Z", + }, + { id: "requests", label: "Requests", remaining: null, unit: "requests" }, + ], + details: [{ id: "tier", label: "Tier", value: "Enterprise" }], + }, + }, + { + id: "plugin.source:personal", + sourceId: "plugin.source", + sourceLabel: "Plugin Source", + account: {}, + fetchedAt: "2026-10-09T12:00:00Z", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex login" }, + }, + loginErrors: [ + { + harness: "Claude Code", + report: { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-10-08T00:00:00Z", refreshedBy: "claude" }, + }, + }, + { + harness: "Codex", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex login" }, + }, + }, + { harness: "Other", report: { status: "error", error: "Login service failed" } }, + ], + }, + { + id: "other:broken", + sourceId: "other", + sourceLabel: "Other", + account: {}, + fetchedAt: "2026-10-09T12:00:00Z", + report: { status: "error", error: "Source failed" }, + }, + { + id: "other:noquota", + sourceId: "other", + sourceLabel: "Other", + account: {}, + fetchedAt: "2026-10-09T12:00:00Z", + report: { + status: "unavailable", + problem: { kind: "no_quota", detail: "No quota for this plan" }, + }, + }, +]; + +function host(entries = reports, supported = true) { + const requests: Array<Parameters<NonNullable<Parameters<typeof runUsageCommand>[2]>>[0]> = []; + const queries: Array< + { agentId?: string; forceRefresh?: boolean; reportIds?: string[] } | undefined + > = []; + let closed = 0; + return { + requests, + queries, + get closed() { + return closed; + }, + connect: async (input: (typeof requests)[number]) => { + requests.push(input); + return { + getLastServerInfoMessage: () => ({ features: { usageSources: supported } }), + listUsageReports: async (query?: (typeof queries)[number]) => { + queries.push(query); + const selected = query?.reportIds + ? entries.filter((entry) => query.reportIds!.includes(entry.id)) + : entries; + return { requestId: "request", reports: selected }; + }, + close: async () => { + closed++; + }, + }; + }, + }; +} + +it("preserves complete JSON entries, numeric values and quiet full IDs", async () => { + const daemon = host(); + const result = await runUsageCommand(options, { kind: "list" }, daemon.connect); + expect(JSON.parse(render(result, { format: "json" }))).toEqual(reports); + expect(render(result, { quiet: true })).toBe(reports.map((entry) => entry.id).join("\n")); + expect(daemon.requests).toEqual([{ target: options.daemonTarget }]); + expect(daemon.closed).toBe(1); +}); + +it("lists all windows, balances, missing values, failures and remedies", async () => { + const result = await runUsageCommand(options, { kind: "list" }, host().connect); + const text = render(result, { noColor: true }); + for (const expected of [ + "REPORT ID", + "SOURCE", + "ACCOUNT", + "STATUS", + "USAGE", + "FETCHED", + "Five hours: 12.5% used, 87.5% remaining", + "Weekly: - used, 0% remaining", + "Extra: 0% used, - remaining", + "Credit: 0 used, 2.5 remaining, 10 limit (usd)", + "Requests: - used, - remaining, - limit (requests)", + "Login expired at 2026-10-08T00:00:00Z. Run claude", + "Login rejected (HTTP 401). Run codex login", + "Other: Login service failed", + "Source failed", + "No quota for this plan", + ]) + expect(text).toContain(expected); + expect(render(result, { noHeaders: true })).not.toContain("REPORT ID"); +}); + +it("inspects exact opaque IDs and includes resets, forecasts, details and login failures", async () => { + const daemon = host(); + const result = await runUsageCommand( + options, + { kind: "inspect", id: reports[0]!.id }, + daemon.connect, + ); + expect(JSON.parse(render(result, { format: "json" }))).toEqual(reports[0]); + const text = render(result); + for (const expected of [ + "Source ID: plugin.source", + "Plan: Pro", + "Resets: 2026-10-09T17:00:00Z", + "Runs out: 2026-10-09T16:00:00Z", + "Shortfall: 4.25%", + "Resets: -", + "Resets: 2026-11-01T00:00:00Z", + "Tier: Enterprise", + ]) + expect(text).toContain(expected); + const failure = await runUsageCommand( + options, + { kind: "inspect", id: reports[1]!.id }, + daemon.connect, + ); + expect(render(failure)).toContain("Login failures:\n Claude Code: Login expired at"); + expect(render(failure)).toContain(" Other: Login service failed"); +}); + +it("discovers before inspecting, then refreshes only the selected report", async () => { + const daemon = host(); + await runUsageCommand( + { ...options, refresh: true }, + { kind: "inspect", id: reports[0]!.id }, + daemon.connect, + ); + expect(daemon.queries).toEqual([undefined, { reportIds: [reports[0]!.id], forceRefresh: true }]); +}); + +it("rejects agent selection combined with inspect before connecting", async () => { + const daemon = host(); + await expect( + runUsageCommand( + { ...options, agent: "agent-A" }, + { kind: "inspect", id: reports[0]!.id }, + daemon.connect, + ), + ).rejects.toMatchObject({ code: "INVALID_OPTIONS" }); + expect(daemon.requests).toEqual([]); +}); + +it("passes agent account selection and refresh without converting it to token consumption", async () => { + const daemon = host(); + await runUsageCommand( + { ...options, agent: "agent-123", refresh: true }, + { kind: "list" }, + daemon.connect, + ); + expect(daemon.queries).toEqual([{ agentId: "agent-123", forceRefresh: true }]); +}); + +it("returns an empty array in JSON and explains empty discovery in human output", async () => { + const result = await runUsageCommand(options, { kind: "list" }, host([]).connect); + expect(render(result, { format: "json" })).toBe("[]"); + expect(render(result)).toBe("No usage reports returned"); + expect(render(result, { quiet: true })).toBe(""); +}); + +it("rejects unknown IDs and prefixes and closes the client", async () => { + const daemon = host(); + await expect( + runUsageCommand(options, { kind: "inspect", id: "plugin.source:work" }, daemon.connect), + ).rejects.toEqual({ + code: "REPORT_NOT_FOUND", + message: "Unknown usage report: plugin.source:work", + }); + expect(daemon.closed).toBe(1); +}); + +it("gates unsupported hosts before any request and closes the connection", async () => { + const daemon = host(reports, false); + await expect(runUsageCommand(options, { kind: "list" }, daemon.connect)).rejects.toMatchObject({ + code: "UNSUPPORTED_HOST", + }); + expect(daemon.queries).toEqual([]); + expect(daemon.closed).toBe(1); +}); + +it("propagates connection and request failures", async () => { + await expect( + runUsageCommand(options, { kind: "list" }, async () => { + throw new Error("Connection failed"); + }), + ).rejects.toThrow("Connection failed"); + let closed = false; + const connect = async () => ({ + getLastServerInfoMessage: () => ({ features: { usageSources: true } }), + listUsageReports: async () => { + throw new Error("Request failed"); + }, + close: async () => { + closed = true; + }, + }); + await expect(runUsageCommand(options, { kind: "list" }, connect)).rejects.toThrow( + "Request failed", + ); + expect(closed).toBe(true); +}); + +describe("usage CLI registration", () => { + it("registers default, ls and inspect with the accepted selectors", () => { + const usage = createCli().commands.find((command) => command.name() === "usage")!; + expect(usage.commands.map((command) => command.name())).toEqual(["ls", "inspect"]); + expect(usage.helpInformation()).toContain("--agent <id>"); + expect(usage.commands[0]!.helpInformation()).toContain("--refresh"); + expect(usage.commands[1]!.helpInformation()).toContain("<report-id>"); + }); +}); diff --git a/packages/cli/src/commands/usage/index.ts b/packages/cli/src/commands/usage/index.ts new file mode 100644 index 0000000000..ab96fc659c --- /dev/null +++ b/packages/cli/src/commands/usage/index.ts @@ -0,0 +1,95 @@ +import { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { connectToDaemon, type ConnectOptions } from "../../utils/client.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { withOutput, type CommandOptions, type CommandResult } from "../../output/index.js"; +import { usageSchema } from "./presentation.js"; + +interface UsageOptions extends CommandOptions { + agent?: string; + refresh?: boolean; +} + +type UsageSelection = { kind: "list" } | { kind: "inspect"; id: string }; +interface UsageClient { + getLastServerInfoMessage(): { features?: { usageSources?: boolean } } | null; + listUsageReports: DaemonClient["listUsageReports"]; + close: DaemonClient["close"]; +} +type ConnectUsage = (options: ConnectOptions) => Promise<UsageClient>; + +export async function runUsageCommand( + options: UsageOptions, + selection: UsageSelection, + connect: ConnectUsage = connectToDaemon, +): Promise<CommandResult<UsageReportEntry>> { + const conflictingSelectors = selection.kind === "inspect" && options.agent !== undefined; + if (conflictingSelectors) { + throw { + code: "INVALID_OPTIONS", + message: + "--agent cannot be combined with usage inspect. Use usage ls --agent <id> for agent account usage, or usage inspect <report-id> for an exact report.", + }; + } + const client = await connect({ target: options.daemonTarget }); + try { + if (client.getLastServerInfoMessage()?.features?.usageSources !== true) { + throw { code: "UNSUPPORTED_HOST", message: "Update the host to use usage reports." }; + } + if (selection.kind === "list") { + const { reports } = await client.listUsageReports({ + agentId: options.agent, + forceRefresh: options.refresh, + }); + return { type: "list", data: reports, schema: usageSchema }; + } + // Report-ID queries only select known accounts. Discover before selecting, including on cold hosts. + const { reports } = await client.listUsageReports(); + let entry = reports.find((report) => report.id === selection.id); + if (entry && options.refresh) { + const refreshed = await client.listUsageReports({ + reportIds: [selection.id], + forceRefresh: true, + }); + entry = refreshed.reports.find((report) => report.id === selection.id); + } + if (!entry) { + throw { code: "REPORT_NOT_FOUND", message: `Unknown usage report: ${selection.id}` }; + } + return { type: "single", data: entry, schema: usageSchema }; + } finally { + await client.close().catch(() => {}); + } +} + +function addListOptions(command: Command): Command { + return addJsonAndDaemonHostOptions(command) + .option( + "--agent <id>", + "Show account quota associated with this agent (not agent token consumption)", + ) + .option("--refresh", "Refresh usage instead of using the host cache"); +} + +export function createUsageCommand(): Command { + const usage = addListOptions(new Command("usage").description("Show account usage reports")); + const list = (options: CommandOptions, _command: Command) => + runUsageCommand(options, { kind: "list" }); + usage.action(withOutput(list)); + addListOptions(usage.command("ls").description("List account quotas and balances")).action( + withOutput(list), + ); + addJsonAndDaemonHostOptions( + usage + .command("inspect") + .description("Show a complete account usage report") + .argument("<report-id>", "Exact opaque Report ID") + .option("--refresh", "Refresh the selected report instead of using the host cache"), + ).action( + withOutput((id: string, options: CommandOptions, _command: Command) => + runUsageCommand(options, { kind: "inspect", id }), + ), + ); + return usage; +} diff --git a/packages/cli/src/commands/usage/presentation.ts b/packages/cli/src/commands/usage/presentation.ts new file mode 100644 index 0000000000..ecc4aba405 --- /dev/null +++ b/packages/cli/src/commands/usage/presentation.ts @@ -0,0 +1,137 @@ +import type { + UsageReportEntry, + UsageReport, + UsageProblem, + ProviderUsageWindow, + ProviderUsageBalance, +} from "@getpaseo/protocol/messages"; +import { renderTable, type OutputSchema } from "../../output/index.js"; + +function value(input: string | number | null | undefined): string { + return input == null ? "-" : String(input); +} + +function percent(input: number | null | undefined): string { + return input == null ? "-" : `${input}%`; +} + +function problemText(problem: UsageProblem): string { + if (problem.kind === "no_quota") return problem.detail; + const remedy = problem.refreshedBy + ? `Run ${problem.refreshedBy} to refresh login.` + : "Sign in again."; + if (problem.kind === "expired") return `Login expired at ${problem.expiresAt}. ${remedy}`; + return `Login rejected (HTTP ${problem.status}). ${remedy}`; +} + +function failureText(report: UsageReport): string { + if (report.status === "error") return report.error; + if (report.status === "unavailable") return problemText(report.problem); + return "-"; +} + +function windowText(window: ProviderUsageWindow): string { + return `${window.label}: ${percent(window.usedPct)} used, ${percent(window.remainingPct)} remaining`; +} + +function balanceText(balance: ProviderUsageBalance): string { + return `${balance.label}: ${value(balance.used)} used, ${value(balance.remaining)} remaining, ${value(balance.limit)} limit (${balance.unit})`; +} + +function summary(entry: UsageReportEntry): string[] { + const { report } = entry; + if (report.status !== "available") { + const lines = [failureText(report)]; + for (const login of entry.loginErrors ?? []) + lines.push(`${login.harness}: ${failureText(login.report)}`); + return lines; + } + const lines = report.windows.map(windowText); + lines.push(...(report.balances ?? []).map(balanceText)); + return lines.length ? lines : ["-"]; +} + +interface SummaryRow { + id: string; + source: string; + account: string; + status: string; + usage: string; + fetched: string; +} + +const summarySchema: OutputSchema<SummaryRow> = { + idField: "id", + columns: [ + { header: "REPORT ID", field: "id" }, + { header: "SOURCE", field: "source" }, + { header: "ACCOUNT", field: "account" }, + { header: "STATUS", field: "status" }, + { header: "USAGE", field: "usage" }, + { header: "FETCHED", field: "fetched" }, + ], +}; + +function inspect(entry: UsageReportEntry): string { + const lines = [ + `Report ID: ${entry.id}`, + `Source: ${entry.sourceLabel}`, + `Source ID: ${entry.sourceId}`, + `Account: ${value(entry.account.label)}`, + `Status: ${entry.report.status}`, + `Fetched: ${value(entry.fetchedAt)}`, + ]; + const { report } = entry; + if (report.status === "available") { + lines.push(`Plan: ${value(report.planLabel)}`, "Windows:"); + if (!report.windows.length) lines.push(" -"); + for (const window of report.windows) { + lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`); + if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`); + if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`); + } + lines.push("Balances:"); + if (!report.balances?.length) lines.push(" -"); + for (const balance of report.balances ?? []) { + lines.push( + ` ${balanceText(balance)} [${balance.id}]`, + ` Resets: ${value(balance.resetsAt)}`, + ); + } + lines.push("Details:"); + if (!report.details?.length) lines.push(" -"); + for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`); + } else { + lines.push(`Reason: ${failureText(report)}`); + } + if (entry.loginErrors?.length) { + lines.push("Login failures:"); + for (const login of entry.loginErrors) + lines.push(` ${login.harness}: ${failureText(login.report)}`); + } + return lines.join("\n"); +} + +export const usageSchema: OutputSchema<UsageReportEntry> = { + idField: "id", + columns: [], + renderHuman(result, options) { + if (result.type === "single") return inspect(result.data); + if (!result.data.length) return "No usage reports returned"; + const rows: SummaryRow[] = []; + for (const entry of result.data) { + for (const [index, usage] of summary(entry).entries()) { + const first = index === 0; + rows.push({ + id: first ? entry.id : "", + source: first ? entry.sourceLabel : "", + account: first ? value(entry.account.label) : "", + status: first ? entry.report.status : "", + usage, + fetched: first ? value(entry.fetchedAt) : "", + }); + } + } + return renderTable<SummaryRow>({ type: "list", data: rows, schema: summarySchema }, options); + }, +}; diff --git a/packages/cli/src/commands/workspace/create.ts b/packages/cli/src/commands/workspace/create.ts index 5ce49278ef..04d9e10ef0 100644 --- a/packages/cli/src/commands/workspace/create.ts +++ b/packages/cli/src/commands/workspace/create.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { resolveCallerAgentId } from "../../utils/caller-agent.js"; import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; @@ -15,6 +16,7 @@ export interface WorkspaceCreateOptions extends CommandOptions { branch?: string; prNumber?: string; forge?: string; + background?: boolean; } interface WorktreeSourceBase { @@ -144,9 +146,12 @@ export async function runCreateCommand( }); try { + const callerAgentId = await resolveCallerAgentId(client); const payload = await client.createWorkspace({ source: buildWorkspaceSource(options), ...(options.title ? { title: options.title } : {}), + ...(options.background !== undefined ? { background: options.background } : {}), + ...(callerAgentId ? { callerAgentId } : {}), }); if (!payload.workspace) { throw new Error(payload.error ?? "Workspace creation failed"); diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts index 19d640d252..7d15ef26f3 100644 --- a/packages/cli/src/commands/workspace/index.ts +++ b/packages/cli/src/commands/workspace/index.ts @@ -34,12 +34,17 @@ export function createWorkspaceCommand(): Command { .option("--base <ref>", "Base ref (--mode branch-off)") .option("--branch <name>", "Existing branch (--mode checkout-branch)") .option("--pr-number <n>", "Pull request or change request number (--mode checkout-pr)") - .option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)"), + .option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)") + .option("--background", "Create a background workspace") + .option("--no-background", "Create a visible workspace instead of inheriting background"), ).action(withOutput(runCreateCommand)); - addJsonAndDaemonHostOptions(workspace.command("ls").description("List active workspaces")).action( - withOutput(runLsCommand), - ); + addJsonAndDaemonHostOptions( + workspace + .command("ls") + .description("List active workspaces") + .option("--background", "Include background workspaces"), + ).action(withOutput(runLsCommand)); addJsonAndDaemonHostOptions( workspace diff --git a/packages/cli/src/commands/workspace/ls.ts b/packages/cli/src/commands/workspace/ls.ts index b9821e061a..680bb06e1c 100644 --- a/packages/cli/src/commands/workspace/ls.ts +++ b/packages/cli/src/commands/workspace/ls.ts @@ -1,10 +1,22 @@ import type { Command } from "commander"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, ListResult } from "../../output/index.js"; -import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; +import { + toWorkspaceRow, + workspaceSchema, + workspaceWithBackgroundSchema, + type WorkspaceRow, +} from "./shared.js"; + +export interface WorkspaceLsOptions { + host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; + /** --background: Include background workspaces */ + background?: boolean; +} export async function runLsCommand( - options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, + options: WorkspaceLsOptions, _command: Command, ): Promise<ListResult<WorkspaceRow>> { const host = getDaemonHost({ target: options.daemonTarget }); @@ -20,12 +32,17 @@ export async function runLsCommand( let cursor: string | undefined; do { const payload = await client.fetchWorkspaces({ + ...(options.background ? { filter: { includeBackground: true } } : {}), page: { limit: 200, ...(cursor ? { cursor } : {}) }, }); workspaces.push(...payload.entries.map(toWorkspaceRow)); cursor = payload.pageInfo.nextCursor ?? undefined; } while (cursor); - return { type: "list", data: workspaces, schema: workspaceSchema }; + return { + type: "list", + data: workspaces, + schema: options.background ? workspaceWithBackgroundSchema : workspaceSchema, + }; } finally { await client.close().catch(() => undefined); } diff --git a/packages/cli/src/commands/workspace/shared.ts b/packages/cli/src/commands/workspace/shared.ts index cd800c9f10..db047e57b3 100644 --- a/packages/cli/src/commands/workspace/shared.ts +++ b/packages/cli/src/commands/workspace/shared.ts @@ -7,6 +7,7 @@ export interface WorkspaceRow { name: string; isolation: "local" | "worktree"; cwd: string; + background: boolean; } export const workspaceSchema: OutputSchema<WorkspaceRow> = { @@ -20,6 +21,15 @@ export const workspaceSchema: OutputSchema<WorkspaceRow> = { ], }; +/** `--background` listings add a column so hidden workspaces stand out. */ +export const workspaceWithBackgroundSchema: OutputSchema<WorkspaceRow> = { + ...workspaceSchema, + columns: [ + ...workspaceSchema.columns, + { header: "BACKGROUND", field: (row) => (row.background ? "yes" : ""), width: 8 }, + ], +}; + export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): WorkspaceRow { return { workspaceId: workspace.id, @@ -27,5 +37,6 @@ export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): Workspace name: workspace.name, isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local", cwd: workspace.workspaceDirectory, + background: workspace.background === true, }; } diff --git a/packages/cli/src/commands/worktree/archive.ts b/packages/cli/src/commands/worktree/archive.ts index eaf3857322..5d2bc60f33 100644 --- a/packages/cli/src/commands/worktree/archive.ts +++ b/packages/cli/src/commands/worktree/archive.ts @@ -2,6 +2,7 @@ import path from "path"; import type { Command } from "commander"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { resolveRepositoryDirectory } from "./repository-directory.js"; import type { CommandOptions, SingleResult, @@ -31,6 +32,7 @@ export const archiveSchema: OutputSchema<WorktreeArchiveResult> = { export interface WorktreeArchiveOptions extends CommandOptions { host?: string; + cwd?: string; } export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>; @@ -49,6 +51,7 @@ export async function runArchiveCommandWithDeps( deps: { connectToDaemon: typeof connectToDaemon }, ): Promise<WorktreeArchiveCommandResult> { const host = getDaemonHost({ target: options.daemonTarget }); + const cwd = resolveRepositoryDirectory(options); // Validate arguments if (!nameArg || nameArg.trim().length === 0) { @@ -76,7 +79,7 @@ export async function runArchiveCommandWithDeps( try { // Get the list of worktrees first to resolve the name - const listResponse = await client.getPaseoWorktreeList({}); + const listResponse = await client.getPaseoWorktreeList({ cwd }); if (listResponse.error) { const error: CommandError = { diff --git a/packages/cli/src/commands/worktree/index.ts b/packages/cli/src/commands/worktree/index.ts index f35f001a38..37405e2630 100644 --- a/packages/cli/src/commands/worktree/index.ts +++ b/packages/cli/src/commands/worktree/index.ts @@ -9,7 +9,10 @@ export function createWorktreeCommand(): Command { const worktree = new Command("worktree").description("Manage Paseo-managed git worktrees"); addJsonAndDaemonHostOptions( - worktree.command("ls").description("List Paseo-managed git worktrees"), + worktree + .command("ls") + .description("List Paseo-managed git worktrees") + .option("--cwd <path>", "Repository directory (default: current)"), ).action(withOutput(runLsCommand)); addJsonAndDaemonHostOptions( @@ -31,7 +34,8 @@ export function createWorktreeCommand(): Command { worktree .command("archive") .description("Archive a worktree (removes worktree and associated branch)") - .argument("<name>", "Worktree name or branch name"), + .argument("<name>", "Worktree name or branch name") + .option("--cwd <path>", "Repository directory (default: current)"), ).action(withOutput(runArchiveCommand)); return worktree; diff --git a/packages/cli/src/commands/worktree/ls.ts b/packages/cli/src/commands/worktree/ls.ts index cd388b2b08..43940131ce 100644 --- a/packages/cli/src/commands/worktree/ls.ts +++ b/packages/cli/src/commands/worktree/ls.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { basename } from "node:path"; import { connectToDaemon } from "../../utils/client.js"; +import { resolveRepositoryDirectory } from "./repository-directory.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Worktree list item for display */ @@ -40,12 +41,14 @@ export type WorktreeLsResult = ListResult<WorktreeListItem>; export interface WorktreeLsOptions extends CommandOptions { host?: string; + cwd?: string; } export async function runLsCommand( options: WorktreeLsOptions, _command: Command, ): Promise<WorktreeLsResult> { + const cwd = resolveRepositoryDirectory(options); const client = await connectToDaemon({ target: options.daemonTarget }); try { @@ -53,7 +56,7 @@ export async function runLsCommand( const agents = agentsPayload.entries.map((entry) => entry.agent); // Get worktree list from daemon - const response = await client.getPaseoWorktreeList({}); + const response = await client.getPaseoWorktreeList({ cwd }); await client.close(); diff --git a/packages/cli/src/commands/worktree/repository-directory.ts b/packages/cli/src/commands/worktree/repository-directory.ts new file mode 100644 index 0000000000..3c241703c0 --- /dev/null +++ b/packages/cli/src/commands/worktree/repository-directory.ts @@ -0,0 +1,13 @@ +import { resolve } from "node:path"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; + +export function resolveRepositoryDirectory(options: { + cwd?: string; + daemonTarget: DaemonTarget; +}): string { + const cwd = options.cwd ?? process.cwd(); + if (options.daemonTarget.kind === "instance") { + return resolve(cwd); + } + return cwd; +} diff --git a/packages/cli/src/run.test.ts b/packages/cli/src/run.test.ts index 66f4c9b613..651719e806 100644 --- a/packages/cli/src/run.test.ts +++ b/packages/cli/src/run.test.ts @@ -1,10 +1,39 @@ +import { spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { createCli } from "./cli"; import { createCliParseArgv } from "./run"; +const runModuleUrl = new URL("./run.ts", import.meta.url).href; + +// Runs the CLI in a child process whose stdout pipe has no reader, as when the +// program that launched it has already closed its end. +async function runCliWithClosedStdout( + argv: string[], +): Promise<{ code: number | null; stderr: string }> { + const script = ` + const { runCli } = await import(${JSON.stringify(runModuleUrl)}); + process.exitCode = await runCli(${JSON.stringify(argv)}); + await new Promise((resolve) => setTimeout(resolve, 100)); + process.stderr.write("still running\\n"); + `; + const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { + cwd: path.dirname(fileURLToPath(import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.destroy(); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + const code = await new Promise<number | null>((resolve) => child.on("close", resolve)); + return { code, stderr }; +} + describe("runCli", () => { it("lets plugin update own --version while preserving global output options", async () => { const cli = createCli() @@ -72,6 +101,19 @@ describe("runCli", () => { ).toEqual(["node", "paseo", "hooks", "claude", "UserPromptSubmit"]); }); + it("finishes the command quietly when stdout is closed before it writes its output", async () => { + const home = mkdtempSync(path.join(tmpdir(), "paseo-cli-closed-stdout-")); + + try { + const result = await runCliWithClosedStdout(["daemon", "status", "--json", "--home", home]); + + expect(result.stderr).toBe("still running\n"); + expect(result.code).toBe(0); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, 30_000); + it("classifies existing unknown directories as open-project invocations", () => { const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-")); const project = path.join(root, "repository"); diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index b95dd9b419..ac67e80745 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -36,7 +36,21 @@ export function createCliParseArgv(input: { return [...nodeArgv, ...cliArgv]; } +// The program that started the CLI can close its end of stdout before the output +// is written, as `paseo ls | head -1` or a launcher that discards output does. +// Drop the output nobody reads and let the command finish, instead of surfacing +// the failed write as an uncaught error. +function ignoreClosedStdout(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + export async function runCli(argv: string[], options: RunCliOptions = {}): Promise<number> { + if (!process.stdout.listeners("error").includes(ignoreClosedStdout)) { + process.stdout.on("error", ignoreClosedStdout); + } + const parseArgv = createCliParseArgv({ argv, cwd: options.cwd ?? process.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/src/utils/wait-for-stop.ts b/packages/cli/src/utils/wait-for-stop.ts new file mode 100644 index 0000000000..8430c39726 --- /dev/null +++ b/packages/cli/src/utils/wait-for-stop.ts @@ -0,0 +1,24 @@ +// Commands that stream until stopped (`paseo agent logs --follow`, `paseo agent +// attach`) stop on Ctrl+C, a termination signal, or when the program reading +// their output closes its end, as `| head` does. A closed reader shows up as +// EPIPE on the next write to stdout, so the command stops at its next line of +// output. +export function waitForStop(): Promise<void> { + return new Promise((resolve) => { + const stop = () => { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + process.stdout.off("error", stopOnClosedStdout); + resolve(); + }; + const stopOnClosedStdout = (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") { + stop(); + } + }; + + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + process.stdout.on("error", stopOnClosedStdout); + }); +} 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/43-agent-follow-closed-stdout.test.ts b/packages/cli/tests/43-agent-follow-closed-stdout.test.ts new file mode 100644 index 0000000000..0191867438 --- /dev/null +++ b/packages/cli/tests/43-agent-follow-closed-stdout.test.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env npx tsx + +/** + * Follow commands stop when nobody reads their output + * + * `paseo agent logs --follow` and `paseo agent attach` stream until they are + * stopped. A program that closes its end of their stdout, as `| head` or a + * launcher that discards output does, stops them the same way Ctrl+C does. + */ + +import assert from "node:assert"; +import { connectToDaemon } from "../src/utils/client.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; + +console.log("=== Follow Commands With Closed Stdout Tests ===\n"); + +const daemon = await startTestDaemon(); +const host = `127.0.0.1:${daemon.port}`; + +try { + // An agent created without a prompt stays idle, so no provider is called. + const client = await connectToDaemon({ target: { kind: "endpoint", host } }); + const agent = await client.createAgent({ + provider: "claude", + cwd: daemon.workDir, + title: "closed stdout", + }); + await client.close(); + + for (const args of [ + ["agent", "logs", agent.id, "--follow"], + ["agent", "attach", agent.id], + ]) { + const label = `paseo ${args.slice(0, 2).join(" ")}`; + console.log(`Test: ${label} exits when its stdout reader is gone`); + const result = await runPaseoCli(daemon, [...args, "--host", host], { + closedStdout: true, + timeout: 20_000, + }); + + assert.strictEqual(result.stderr, "", `${label} should exit quietly`); + assert.strictEqual(result.exitCode, 0, `${label} should exit with code 0`); + console.log(`✓ ${label} exits when its stdout reader is gone\n`); + } +} finally { + await daemon.stop(); +} + +console.log("=== All follow commands with closed stdout tests passed ==="); diff --git a/packages/cli/tests/44-usage.test.ts b/packages/cli/tests/44-usage.test.ts new file mode 100644 index 0000000000..2db20e2824 --- /dev/null +++ b/packages/cli/tests/44-usage.test.ts @@ -0,0 +1,152 @@ +#!/usr/bin/env npx tsx +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; +import { connectToDaemon } from "../src/utils/client.ts"; +import { resolveCliVersion } from "../src/version.ts"; + +const context = await createE2ETestContext(); +try { + const directory = join(context.workDir, "usage-plugin"); + await mkdir(directory); + await writeFile( + join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "cli-usage", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile( + join(directory, "index.server.ts"), + ` +import { z } from 'zod'; +export default function contribute(server) { + const counts = new Map(); + server.registerUsageSource({ + id: 'cli-usage', label: 'CLI usage fixture', input: z.object({ key: z.string() }), + discover: async () => [ + { key: 'work.account', label: 'Work', input: { key: 'work' } }, + { key: 'personal', input: { key: 'personal' } }, + { key: 'broken', harness: 'Harness A', input: { key: 'rejected' } }, + { key: 'broken', harness: 'Harness B', input: { key: 'expired' } }, + { key: 'error', input: { key: 'error' } }, + ], + fetch: async ({key}) => { + counts.set(key, (counts.get(key) ?? 0) + 1); + if (key === 'rejected') return {status: 'unavailable', problem: {kind: 'rejected', status: 401, refreshedBy: 'codex login'}}; + if (key === 'expired') return {status: 'unavailable', problem: {kind: 'expired', expiresAt: '2026-10-08T00:00:00Z', refreshedBy: 'claude'}}; + if (key === 'error') throw new Error('Quota service failed'); + return { status: 'available', planLabel: 'Pro', windows: [ + { id: 'session', label: 'Session', usedPct: counts.get(key), remainingPct: 99, resetsAt: '2026-10-09T17:00:00Z', runsOutAt: '2026-10-09T16:00:00Z', shortfallPct: 4.25 }, + { id: 'weekly', label: 'Weekly', usedPct: null, remainingPct: 0 }, + ], balances: [ + { id: 'credits', label: 'Credits', remaining: 2.5, unit: 'credits' }, + { id: 'tokens', label: 'Tokens', used: 0, limit: 100, unit: 'tokens' }, + ], details: [{ id: 'tier', label: 'Tier', value: 'Enterprise' }] }; + }, + }); + return () => {}; +}`, + ); + const client = await connectToDaemon({ target: { kind: "instance", home: context.paseoHome } }); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory, "cli-usage"); + } finally { + await client.close(); + } + async function ok(args: string[]) { + const result = await context.paseo(args); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stderr, ""); + return result.stdout; + } + // The first usage request is inspect: no earlier usage discovery has populated known IDs. + const cold = JSON.parse(await ok(["usage", "inspect", "cli-usage:work.account", "--json"])); + assert.equal(cold.id, "cli-usage:work.account"); + assert.equal(cold.report.windows[0].usedPct, 1); + for (const args of [ + ["usage", "--agent", "agent-A", "inspect", cold.id, "--json"], + ["usage", "inspect", cold.id, "--agent", "agent-A", "--json"], + ]) { + const conflicting = await context.paseo(args); + assert.equal(conflicting.exitCode, 1, `Expected selector rejection: ${args.join(" ")}`); + assert.equal(conflicting.stdout, ""); + assert.deepEqual(JSON.parse(conflicting.stderr).error, { + code: "INVALID_OPTIONS", + message: + "--agent cannot be combined with usage inspect. Use usage ls --agent <id> for agent account usage, or usage inspect <report-id> for an exact report.", + }); + } + + const list = JSON.parse(await ok(["usage", "ls", "--json"])); + const defaultList = JSON.parse(await ok(["usage", "--json"])); + assert.deepEqual(defaultList, list); + assert.deepEqual(list.map((entry: { id: string }) => entry.id).sort(), [ + "cli-usage:broken", + "cli-usage:error", + "cli-usage:personal", + "cli-usage:work.account", + ]); + const work = list.find((entry: { id: string }) => entry.id === cold.id); + assert.deepEqual(work, cold); + const table = await ok(["usage", "ls", "--no-color"]); + for (const text of [ + "REPORT ID", + "Credits: - used, 2.5 remaining, - limit (credits)", + "Tokens: 0 used, - remaining, 100 limit (tokens)", + "Weekly: - used, 0% remaining", + "Harness A: Login rejected (HTTP 401). Run codex login", + "Harness B: Login expired at", + "Quota service failed", + ]) + assert.ok(table.includes(text), text); + const quiet = await ok(["usage", "ls", "--quiet"]); + assert.deepEqual( + quiet.trim().split("\n").sort(), + list.map((entry: { id: string }) => entry.id).sort(), + ); + assert.ok(!(await ok(["usage", "ls", "--no-headers"])).includes("REPORT ID")); + const detail = await ok(["usage", "inspect", cold.id]); + for (const text of [ + "Plan: Pro", + "Resets: 2026-10-09T17:00:00Z", + "Runs out: 2026-10-09T16:00:00Z", + "Shortfall: 4.25%", + "Tier: Enterprise", + ]) + assert.ok(detail.includes(text), text); + const refreshed = JSON.parse(await ok(["usage", "inspect", cold.id, "--refresh", "--json"])); + assert.equal(refreshed.report.windows[0].usedPct, 2); + const personal = JSON.parse(await ok(["usage", "inspect", "cli-usage:personal", "--json"])); + assert.equal(personal.report.windows[0].usedPct, 1); + const allRefreshed = JSON.parse(await ok(["usage", "ls", "--refresh", "--json"])); + assert.equal( + allRefreshed.find((entry: { id: string }) => entry.id === cold.id).report.windows[0].usedPct, + 3, + ); + for (const id of ["cli-usage:work", "missing"]) { + const unknown = await context.paseo(["usage", "inspect", id, "--json"]); + assert.equal(unknown.exitCode, 1); + assert.equal(unknown.stdout, ""); + assert.equal(JSON.parse(unknown.stderr).error.code, "REPORT_NOT_FOUND"); + } + const agentFailure = await context.paseo(["usage", "ls", "--agent", "missing-agent", "--json"]); + assert.equal(agentFailure.exitCode, 1); + assert.equal(agentFailure.stdout, ""); + assert.match(JSON.parse(agentFailure.stderr).error.message, /Unknown agent/); + const remove = await context.paseo(["plugin", "remove", "cli-usage", "--json"]); + assert.equal(remove.exitCode, 0, remove.stderr); + assert.equal((await ok(["usage", "ls", "--json"])).trim(), "[]"); + assert.equal((await ok(["usage"])).trim(), "No usage reports returned"); + const connection = await context.paseo(["usage", "ls", "--host", "127.0.0.1:1", "--json"]); + assert.equal(connection.exitCode, 1); + assert.equal(connection.stdout, ""); + assert.equal(JSON.parse(connection.stderr).error.code, "DAEMON_UNREACHABLE"); + console.log( + "Usage CLI: cold inspect, default/ls, complete JSON, mixed statuses, all windows/balances, inspect details, refresh, IDs, errors and empty discovery passed.", + ); +} finally { + await context.stop(); +} diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index ce509af8e1..3e71b6b608 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -8,11 +8,13 @@ import { } from "../../../../scripts/test-support/npm-registry.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { once } from "node:events"; +import { WebSocket, WebSocketServer } from "ws"; import { connectToDaemon } from "../../src/utils/client.ts"; import { createE2ETestContext } from "../helpers/test-daemon.ts"; @@ -45,6 +47,81 @@ async function main(): Promise<void> { ); await writeFile(path.join(directory, "index.server.ts"), pluginSource); + // Observe real CLI requests while forwarding them to the isolated daemon. + const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const sources: string[] = []; + proxy.on("connection", (socket, request) => { + const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, { + headers: { authorization: request.headers.authorization ?? "" }, + }); + const ready = once(upstream, "open"); + socket.on("message", async (data, isBinary) => { + const message = JSON.parse(data.toString()); + if ( + message.type === "session" && + message.message.type === "plugin.source.install.request" + ) { + sources.push(message.message.source); + } + await ready; + upstream.send(data, { binary: isBinary }); + }); + upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); + socket.on("close", () => upstream.close()); + upstream.on("close", () => socket.close()); + }); + await once(proxy, "listening"); + const address = proxy.address(); + assert.ok(address && typeof address !== "string"); + const sibling = path.join(context.workDir, "x"); + const caller = path.join(context.workDir, "caller"); + await mkdir(caller); + await mkdir(path.join(sibling, "sub"), { recursive: true }); + for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) { + await writeFile( + path.join(target, "paseo-plugin.json"), + JSON.stringify({ + id: "relative-cli", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile(path.join(target, "index.server.ts"), pluginSource); + } + assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd. + try { + for (const [source, expectedSource, expectedDirectory, extraArgs] of [ + [".", caller, caller, []], + ["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []], + ["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]], + ["~", context.paseoHome, context.paseoHome, []], + ["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []], + ] as const) { + sources.length = 0; + const result = await context.paseo( + ["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [expectedSource]); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).path, expectedDirectory); + const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]); + assert.equal(removed.exitCode, 0, removed.stderr); + } + // An absolute path belongs to the daemon's platform, which can differ from the CLI's. + const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`; + sources.length = 0; + const missing = await context.paseo( + ["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [windowsSource]); + assert.equal(missing.exitCode, 1); + assert.match(missing.stderr, /Plugin directory does not exist/); + } finally { + for (const socket of proxy.clients) socket.close(); + await promisify(proxy.close.bind(proxy))(); + } + const install = await context.paseo(["plugin", "install", directory, "--json"]); assert.equal(install.exitCode, 0, install.stderr); assert.equal(JSON.parse(install.stdout).id, "cli-e2e"); diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index f8be7e6ead..5b5513d1a7 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -352,6 +352,8 @@ export async function runPaseoCli( timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; + /** Close the reading end of the CLI's stdout, as a launcher that discards output does. */ + closedStdout?: boolean; }, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { const timeout = options?.timeout ?? 60000; @@ -380,6 +382,10 @@ export async function runPaseoCli( const stdout = createOutputCapture(); const stderr = createOutputCapture(); + if (options?.closedStdout) { + proc.stdout?.destroy(); + } + proc.stdout?.on("data", (data) => { appendOutputCapture(stdout, data); }); diff --git a/packages/client/package.json b/packages/client/package.json index 47fd164b08..937190e0d0 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.11.0-beta.3", + "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.3", - "@getpaseo/relay": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.2", + "@getpaseo/relay": "0.11.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 1bc175bf1b..e25779a7bb 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -1,3 +1,4 @@ +import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { createClientChannel, type EncryptedChannel, @@ -8,7 +9,11 @@ import type { DaemonTransportFactory, TransportLogger, } from "./daemon-client-transport-types.js"; -import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js"; +import { + extractRelayMessage, + normalizeTransportPayload, + messageTooLargeError, +} from "./daemon-client-transport-utils.js"; type OpenHandler = () => void; type CloseHandler = (event?: unknown) => void; @@ -137,7 +142,13 @@ export function createEncryptedTransport( if (!channel) { throw new Error("Encrypted channel not ready"); } - void channel.send(normalizeTransportPayload(data)).catch((error) => { + const payload = normalizeTransportPayload(data); + // Reject synchronously so the requesting operation fails without closing the transport. + const wireBytes = channel.outboundWireByteLength(payload); + if (wireBytes > MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) { + throw messageTooLargeError(wireBytes, MAX_RELAY_WEBSOCKET_MESSAGE_BYTES); + } + void channel.send(payload).catch((error) => { emitError(error); }); }, diff --git a/packages/client/src/daemon-client-transport-utils.ts b/packages/client/src/daemon-client-transport-utils.ts index 3b39cf5114..cb767c1d87 100644 --- a/packages/client/src/daemon-client-transport-utils.ts +++ b/packages/client/src/daemon-client-transport-utils.ts @@ -133,3 +133,10 @@ export function encodeUtf8String(value: string): Uint8Array { } return out; } + +export function messageTooLargeError(size: number, limit: number): Error { + const toMiB = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1); + return new Error( + `Message is too large to send (${toMiB(size)} MiB). The limit is ${toMiB(limit)} MiB, so send fewer or smaller attachments.`, + ); +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index d7ab645241..c02e1720cb 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2801,6 +2801,102 @@ test("readFile drops an old daemon's over-budget binary chunks and reports the r await expect(responsePromise).rejects.toThrow("File is too large to display"); }); +async function connectFileClient(clientId: string) { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId, + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + return { mock, client }; +} + +function sendBinaryFile( + mock: ReturnType<typeof createMockTransport>, + requestId: string, + announcedSize: number, + chunks: string[], +) { + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: "application/octet-stream", + size: announcedSize, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + }), + ); + for (const chunk of chunks) { + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: new TextEncoder().encode(chunk), + }), + ); + } +} + +test("readFile with timeout 0 outlives the default request timeout", async () => { + useHeartbeatClock(); + const { mock, client } = await connectFileClient("clsk_file_no_timeout"); + + const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0); + await vi.advanceTimersByTimeAsync(60_001); + + sendBinaryFile(mock, "req-slow", 5, ["hello"]); + mock.triggerMessage( + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }), + ); + + const result = await responsePromise; + expect(result.size).toBe(5); + expect(new TextDecoder().decode(result.bytes)).toBe("hello"); +}); + +test("readFile rejects a binary transfer that ends before the announced size", async () => { + const { mock, client } = await connectFileClient("clsk_file_short"); + + const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short"); + sendBinaryFile(mock, "req-short", 10, ["hello"]); + mock.triggerMessage( + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }), + ); + + await expect(responsePromise).rejects.toThrow( + "File transfer incomplete: expected 10 bytes, received 5.", + ); +}); + +test("readFile rejects a binary transfer that exceeds the announced size", async () => { + const { mock, client } = await connectFileClient("clsk_file_long"); + + const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long"); + const settled = responsePromise.then( + () => null, + (error: unknown) => error, + ); + sendBinaryFile(mock, "req-long", 4, ["hello!"]); + + expect(() => + mock.triggerMessage( + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }), + ), + ).not.toThrow(); + expect(await settled).toMatchObject({ + message: "File transfer incomplete: expected 4 bytes, received 6.", + }); +}); + test("uploadFile sends metadata request and file bytes as binary chunks", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -3100,6 +3196,162 @@ test("sends worktree target and autoArchive in create_agent_request", async () = await expect(createPromise).rejects.toThrow("worktree auto archive sentinel"); }); +test("sends internal in create_agent_request when the host advertises internal agents", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ features: { backgroundWorkspaces: true } }); + await connectPromise; + + const createPromise = client.createAgent({ + provider: "codex", + cwd: "/tmp/project", + background: true, + }); + + expect(mock.sent).toHaveLength(1); + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual( + expect.objectContaining({ + type: "create_agent_request", + config: { provider: "codex", cwd: "/tmp/project" }, + background: true, + }), + ); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.requestId, + error: "internal agent sentinel", + }, + }), + ); + + await expect(createPromise).rejects.toThrow("internal agent sentinel"); +}); + +test("rejects an internal create before sending when the host predates internal agents", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ features: {} }); + await connectPromise; + + await expect( + client.createAgent({ provider: "codex", cwd: "/tmp/project", background: true }), + ).rejects.toThrow("Update the host to use background workspaces."); + expect(mock.sent).toHaveLength(0); +}); + +test("sends internal workspace creation and internal listings only to hosts that support them", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + // workspaceMultiplicity keeps the fetch on the real workspace directory RPC. + mock.triggerOpen({ + features: { workspaceMultiplicity: true, backgroundWorkspaces: true }, + }); + await connectPromise; + + void client + .createWorkspace({ source: { kind: "directory", path: "/tmp/project" }, background: true }) + .catch(() => undefined); + void client.fetchWorkspaces({ filter: { includeBackground: true } }).catch(() => undefined); + void client.fetchAgents({ filter: { includeBackground: true } }).catch(() => undefined); + + expect(mock.sent.map((frame) => parseSentFrame(frame))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "workspace.create.request", background: true }), + expect.objectContaining({ + type: "fetch_workspaces_request", + filter: { includeBackground: true }, + }), + expect.objectContaining({ + type: "fetch_agents_request", + filter: { includeBackground: true }, + }), + ]), + ); +}); + +test("rejects internal workspace creation and internal listings before sending on older hosts", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ features: {} }); + await connectPromise; + + await expect( + client.createWorkspace({ + source: { kind: "directory", path: "/tmp/project" }, + background: true, + }), + ).rejects.toThrow("Update the host to use background workspaces."); + await expect(client.fetchWorkspaces({ filter: { includeBackground: true } })).rejects.toThrow( + "Update the host to use background workspaces.", + ); + expect(mock.sent).toHaveLength(0); + + // Agent listings gate on the older internalAgents feature. + const olderMock = createMockTransport(); + const olderClient = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => olderMock.transport, + }); + clients.push(olderClient); + const olderConnect = olderClient.connect(); + olderMock.triggerOpen({ features: {} }); + await olderConnect; + await expect(olderClient.fetchAgents({ filter: { includeBackground: true } })).rejects.toThrow( + "Update the host to use background workspaces.", + ); + expect(olderMock.sent).toHaveLength(0); +}); + test("sends structured attachments with create_agent_request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -7325,3 +7577,75 @@ test("usage request timeout detaches its update listener", async () => { vi.useRealTimers(); } }); + +test.each([false, true])( + "agent provenance requires host support=%s while human sends remain compatible", + async (supported) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "provenance", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: { agentMessageProvenance: supported } }); + await connecting; + if (!supported) { + await expect( + client.sendAgentMessage("recipient", "review", { sourceAgentId: "server::sender" }), + ).rejects.toThrow("Update the Paseo host"); + expect(transport.sent).toHaveLength(0); + } + const send = client.sendAgentMessage("recipient", "review", { + messageId: "delivery", + ...(supported ? { sourceAgentId: "server::sender" } : {}), + }); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "send_agent_message_request", + text: "review", + messageId: "delivery", + }); + expect(request.sourceAgentId).toBe(supported ? "server::sender" : undefined); + transport.triggerMessage( + wrapSessionMessage({ + type: "send_agent_message_response", + payload: { + requestId: request.requestId, + agentId: "recipient", + accepted: true, + error: null, + }, + }), + ); + await send; + }, +); + +test("measures an outbound message in UTF-8 bytes against the daemon's message limit", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "message_limit_unit_test", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + const mib = 1024 * 1024; + + // "é" is one UTF-16 unit but two UTF-8 bytes, so this text is ~98 MiB on the wire. + void client.sendMessage("agent-1", "é".repeat(49 * mib)).catch(() => undefined); + expect(mock.sent).toHaveLength(1); + + // ~60M characters is under the 100 MiB limit; ~120 MiB of UTF-8 is over it. + await expect(client.sendMessage("agent-1", "é".repeat(60 * mib))).rejects.toThrow( + "Message is too large to send (120.0 MiB). The limit is 100.0 MiB", + ); + expect(mock.sent).toHaveLength(1); + expect(client.getConnectionState().status).toBe("connected"); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 858c61e5b3..1aa0d3e995 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,4 @@ +import { messageTooLargeError } from "./daemon-client-transport-utils.js"; import { legacyUsageIcon } from "./legacy-usage-icons.js"; import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; @@ -13,6 +14,7 @@ import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; +import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, @@ -424,6 +426,7 @@ export interface DaemonClientTrace { } export interface SendMessageOptions { + sourceAgentId?: string; messageId?: string; /** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */ activeTurnBehavior?: ActiveTurnBehavior; @@ -459,6 +462,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { git?: GitSetupOptions; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; + background?: CreateAgentRequestMessage["background"]; // COMPAT(createAgentWorktree): low-level old callers may still send the // create-agent worktree field. Added in v0.2.0; remove after 2027-01-17. worktreeName?: string; @@ -469,6 +473,9 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { export interface CreateWorkspaceRequestOptions { source: WorkspaceCreateRequest["source"]; title?: string; + /** Workspace discovery visibility; contents retain normal durability. */ + background?: boolean; + callerAgentId?: string; idempotencyKey?: string; workspaceId?: string; agent?: Omit< @@ -1127,6 +1134,19 @@ function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number return frame.byteLength; } +function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): void { + if (typeof frame !== "string") { + if (frame.byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) + throw messageTooLargeError(frame.byteLength, MAX_WEBSOCKET_MESSAGE_BYTES); + return; + } + // UTF-8 needs at most three bytes per UTF-16 unit, so only a string near the limit is measured. + if (frame.length * 3 <= MAX_WEBSOCKET_MESSAGE_BYTES) return; + const size = new TextEncoder().encode(frame).byteLength; + if (size > MAX_WEBSOCKET_MESSAGE_BYTES) + throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES); +} + function describeInboundTransportFrame( frame: unknown, rawBytes: Uint8Array | null, @@ -1195,6 +1215,25 @@ export function supportsUsageReports(features: ServerInfoStatusPayload["features return features?.usageSources === true || features?.providerUsageList === true; } +function buildFetchAgentsRequest( + options: FetchAgentsOptions | undefined, + requestId: string, +): SessionInboundMessage { + return SessionInboundMessageSchema.parse({ + type: "fetch_agents_request", + requestId, + ...(options?.scope ? { scope: options.scope } : {}), + ...(options?.filter ? { filter: options.filter } : {}), + ...(options?.sort ? { sort: options.sort } : {}), + ...(options?.page ? { page: options.page } : {}), + ...(options?.subscribe ? { subscribe: options.subscribe } : {}), + ...(options?.sync ? { sync: options.sync } : {}), + ...(options?.includeBackground !== undefined + ? { includeBackground: options.includeBackground } + : {}), + }); +} + export class DaemonClient { private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({ active: (message) => this.owned.owns(message), @@ -1743,6 +1782,8 @@ export class DaemonClient { if (!this.transport) { throw new DaemonConnectionError("Transport not connected"); } + // The daemon closes the connection on an oversized message, and creations resubmit on reconnect. + assertTransportFrameFits(frame); const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", { kind: typeof frame === "string" ? "text" : "binary", size: String(getTransportFrameSize(frame)), @@ -2341,6 +2382,8 @@ export class DaemonClient { observeAgents( options: Omit<FetchAgentsOptions, "subscribe"> = {}, ): OwnedSubscription<FetchAgentsPayload> { + if (options.includeBackground || options.filter?.includeBackground) + this.requireBackgroundWorkspaces(); const { signal, requestId, timeout, ...query } = options; return this.observe( "fetch_agents_response", @@ -2352,6 +2395,8 @@ export class DaemonClient { observeWorkspaces( options: Omit<FetchWorkspacesOptions, "subscribe"> = {}, ): OwnedSubscription<FetchWorkspacesPayload> { + if (options.includeBackground || options.filter?.includeBackground) + this.requireBackgroundWorkspaces(); const { signal, requestId, ...query } = options; return this.observe( "fetch_workspaces_response", @@ -2367,6 +2412,8 @@ export class DaemonClient { async fetchAgents( options?: FetchAgentsOptions, ): Promise<FetchAgentsPayload & { subscription?: OwnedSubscription<FetchAgentsPayload> }> { + if (options?.includeBackground || options?.filter?.includeBackground) + this.requireBackgroundWorkspaces(); if (options?.subscribe) { if (options.subscribe.subscriptionId !== undefined) throw new Error("Subscription IDs are assigned by the host"); @@ -2374,16 +2421,7 @@ export class DaemonClient { return { ...(await subscription.ready), subscription }; } const resolvedRequestId = this.createRequestId(options?.requestId); - const message = SessionInboundMessageSchema.parse({ - type: "fetch_agents_request", - requestId: resolvedRequestId, - ...(options?.scope ? { scope: options.scope } : {}), - ...(options?.filter ? { filter: options.filter } : {}), - ...(options?.sort ? { sort: options.sort } : {}), - ...(options?.page ? { page: options.page } : {}), - ...(options?.subscribe ? { subscribe: options.subscribe } : {}), - ...(options?.sync ? { sync: options.sync } : {}), - }); + const message = buildFetchAgentsRequest(options, resolvedRequestId); return this.sendRequest({ requestId: resolvedRequestId, message, @@ -2402,6 +2440,7 @@ export class DaemonClient { } async fetchAgentHistory(options?: FetchAgentHistoryOptions): Promise<FetchAgentHistoryPayload> { + if (options?.filter?.includeBackground) this.requireBackgroundWorkspaces(); const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_history_request", @@ -2465,6 +2504,8 @@ export class DaemonClient { ): Promise< FetchWorkspacesPayload & { subscription?: OwnedSubscription<FetchWorkspacesPayload> } > { + if (options?.includeBackground || options?.filter?.includeBackground) + this.requireBackgroundWorkspaces(); if (options?.subscribe) { if (options.subscribe.subscriptionId !== undefined) throw new Error("Subscription IDs are assigned by the host"); @@ -2474,12 +2515,8 @@ export class DaemonClient { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_workspaces_request", + ...options, requestId: resolvedRequestId, - ...(options?.filter ? { filter: options.filter } : {}), - ...(options?.sort ? { sort: options.sort } : {}), - ...(options?.page ? { page: options.page } : {}), - ...(options?.subscribe ? { subscribe: options.subscribe } : {}), - ...(options?.sync ? { sync: options.sync } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -2852,7 +2889,19 @@ export class DaemonClient { legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId), }); + // Background workspaces are gated once at the transport boundary. + private requireBackgroundWorkspaces(): void { + if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) { + throw new Error("Update the host to use background workspaces."); + } + } + + supportsBackgroundWorkspaces(): boolean { + return this.lastServerInfoMessage?.features?.backgroundWorkspaces === true; + } + async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> { + if (options.background !== undefined) this.requireBackgroundWorkspaces(); const result = await this.creations.createAgent({ ...options, config: resolveAgentConfig(options), @@ -2885,6 +2934,7 @@ export class DaemonClient { ...(options.git ? { git: options.git } : {}), ...(options.worktree ? { worktree: options.worktree } : {}), ...(options.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), + ...(options.background !== undefined ? { background: options.background } : {}), ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}), ...(options.labels && Object.keys(options.labels).length > 0 ? { labels: options.labels } @@ -3447,6 +3497,12 @@ export class DaemonClient { text: string, options?: SendMessageOptions, ): Promise<void> { + if ( + options?.sourceAgentId && + this.lastServerInfoMessage?.features?.agentMessageProvenance !== true + ) { + throw new Error("Update the Paseo host to send messages with agent provenance."); + } const requestId = this.createRequestId(); const messageId = options?.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ @@ -3454,6 +3510,7 @@ export class DaemonClient { requestId, agentId, text, + sourceAgentId: options?.sourceAgentId, ...(messageId ? { messageId } : {}), ...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}), ...(options?.images ? { images: options.images } : {}), @@ -4552,6 +4609,9 @@ export class DaemonClient { requestId?: string, ): Promise<WorkspaceCreatePayload> { const resolvedRequestId = this.createRequestId(requestId ?? input.requestId); + if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces(); + if (input.agent?.background !== undefined) + throw new Error("Configure background on the workspace, not its initial agent."); const result = await this.creations.createWorkspace({ ...input, requestId: resolvedRequestId, @@ -4583,6 +4643,8 @@ export class DaemonClient { ? { idempotencyKey: input.idempotencyKey } : {}), ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.background !== undefined ? { background: input.background } : {}), + ...(input.callerAgentId ? { callerAgentId: input.callerAgentId } : {}), ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext } : {}), @@ -4696,6 +4758,7 @@ export class DaemonClient { requestId?: string, acceptBinary = false, maxBytes?: number, + timeout?: number, ): Promise<FileExplorerPayload> { return this.sendCorrelatedSessionRequest({ requestId, @@ -4708,6 +4771,7 @@ export class DaemonClient { ...(maxBytes ? { maxBytes } : {}), }, responseType: "file_explorer_response", + timeout, }); } @@ -4726,11 +4790,17 @@ export class DaemonClient { return payload.directory; } + /** + * `timeout` bounds the whole transfer, not each frame. `0` disables the timer (as + * `workspace.create` does) for files that legitimately take longer than the default over a + * slow link; a dead connection still rejects on disconnect. + */ async readFile( cwd: string, path: string, requestId?: string, maxBytes?: number, + timeout?: number, ): Promise<FileReadResult> { const resolvedRequestId = this.createRequestId(requestId); this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes }); @@ -4742,6 +4812,7 @@ export class DaemonClient { resolvedRequestId, true, maxBytes, + timeout, ); if (payload.error) { throw new Error(payload.error); @@ -6478,6 +6549,18 @@ export class DaemonClient { return; } + const received = transfer.chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + if (received !== transfer.size) { + this.activeBinaryFileTransfers.delete(frame.requestId); + this.rejectWaitersForRequestId( + frame.requestId, + new Error( + `File transfer incomplete: expected ${transfer.size} bytes, received ${received}.`, + ), + ); + return; + } + const bytes = concatByteChunks(transfer.chunks, transfer.size); this.activeBinaryFileTransfers.delete(frame.requestId); this.completedBinaryFileReads.set(frame.requestId, { @@ -6932,6 +7015,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon attachments: _attachments, worktree: _worktree, autoArchive: _autoArchive, + background: _background, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 0f60e52673..ce6d4a5156 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -691,6 +691,61 @@ test("plugin-shaped PR workspace create and agent create use the existing daemon await client.close(); }); +test("agents.create forwards internal on the request, outside the agent config", async () => { + const { client, ws } = await connectClient({ + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + backgroundWorkspaces: true, + }); + + const createPromise = client.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/repo/sdk", + title: "Summary helper", + background: true, + autoArchive: true, + }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ + type: "create_agent_request", + config: { provider: "codex", model: "gpt-5.4", cwd: "/repo/sdk", title: "Summary helper" }, + background: true, + autoArchive: true, + }); + expect(request.config).not.toHaveProperty("internal"); + ws.message( + sessionMessage({ + type: "status", + payload: { + status: "agent_created", + requestId: request.requestId, + agentId: "agent_helper", + agent: createAgent({ id: "agent_helper" }), + }, + }), + ); + + const agent = await createPromise; + expect(agent.id).toBe("agent_helper"); + await client.close(); +}); + +test("agents.create with internal fails before sending on a host without internal agents", async () => { + const { client, ws } = await connectClient(); + const sentBefore = ws.sent.length; + + await expect( + client.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/repo/sdk", + background: true, + }), + ).rejects.toThrow("Update the host to use background workspaces."); + expect(ws.sent).toHaveLength(sentBefore); + await client.close(); +}); + test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => { const { client, ws } = await connectClient(); const createdAgent = createAgent(); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ae868c5974..058ec158ef 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -257,6 +257,8 @@ export interface PaseoAgentCreateOptions { git?: CreateAgentRequestMessage["git"]; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; + /** Visibility intent for a NEW workspace. Existing targets reject this option. */ + background?: CreateAgentRequestMessage["background"]; requestId?: string; labels?: Record<string, string>; } @@ -780,9 +782,12 @@ function createWorkspaceHandleFactory( const refresh = async (options?: { requestId?: string }) => { let cursor: string | undefined; let requestId = options?.requestId; + // A ref addresses one workspace by id, so a background one resolves too. + const filter = daemonClient.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}; do { const result = await daemonClient.fetchWorkspaces({ requestId, + filter, page: { limit: 200, ...(cursor ? { cursor } : {}) }, }); const match = result.entries.find((entry) => entry.id === id); @@ -1046,7 +1051,7 @@ async function waitForProvidersReady( try { await observation.ready; - signal.throwIfAborted(); + if (signal.aborted) throw new Error("Paseo API is disposed"); return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => { let settled = false; let requestId: string | null = null; diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index 4beed26643..5501ff8ba4 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -1574,9 +1574,8 @@ async function automationDrag(guest, sourceRef, targetRef) { } async function automationType(guest, refEntry, text) { - const send = await attachAutomationDebugger(guest); await automationClick(guest, refEntry); - await send("Input.insertText", { text }); + await guest.insertText(text); } function sendContainedEnter(guest) { @@ -2641,13 +2640,25 @@ async function runBrowserProfileGroup() { async function main() { ensureDirSync(OUT_DIR); if ( - !["all", "existing", "permanent-parking", "automation", "browser-profile"].includes( - HARNESS_GROUP, - ) + ![ + "all", + "existing", + "permanent-parking", + "automation", + "browser-profile", + "titlebar-drag", + ].includes(HARNESS_GROUP) ) { fail(`unknown harness group ${HARNESS_GROUP}`); } + if (HARNESS_GROUP === "titlebar-drag") { + const { runTitlebarDragGroup } = require("./titlebar-drag.js"); + await runTitlebarDragGroup(OUT_DIR); + pass(`capture harness titlebar-drag complete output=${OUT_DIR}`); + return; + } + if (HARNESS_GROUP === "browser-profile") { const browserProfileResults = await runBrowserProfileGroup(); await fsp.writeFile( diff --git a/packages/desktop/capture-harness/titlebar-drag-mouse.m b/packages/desktop/capture-harness/titlebar-drag-mouse.m new file mode 100644 index 0000000000..ac8894bc52 --- /dev/null +++ b/packages/desktop/capture-harness/titlebar-drag-mouse.m @@ -0,0 +1,52 @@ +// titlebar-drag-mouse.m — native mouse helper for the titlebar-drag harness. +// Posts real CGEvents (kCGHIDEventTap) so a drag moves the actual window; pure +// CDP input would never exercise Electron's draggable-region pipeline. +// Usage: titlebar-drag-mouse drag x1 y1 x2 y2 | click x y +// Every event carries flags 0 so a user-held Cmd/Opt cannot leak into the drag. +// Exit 2 when this process lacks Accessibility trust: the harness fails loudly +// instead of posting nothing and reading as a pass. +#import <ApplicationServices/ApplicationServices.h> +#import <stdio.h> +#import <stdlib.h> +#import <string.h> +#import <unistd.h> + +static void post(CGEventType type, CGPoint p, useconds_t delay) { + CGEventRef event = CGEventCreateMouseEvent(NULL, type, p, kCGMouseButtonLeft); + if (!event) { + fprintf(stderr, "mouse: CGEventCreateMouseEvent failed\n"); + exit(3); + } + CGEventSetFlags(event, (CGEventFlags)0); + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + usleep(delay); +} + +int main(int argc, char **argv) { + if (!AXIsProcessTrusted()) { + fprintf(stderr, "mouse: not accessibility-trusted\n"); + return 2; + } + if (argc == 6 && strcmp(argv[1], "drag") == 0) { + CGPoint a = {atof(argv[2]), atof(argv[3])}; + CGPoint b = {atof(argv[4]), atof(argv[5])}; + post(kCGEventMouseMoved, a, 150000); + post(kCGEventLeftMouseDown, a, 150000); + for (int i = 1; i <= 20; i++) { + CGPoint step = {a.x + (b.x - a.x) * i / 20, a.y + (b.y - a.y) * i / 20}; + post(kCGEventLeftMouseDragged, step, 25000); + } + post(kCGEventLeftMouseUp, b, 25000); + return 0; + } + if (argc == 4 && strcmp(argv[1], "click") == 0) { + CGPoint p = {atof(argv[2]), atof(argv[3])}; + post(kCGEventMouseMoved, p, 150000); + post(kCGEventLeftMouseDown, p, 150000); + post(kCGEventLeftMouseUp, p, 25000); + return 0; + } + fprintf(stderr, "usage: %s drag x1 y1 x2 y2 | click x y\n", argv[0]); + return 1; +} diff --git a/packages/desktop/capture-harness/titlebar-drag.js b/packages/desktop/capture-harness/titlebar-drag.js new file mode 100644 index 0000000000..fe4a35d06c --- /dev/null +++ b/packages/desktop/capture-harness/titlebar-drag.js @@ -0,0 +1,272 @@ +// Native mouse input is required: CDP mouse events do not move Electron windows. +// Load production CSS so restoring the global no-drag rule reproduces the failure. +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const path = require("node:path"); +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); +const { BrowserWindow } = require("electron"); + +const execFileAsync = promisify(execFile); +const PACKAGE_ROOT = path.join(__dirname, "..", ".."); +const INDEX_HTML = path.join(PACKAGE_ROOT, "app", "public", "index.html"); +const MOUSE_SOURCE = path.join(__dirname, "titlebar-drag-mouse.m"); +const BASE = { x: 100, y: 150 }; +const DRAG_DELTA = { x: 80, y: 50 }; +const DRAG_POINTS = [ + { check: "header-overlay-blank", x: 625, y: 18 }, + { check: "tab-row-overlay-blank", x: 625, y: 50 }, + { check: "sidebar-overlay-blank", x: 125, y: 18 }, + { check: "direct-rail-blank", x: 125, y: 50 }, +]; +const CLICK_POINTS = [ + { check: "header-button-click", id: "header-btn", x: 955, y: 18 }, + { check: "window-owned-toggle-click", id: "window-toggle", x: 40, y: 18 }, + { check: "window-owned-focusable-click", id: "focusable", x: 320, y: 18 }, + { check: "window-owned-negative-tabindex-button-click", id: "negative-button", x: 400, y: 18 }, + { check: "sidebar-tab-click", id: "sidebar-btn", x: 205, y: 18 }, + { check: "rail-tab-click", id: "rail-btn", x: 205, y: 50 }, + { check: "portal-button-click", id: "portal-btn", x: 490, y: 18 }, + { check: "floating-panel-button-click", id: "floating-btn", x: 785, y: 18 }, +]; + +/** Static overlays and a sibling window control mirror the app shell ownership. */ +function buildFixtureHtml(appStyles) { + const dragOverlay = + 'style="position:absolute;top:0;left:0;display:block;width:100%;height:100%;-webkit-app-region:drag"'; + const button = (id, style) => + `<button id="${id}" style="${style}" onclick="count('${id}')">${id}</button>`; + return `<!doctype html><html><head><meta charset="utf-8"><style>${appStyles}</style> +<style>html,body{margin:0;width:100%;height:100%;background:#161616;color:#ddd;font:12px -apple-system,sans-serif;user-select:none} +.pane{position:absolute}</style></head><body> +<div class="pane" id="sidebar" style="left:0;top:0;width:250px;height:600px;background:#1d1d1d"> + <div class="pane" style="left:0;top:0;width:250px;height:36px"> + <div ${dragOverlay}></div> + ${button("sidebar-btn", "position:absolute;right:8px;top:6px;width:60px;height:24px")} + </div> + <div class="pane" + style="left:0;top:36px;width:250px;height:28px;-webkit-app-region:drag"> + ${button("rail-btn", "position:absolute;right:8px;top:2px;width:60px;height:24px")} + </div> +</div> +<div class="pane" id="header" style="left:250px;top:0;width:750px;height:36px"> + <div ${dragOverlay}></div> + ${button("header-btn", "position:absolute;right:8px;top:6px;width:60px;height:24px")} +</div> +<div class="pane" style="left:250px;top:36px;width:750px;height:28px"> + <div ${dragOverlay}></div> +</div> +<!-- The sidebar toggle belongs to the window, outside every drag surface subtree. --> +${button("window-toggle", "position:absolute;left:10px;top:6px;width:60px;height:24px;z-index:2")} +<div id="focusable" tabindex="0" onclick="count('focusable')" + style="position:absolute;left:290px;top:6px;width:60px;height:24px;z-index:2">Focusable</div> +<div id="negative-button" role="button" tabindex="-1" onclick="count('negative-button')" + style="position:absolute;left:370px;top:6px;width:60px;height:24px;z-index:2">Button</div> +<div class="pane" id="chat-root" style="left:250px;top:64px;width:750px;height:536px;overflow:clip"> + <div id="scroller" tabindex="-1" data-window-content + style="width:100%;height:100%;overflow-y:auto;overflow-x:clip;outline:none"> + <div id="chat-inner" style="height:2000px;position:relative"> + <a id="chat-link" href="#" onclick="event.preventDefault();count('chat-link')" style="position:absolute;left:250px;top:160px;width:180px;height:24px">Chat link</a> + ${button("chat-code", "position:absolute;left:250px;top:320px;width:180px;height:24px")} + <div id="chat-file" role="link" tabindex="0" onclick="count('chat-file')" style="position:absolute;left:250px;top:480px;width:180px;height:24px">file.ts</div> + </div> + </div> +</div> +<div id="overlay-root" style="position:fixed;inset:0;pointer-events:none;z-index:9999"> + ${button("portal-btn", "position:fixed;left:460px;top:6px;width:60px;height:24px;pointer-events:auto")} +</div> +<div style="position:fixed;inset:0;pointer-events:none;z-index:9999"> + ${button("floating-btn", "position:fixed;left:755px;top:6px;width:60px;height:24px;pointer-events:auto")} +</div> +<script> + window.__clicks = {}; + window.count = (id) => { window.__clicks[id] = (window.__clicks[id] || 0) + 1; }; + window.__fixture = { + setScroll(value) { document.getElementById("scroller").scrollTop = value; return this.snapshot(); }, + placeControl(id, y) { + const control = document.getElementById(id); + const rect = control.getBoundingClientRect(); + document.getElementById("scroller").scrollTop += rect.top + rect.height / 2 - y; + const placed = control.getBoundingClientRect(); + return { x: placed.left + placed.width / 2, y: placed.top + placed.height / 2 }; + }, + snapshot() { + const inner = document.getElementById("chat-inner").getBoundingClientRect(); + const clicks = { ...window.__clicks }; + window.__clicks = {}; + return { innerTop: inner.top, innerBottom: inner.bottom, clicks }; + }, + }; +</script></body></html>`; +} + +function readRealAppStyles() { + const html = fs.readFileSync(INDEX_HTML, "utf8"); + const styles = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join("\n"); + if (!styles.trim()) throw new Error(`no <style> blocks found in ${INDEX_HTML}`); + return styles; +} + +async function compileMouseHelper(outDir) { + const binary = path.join(outDir, "titlebar-drag-mouse"); + await execFileAsync("clang", [ + "-O2", + "-o", + binary, + MOUSE_SOURCE, + "-framework", + "ApplicationServices", + ]); + return binary; +} + +function samePosition(before, after) { + return before.x === after.x && before.y === after.y; +} + +async function runTitlebarDragGroup(outDir) { + const results = []; + const record = (check, pass, detail) => { + results.push({ group: "titlebar-drag", check, pass, ...detail }); + process.stdout.write(`${pass ? "PASS" : "FAIL"} titlebar-drag/${check}\n`); + }; + let win = null; + let hardError = null; + try { + if (process.platform !== "darwin") { + throw new Error("titlebar-drag: macOS only — native window drag needs CGEventPost"); + } + const mouse = await compileMouseHelper(outDir); + const fixturePath = path.join(outDir, "titlebar-drag-fixture.html"); + await fsp.writeFile(fixturePath, buildFixtureHtml(readRealAppStyles())); + + win = new BrowserWindow({ + ...BASE, + width: 1000, + height: 600, + frame: false, + show: false, + backgroundColor: "#161616", + }); + const evalPage = (code) => win.webContents.executeJavaScript(code); + const bounds = () => { + const current = win.getBounds(); + return { x: current.x, y: current.y }; + }; + const resetPosition = async () => { + win.setPosition(BASE.x, BASE.y); + await new Promise((resolve) => setTimeout(resolve, 120)); + }; + const runMouse = async (args) => { + await execFileAsync(mouse, args.map(String)); + await new Promise((resolve) => setTimeout(resolve, 220)); + }; + const mouseAt = (point) => ({ x: BASE.x + point.x, y: BASE.y + point.y }); + + await win.loadFile(fixturePath); + win.showInactive(); + await new Promise((resolve) => setTimeout(resolve, 300)); + + for (const scrollTop of [0, 1000]) { + const snapshot = await evalPage(`window.__fixture.setScroll(${scrollTop})`); + const crossesHeader = snapshot.innerTop < 36 && snapshot.innerBottom > 0; + record(`scroll-${scrollTop}-content-crosses-header`, crossesHeader === (scrollTop === 1000), { + innerTop: snapshot.innerTop, + innerBottom: snapshot.innerBottom, + }); + for (const point of DRAG_POINTS) { + await resetPosition(); + const before = bounds(); + const from = mouseAt(point); + await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]); + const after = bounds(); + const moved = after.x - before.x === DRAG_DELTA.x && after.y - before.y === DRAG_DELTA.y; + record(`scroll-${scrollTop}-${point.check}`, moved, { before, after }); + } + const chatPoint = { x: 625, y: 300 }; + await resetPosition(); + const before = bounds(); + const from = mouseAt(chatPoint); + await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]); + const after = bounds(); + record(`scroll-${scrollTop}-chat-area-does-not-drag`, samePosition(before, after), { + before, + after, + }); + } + + for (const point of CLICK_POINTS) { + await resetPosition(); + const before = bounds(); + const from = mouseAt(point); + await runMouse(["click", from.x, from.y]); + const { clicks } = await evalPage("window.__fixture.snapshot()"); + const after = bounds(); + const clicked = clicks[point.id] === 1; + const stillStationary = samePosition(before, after); + record(point.check, clicked && stillStationary, { clicks, before, after }); + await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]); + const afterDrag = bounds(); + record(`${point.check}-does-not-drag`, samePosition(before, afterDrag), { + before, + after: afterDrag, + }); + } + + for (const id of ["chat-link", "chat-code", "chat-file"]) { + for (const y of [18, 50]) { + await resetPosition(); + const point = await evalPage(`window.__fixture.placeControl(${JSON.stringify(id)}, ${y})`); + const before = bounds(); + const from = mouseAt(point); + await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]); + const after = bounds(); + record( + `${id}-behind-header-${y}`, + after.x - before.x === DRAG_DELTA.x && after.y - before.y === DRAG_DELTA.y, + { before, after, point }, + ); + } + await resetPosition(); + const point = await evalPage(`window.__fixture.placeControl(${JSON.stringify(id)}, 180)`); + const before = bounds(); + const from = mouseAt(point); + await evalPage("window.__fixture.snapshot()"); + await runMouse(["click", from.x, from.y]); + const { clicks } = await evalPage("window.__fixture.snapshot()"); + const after = bounds(); + record(`${id}-visible-click`, clicks[id] === 1 && samePosition(before, after), { + clicks, + before, + after, + point, + }); + await runMouse(["drag", from.x, from.y, from.x + DRAG_DELTA.x, from.y + DRAG_DELTA.y]); + const afterDrag = bounds(); + record(`${id}-visible-does-not-drag`, samePosition(before, afterDrag), { + before, + after: afterDrag, + }); + } + + const image = await win.webContents.capturePage(); + await fsp.writeFile(path.join(outDir, "titlebar-drag.png"), image.toPNG()); + } catch (error) { + hardError = error; + record("group-error", false, { reason: String(error?.message ?? error) }); + } finally { + win?.destroy(); + } + + await fsp.writeFile( + path.join(outDir, "titlebar-drag-results.json"), + `${JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2)}\n`, + ); + const failed = results.filter((entry) => !entry.pass).map((entry) => entry.check); + if (hardError || failed.length > 0) { + throw new Error(`titlebar-drag group failed: ${failed.join(", ")}`, { cause: hardError }); + } + return results; +} + +module.exports = { runTitlebarDragGroup }; diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 6647af7b31..fb0958aad1 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -272,7 +272,7 @@ async function waitForGuestActiveElement(client, browserId, elementId) { return false; } -async function createCallerAgent(daemonPort) { +async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) { const transport = new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), ); @@ -282,12 +282,11 @@ async function createCallerAgent(daemonPort) { name: "create_agent", args: { relationship: { kind: "detached" }, - workspace: { kind: "existing", workspaceId: workspaceIds[0] }, + workspace: { kind: "existing", workspaceId }, title: "Browser desktop browser E2E caller", provider: "mock/ten-second-stream", settings: { modeId: "load-test" }, initialPrompt: "Remain available while the browser bridge regression runs.", - background: true, }, }); const result = response.structuredContent; @@ -297,7 +296,7 @@ async function createCallerAgent(daemonPort) { ); assert(typeof result.agentId === "string", "create_agent returned no caller agent id"); assert( - result.workspaceId === workspaceIds[0], + result.workspaceId === workspaceId, `MCP caller attached to unexpected workspace ${result.workspaceId}`, ); return result.agentId; @@ -562,12 +561,66 @@ async function verifyHiddenBrowserScreenshots({ await expectAnimation(); } +async function verifyBackgroundTypingStaysInGuest({ page, client, serverId, browserId, agent }) { + await page + .getByTestId(`workspace-deck-entry-${serverId}:${agent.workspaceId}`) + .getByTestId(`workspace-tab-agent_${agent.agentId}`) + .click({ timeout: timeoutMs }); + const composer = page + .getByRole("textbox", { name: "Message agent..." }) + .filter({ visible: true }) + .first(); + const draft = "draft the user is typing"; + await composer.click(); + await composer.pressSequentially(draft); + assert( + await composer.evaluate((element) => element === document.activeElement), + "Composer did not keep focus while the user typed", + ); + + const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId }); + const ref = snapshot.snapshot.match(/textbox "Typing target" \[ref=(@e\d+)\]/)?.[1]; + assert(ref, `browser_snapshot did not expose the typing target: ${snapshot.snapshot}`); + await callBrowserTool(client, "browser_type", { browserId, ref, text: "agent text" }); + await callBrowserTool(client, "browser_keypress", { browserId, ref, key: "!" }); + + // The user keeps typing in the composer while the agent types into the tab's focused field. + await composer.click(); + await composer.press("End"); + assert( + await composer.evaluate((element) => element === document.activeElement), + "Composer did not take focus back from the browser tab", + ); + await callBrowserTool(client, "browser_type", { browserId, text: " more" }); + await callBrowserTool(client, "browser_keypress", { browserId, key: "?" }); + + const guestValue = JSON.parse( + ( + await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => document.getElementById('typing-target').value", + }) + ).resultJson, + ); + const composerValue = await composer.inputValue(); + assert( + composerValue === draft, + `browser_type and browser_keypress in a background tab changed the focused composer: ${JSON.stringify(composerValue)}`, + ); + assert( + guestValue === "agent text! more?", + `browser_type and browser_keypress did not reach the browser tab: ${JSON.stringify(guestValue)}`, + ); + await composer.fill(""); +} + async function runRegression({ page, client, serverId, targetUrl, callerAgentId, + typingAgent, artifactDir, inspectorPort, }) { @@ -857,6 +910,13 @@ async function runRegression({ text: "Clicked", timeoutMs: 5_000, }); + await verifyBackgroundTypingStaysInGuest({ + page, + client, + serverId, + browserId, + agent: typingAgent, + }); await originalWorkspaceRow.click(); await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click(); @@ -1157,6 +1217,10 @@ async function main() { await runAppearanceFontSizeRegression(page); const callerAgentId = await createCallerAgent(daemonPort); + const typingAgent = { + workspaceId: workspaceIds.at(-1), + agentId: await createCallerAgent(daemonPort, workspaceIds.at(-1)), + }; const transport = new StreamableHTTPClientTransport( new URL( `http://127.0.0.1:${daemonPort}/mcp/agents?callerAgentId=${encodeURIComponent(callerAgentId)}`, @@ -1170,6 +1234,7 @@ async function main() { targetUrl: target.url, inspectorPort, callerAgentId, + typingAgent, artifactDir, }); const pluginLinks = await checkPluginLinks(); diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index b1b299494a..f1310e287f 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -848,6 +848,11 @@ async function assertSandboxState({ browser, page, expectedSandbox, stdout, stde function assertLinuxDesktopIdentity(appPath) { if (process.platform === "linux") { + assert.deepEqual( + fs.readFileSync(path.join(appPath, "resources", "icon.png")), + fs.readFileSync(path.join(__dirname, "..", "assets", "icon.png")), + "Packaged Linux windows must have the Paseo icon at their runtime lookup path", + ); const metadata = JSON.parse( extractFile(path.join(appPath, "resources", "app.asar"), "package.json").toString(), ); diff --git a/packages/desktop/e2e/window-startup.e2e.mjs b/packages/desktop/e2e/window-startup.e2e.mjs new file mode 100644 index 0000000000..e6654c09c2 --- /dev/null +++ b/packages/desktop/e2e/window-startup.e2e.mjs @@ -0,0 +1,48 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { _electron as electron, expect } from "playwright/test"; + +const root = await mkdtemp(path.join(tmpdir(), "paseo-window-startup-")); +const modulePath = fileURLToPath(new URL("../dist/window/show-when-ready.js", import.meta.url)); + +async function launchWindowWithoutHiddenFrames() { + const main = path.join(root, "main.cjs"); + await writeFile( + main, + `const { app, BrowserWindow } = require("electron"); + const { showWindowWhenReady } = require(${JSON.stringify(modulePath)}); + app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))}); + app.whenReady().then(async () => { + const win = global.mainWindow = new BrowserWindow({ show: false, paintWhenInitiallyHidden: false }); + showWindowWhenReady(win, { showOnLoad: true }); + await win.loadURL("data:text/html,<h1>Desktop ready</h1>"); + global.visibleAfterLoad = win.isVisible(); + });`, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key !== "ELECTRON_RUN_AS_NODE"), + ); + const displayArgs = process.platform === "linux" ? ["--ozone-platform=x11", "--disable-gpu"] : []; + return electron.launch({ + args: ["--no-sandbox", ...displayArgs, main], + env, + }); +} + +async function expectLoadedDesktopIsShown(desktop) { + await expect.poll(() => desktop.evaluate(() => global.visibleAfterLoad)).toBe(true); + const page = await desktop.firstWindow(); + await expect(page.getByRole("heading", { name: "Desktop ready" })).toBeVisible(); +} + +let desktop; +try { + desktop = await launchWindowWithoutHiddenFrames(); + await expectLoadedDesktopIsShown(desktop); + console.log("Loaded desktop is visible without hidden compositor frames"); +} finally { + await desktop?.close(); + await rm(root, { recursive: true, force: true }); +} diff --git a/packages/desktop/e2e/workspace-restart.spec.ts b/packages/desktop/e2e/workspace-restart.spec.ts index 36259bee4f..4ee1655242 100644 --- a/packages/desktop/e2e/workspace-restart.spec.ts +++ b/packages/desktop/e2e/workspace-restart.spec.ts @@ -6,6 +6,8 @@ import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port"; import { installDaemonWebSocketGate } from "../../app/e2e/support/helpers/daemon-websocket-gate"; import { expectAppRoute } from "../../app/e2e/support/helpers/route-assertions"; import { seedWorkspace } from "../../app/e2e/support/helpers/seed-client"; +import { openAgentRoute, seedMockAgentWorkspace } from "../../app/e2e/support/helpers/mock-agent"; +import { scrollAgentChatToBottom } from "../../app/e2e/support/helpers/agent-bottom-anchor"; import { getServerId } from "../../app/e2e/support/helpers/server-id"; import { waitForWorkspaceTabsVisible } from "../../app/e2e/support/helpers/workspace-tabs"; import { @@ -118,3 +120,131 @@ test("refresh keeps one continuous splash before restoring the desktop workspace await workspace.cleanup(); } }); + +function inspectChatDragExclusions(scroll: Element) { + const header = document.querySelector('[data-testid="composer-dock-header"]'); + if (!header) throw new Error("Expected the workspace header"); + const headerRect = header.getBoundingClientRect(); + const headerY = headerRect.top + headerRect.height / 2; + const content = scroll.firstElementChild; + const focusScope = scroll.closest("[tabindex]"); + if (!content || !focusScope) throw new Error("Expected chat content and its focus scope"); + const contentRect = content.getBoundingClientRect(); + const chatX = (contentRect.left + contentRect.right) / 2; + const tabRow = [...document.querySelectorAll('[data-testid="workspace-tabs-row"]')].find( + (row) => { + const rect = row.getBoundingClientRect(); + return rect.left < chatX && rect.right > chatX && rect.height > 0; + }, + ); + if (!tabRow) throw new Error("Expected the chat pane's tab row"); + const tabRect = tabRow.getBoundingClientRect(); + const tabY = tabRect.top + tabRect.height / 2; + const exclusions = [...scroll.querySelectorAll("*")].filter((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return ( + style.visibility === "visible" && + style.getPropertyValue("-webkit-app-region") === "no-drag" && + ((rect.top < headerY && rect.bottom > headerY) || (rect.top < tabY && rect.bottom > tabY)) && + rect.right > headerRect.left && + rect.left < headerRect.right + ); + }); + return { + contentCrossesHeader: contentRect.top < headerY && contentRect.bottom > headerY, + contentCrossesTabRow: contentRect.top < tabY && contentRect.bottom > tabY, + focusScopeRegion: getComputedStyle(focusScope).getPropertyValue("-webkit-app-region"), + exclusions: exclusions.length, + }; +} + +test("scrolled chat does not exclude the workspace titlebar from dragging", async ({ + page, +}, testInfo) => { + const response = Array.from( + { length: 80 }, + (_, index) => + `Paragraph ${index}: a long conversation keeps the chat scrolled below its first message.\n\n[Drag reference](https://example.com) and [file.ts](./file.ts)\n\n\`\`\`ts\nconst item = ${index};\n\`\`\``, + ).join("\n\n"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "desktop-scrolled-chat-", + title: "Scrollable conversation", + initialPrompt: "Show a long response", + featureValues: { mockAssistantResponse: response }, + }); + + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + await installDesktopRuntime(page, { + serverId: getServerId(), + daemonListen: `127.0.0.1:${getE2EDaemonPort()}`, + }); + await openAgentRoute(page, agent); + const chat = page.getByTestId("agent-chat-scroll"); + await expect(chat).toBeVisible(); + + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await scrollAgentChatToBottom(page); + // Electron can subtract no-drag rectangles outside a scroll viewport's clip. + // Check the offending geometry, rather than DOM hit testing or a specific row style. + await expect + .poll(() => chat.evaluate(inspectChatDragExclusions)) + .toEqual({ + contentCrossesHeader: true, + contentCrossesTabRow: true, + focusScopeRegion: "no-drag", + exclusions: 0, + }); + } + + // Window-owned controls are siblings of workspace chrome, not descendants of it. + const closeMenu = page.getByRole("button", { name: "Close menu", exact: true }); + await expect(closeMenu).toHaveCSS("-webkit-app-region", "no-drag"); + await closeMenu.click(); + const openMenu = page.getByRole("button", { name: "Open menu", exact: true }); + await expect(openMenu).toHaveCSS("-webkit-app-region", "no-drag"); + await openMenu.click(); + await expect(closeMenu).toBeVisible(); + + const menuTrigger = page.getByTestId("workspace-header-menu-trigger"); + await expect(menuTrigger).toHaveCSS("-webkit-app-region", "no-drag"); + await menuTrigger.click(); + const menu = page.getByTestId("workspace-header-menu"); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem").first()).toHaveCSS("-webkit-app-region", "no-drag"); + await page.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + await page.getByRole("textbox", { name: "Message agent...", exact: true }).click(); + // installDesktopRuntime supplies a macOS bridge, including shortcut identity. + await page.keyboard.press("Meta+f"); + const find = page.getByRole("textbox", { name: "Find in pane", exact: true }); + await expect(find).toBeFocused(); + await expect(find).toHaveCSS("-webkit-app-region", "no-drag"); + await find.fill("Paragraph 40:"); + await expect(page.getByRole("status", { name: "Find matches" })).toHaveText("1 of 1"); + for (const name of ["Next match", "Previous match", "Close Find"]) { + await expect(page.getByRole("button", { name, exact: true })).toHaveCSS( + "-webkit-app-region", + "no-drag", + ); + } + await page.getByRole("button", { name: "Close Find", exact: true }).click(); + await expect(find).toBeHidden(); + await expect + .poll(() => + chat.evaluate((scroll) => ({ + focusReturned: document.activeElement?.contains(scroll), + tabindex: document.activeElement?.getAttribute("tabindex"), + appRegion: getComputedStyle(document.activeElement!).getPropertyValue( + "-webkit-app-region", + ), + })), + ) + .toEqual({ focusReturned: true, tabindex: "-1", appRegion: "no-drag" }); + await page.screenshot({ path: testInfo.outputPath("scrolled-chat-titlebar.png") }); + } finally { + await agent.cleanup(); + } +}); 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 a756b08ce7..e6e2dbd567 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.11.0-beta.3", + "version": "0.11.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", @@ -25,7 +25,8 @@ "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", "test": "install-electron && vitest run --exclude \"e2e/**\"", "typecheck": "tsgo --noEmit -p tsconfig.json", - "test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs" + "test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs", + "test:e2e:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs" }, "dependencies": { "@getpaseo/cli": "*", diff --git a/packages/desktop/src/diagnostics/console-streams.test.ts b/packages/desktop/src/diagnostics/console-streams.test.ts new file mode 100644 index 0000000000..14ad337b7e --- /dev/null +++ b/packages/desktop/src/diagnostics/console-streams.test.ts @@ -0,0 +1,88 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const modulePath = fileURLToPath(new URL("./console-streams.ts", import.meta.url)); + +type Output = "stdout" | "stderr"; + +async function writeToConsole(input: { closed?: Output; invalidStdout?: boolean } = {}) { + const child = spawn( + process.execPath, + [ + "--import", + "tsx", + "-e", + ` + const { protectConsoleStreams } = require(${JSON.stringify(modulePath)}); + protectConsoleStreams(); + process.send("ready"); + process.once("message", () => { + if (${JSON.stringify(input.invalidStdout)}) require("node:fs").closeSync(1); + console.info("desktop info"); + console.warn("desktop warning"); + setImmediate(() => { process.send("alive"); process.disconnect(); }); + }); + `, + ], + { + cwd: fileURLToPath(new URL(".", import.meta.url)), + stdio: ["ignore", "pipe", "pipe", "ipc"], + env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "FORCE_COLOR")), + }, + ); + let stdout = ""; + let stderr = ""; + let alive = false; + child.stdout.setEncoding("utf8").on("data", (text: string) => { + stdout += text; + }); + child.stderr.setEncoding("utf8").on("data", (text: string) => { + stderr += text; + }); + child.on("message", (message) => { + if (message === "ready") { + if (input.closed) child[input.closed].destroy(); + child.send("write"); + } + if (message === "alive") alive = true; + }); + const code = await new Promise<number | null>((resolve, reject) => { + child.once("close", resolve); + child.once("error", reject); + }); + return { code, stdout, stderr, alive }; +} + +describe("desktop console streams", () => { + for (const stream of ["stdout", "stderr"] as const) { + it(`keeps running when the launcher closes ${stream}`, async () => { + const result = await writeToConsole({ closed: stream }); + expect(result.code, result.stderr).toBe(0); + expect(result.alive).toBe(true); + expect(result[stream === "stdout" ? "stderr" : "stdout"]).toBe( + stream === "stdout" ? "desktop warning\n" : "desktop info\n", + ); + }); + } + + // Windows stdout uses a native handle rather than the POSIX fd closed here. + it.skipIf(process.platform === "win32")( + "does not suppress a console failure other than a closed pipe", + async () => { + const result = await writeToConsole({ invalidStdout: true }); + expect(result.code).toBe(1); + expect(result.alive).toBe(false); + expect(result.stderr).toContain("EBADF"); + }, + ); + + it("preserves logging while the console has readers", async () => { + expect(await writeToConsole()).toEqual({ + code: 0, + stdout: "desktop info\n", + stderr: "desktop warning\n", + alive: true, + }); + }); +}); diff --git a/packages/desktop/src/diagnostics/console-streams.ts b/packages/desktop/src/diagnostics/console-streams.ts new file mode 100644 index 0000000000..686505bb40 --- /dev/null +++ b/packages/desktop/src/diagnostics/console-streams.ts @@ -0,0 +1,11 @@ +function ignoreClosedConsole(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + +/** Keep desktop logging independent of the program that launched its console. */ +export function protectConsoleStreams(): void { + process.stdout.on("error", ignoreClosedConsole); + process.stderr.on("error", ignoreClosedConsole); +} diff --git a/packages/desktop/src/features/browser-automation/ipc.test.ts b/packages/desktop/src/features/browser-automation/ipc.test.ts index ee7143adc6..427e86e1b1 100644 --- a/packages/desktop/src/features/browser-automation/ipc.test.ts +++ b/packages/desktop/src/features/browser-automation/ipc.test.ts @@ -190,6 +190,8 @@ class FakeWebContents { this.inputEvents.push(event); } + public async insertText(_text: string): Promise<void> {} + public on(event: "console-message", listener: ConsoleMessageListener): void { expect(event).toBe("console-message"); this.consoleMessageListener = listener; diff --git a/packages/desktop/src/features/browser-automation/ipc.ts b/packages/desktop/src/features/browser-automation/ipc.ts index 42c38d2e2c..18e5b84544 100644 --- a/packages/desktop/src/features/browser-automation/ipc.ts +++ b/packages/desktop/src/features/browser-automation/ipc.ts @@ -111,6 +111,7 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter { getBackgroundThrottling(): boolean; setBackgroundThrottling(allowed: boolean): void; sendInputEvent(event: IsolatedKeyboardInputEvent): void; + insertText(text: string): Promise<void>; } export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents { @@ -143,6 +144,7 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon } }, sendInputEvent: (event) => contents.sendInputEvent(event), + insertText: (text) => contents.insertText(text), getConsoleMessages: () => consoleMessagesByContentsId.get(contentsId) ?? [], captureDialogs: (task) => dialogMonitor.capture(task), sendDebugCommand: (command: string, params?: Record<string, unknown>) => diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index dee624a3f7..6bc054c6a5 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -41,6 +41,7 @@ class FakeTab implements TabContents { public readonly actions: string[] = []; public readonly capturedViewports: AbortSignal[] = []; public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = []; + public readonly insertedText: string[] = []; public readonly inputEvents: IsolatedKeyboardInputEvent[] = []; private readonly captureStartWaiters: Array<() => void> = []; private readonly deferredCaptures: Array<(image: TabImage) => void> = []; @@ -231,6 +232,10 @@ class FakeTab implements TabContents { this.inputEvents.push(event); } + public async insertText(text: string): Promise<void> { + this.insertedText.push(text); + } + public waitForCaptureStart(count: number): Promise<void> { if (this.capturedViewports.length >= count) { return Promise.resolve(); @@ -1104,17 +1109,31 @@ describe("executeAutomationCommand", () => { ok: true, result: { command: "type", browserId: BROWSER_A, ref: "@e1", x: 40, y: 30 }, }); - expect(browser.tab.debugCommands.at(-1)).toEqual({ - command: "Input.insertText", - params: { text: "Ada" }, - }); - expect(browser.tab.debugCommands.slice(0, 3).map((entry) => entry.command)).toEqual([ + expect(browser.tab.insertedText).toEqual(["Ada"]); + expect(browser.tab.debugCommands.map((entry) => entry.command)).toEqual([ "Input.dispatchMouseEvent", "Input.dispatchMouseEvent", "Input.dispatchMouseEvent", ]); }); + test("type inserts text through the tab, not the window-wide debugger input", async () => { + const browser = new BrowserAutomationHarness(); + + const action = await browser.execute({ + command: "type", + args: { browserId: BROWSER_A, text: "Ada" }, + }); + + expect(action).toEqual({ + requestId: "req-type", + ok: true, + result: { command: "type", browserId: BROWSER_A }, + }); + expect(browser.tab.insertedText).toEqual(["Ada"]); + expect(browser.tab.debugCommands).toEqual([]); + }); + test("keypress dispatches a trusted key to the focused element when ref is omitted", async () => { const browser = new BrowserAutomationHarness(); diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index ca6247e543..fe07adecd3 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -17,7 +17,6 @@ import { dispatchTrustedHover, dispatchTrustedKey, dispatchTrustedScroll, - dispatchTrustedText, type ClickInputOptions, type IsolatedKeyboardInputEvent, } from "./trusted-input.js"; @@ -39,6 +38,9 @@ export interface TabContents { invalidate(): void; withFrameProduction<T>(capture: () => Promise<T>): Promise<T>; sendInputEvent(event: IsolatedKeyboardInputEvent): void; + // Commits text into this tab's focused element. CDP Input.insertText would + // commit into whichever element has focus in the Paseo window instead. + insertText(text: string): Promise<void>; getConsoleMessages?(): BrowserAutomationConsoleLogEntry[]; captureDialogs?<T>( task: () => Promise<T>, @@ -1076,7 +1078,7 @@ async function executeType( } await dispatchTrustedClick(cdpSender(target.contents), actionable.target.point); } - await dispatchTrustedText(cdpSender(target.contents), text); + await target.contents.insertText(text); return { requestId, ok: true, diff --git a/packages/desktop/src/features/browser-automation/trusted-input.ts b/packages/desktop/src/features/browser-automation/trusted-input.ts index 671c5dbda1..8473263a1c 100644 --- a/packages/desktop/src/features/browser-automation/trusted-input.ts +++ b/packages/desktop/src/features/browser-automation/trusted-input.ts @@ -154,13 +154,6 @@ export async function dispatchTrustedScroll( }); } -export async function dispatchTrustedText(send: CdpCommandSender, text: string): Promise<void> { - if (text.length === 0) { - return; - } - await send("Input.insertText", { text }); -} - export function dispatchTrustedKey(send: KeyboardInputSender, key: string): void { const keyCode = ELECTRON_KEY_CODE_ALIASES[key] ?? key; let character: string | null = null; diff --git a/packages/desktop/src/integrations/cli-install/path.test.ts b/packages/desktop/src/integrations/cli-install/path.test.ts index 472f066e00..77d340b409 100644 --- a/packages/desktop/src/integrations/cli-install/path.test.ts +++ b/packages/desktop/src/integrations/cli-install/path.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { resolveCliInstallSourcePath } from "./path"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { resolveCliInstallSourcePath, resolveCliShimPath } from "./path"; describe("cli-install-path", () => { it("uses the bundled shim for packaged macOS installs", () => { @@ -56,3 +58,38 @@ describe("cli-install-path", () => { ).toBe("/opt/Paseo/resources/bin/paseo"); }); }); + +describe("CLI executable selection", () => { + const resolveWorkspaceCli = () => + createRequire(import.meta.url).resolve("@getpaseo/cli/bin/paseo"); + + it("uses the workspace CLI for an unpackaged Electron launcher", () => { + expect( + resolveCliShimPath({ + platform: "linux", + isPackaged: false, + executablePath: "/nix/store/electron/bin/electron", + resolveWorkspaceCli, + }), + ).toBe(resolveWorkspaceCli()); + }); + + it("uses the application shim for a packaged launcher", () => { + expect( + resolveCliShimPath({ + platform: "linux", + isPackaged: true, + executablePath: "/opt/Paseo/paseo", + resolveWorkspaceCli, + }), + ).toBe(path.join("/opt/Paseo", "resources", "bin", "paseo")); + expect( + resolveCliShimPath({ + platform: "darwin", + isPackaged: true, + executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo", + resolveWorkspaceCli, + }), + ).toBe(path.join("/Applications/Paseo.app", "Contents", "Resources", "bin", "paseo")); + }); +}); diff --git a/packages/desktop/src/integrations/cli-install/path.ts b/packages/desktop/src/integrations/cli-install/path.ts index c9114c2301..0cad7fc757 100644 --- a/packages/desktop/src/integrations/cli-install/path.ts +++ b/packages/desktop/src/integrations/cli-install/path.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + export function resolveCliInstallSourcePath(input: { platform: NodeJS.Platform; isPackaged: boolean; @@ -27,3 +29,19 @@ export function resolveCliInstallSourcePath(input: { return input.executablePath; } + +export function resolveCliShimPath(input: { + platform: NodeJS.Platform; + isPackaged: boolean; + executablePath: string; + resolveWorkspaceCli: () => string; +}): string { + if (!input.isPackaged) return input.resolveWorkspaceCli(); + + const filename = input.platform === "win32" ? "paseo.cmd" : "paseo"; + if (input.platform === "darwin") { + const bundle = input.executablePath.replace(/\/Contents\/MacOS\/.+$/, ""); + return path.join(bundle, "Contents", "Resources", "bin", filename); + } + return path.join(path.dirname(input.executablePath), "resources", "bin", filename); +} diff --git a/packages/desktop/src/integrations/cli-install/paths.ts b/packages/desktop/src/integrations/cli-install/paths.ts index 312a587892..1f7bf98f48 100644 --- a/packages/desktop/src/integrations/cli-install/paths.ts +++ b/packages/desktop/src/integrations/cli-install/paths.ts @@ -1,4 +1,6 @@ import path from "node:path"; +import { createRequire } from "node:module"; +import { resolveCliShimPath } from "./path.js"; import os from "node:os"; import { app } from "electron"; @@ -12,20 +14,10 @@ export function getCliTargetPath(): string { } export function getBundledCliShimPath(): string { - const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo"; - - if (process.platform === "darwin") { - const electronExePath = app.getPath("exe"); - const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, ""); - return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename); - } - - if (process.platform === "win32") { - const electronExePath = app.getPath("exe"); - return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); - } - - // Linux - const electronExePath = app.getPath("exe"); - return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); + return resolveCliShimPath({ + platform: process.platform, + isPackaged: app.isPackaged, + executablePath: app.getPath("exe"), + resolveWorkspaceCli: () => createRequire(__filename).resolve("@getpaseo/cli/bin/paseo"), + }); } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 63ab83281a..072f96a26d 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -1,3 +1,6 @@ +import { protectConsoleStreams } from "./diagnostics/console-streams.js"; +protectConsoleStreams(); + process.emitWarning = (() => {}) as typeof process.emitWarning; import log from "electron-log/main"; @@ -43,6 +46,7 @@ import { buildStandardContextMenuItems, } from "./window/window-manager.js"; import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js"; +import { showWindowWhenReady } from "./window/show-when-ready.js"; import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js"; import { registerDialogHandlers } from "./features/dialogs.js"; import { @@ -773,8 +777,8 @@ async function createWindow( registerBrowserWebviewNavigationGuards(contents); }); - mainWindow.once("ready-to-show", () => { - mainWindow.show(); + showWindowWhenReady(mainWindow, { + showOnLoad: process.platform === "linux", }); if (!app.isPackaged) { diff --git a/packages/desktop/src/window/show-when-ready.test.ts b/packages/desktop/src/window/show-when-ready.test.ts new file mode 100644 index 0000000000..756ad24cc2 --- /dev/null +++ b/packages/desktop/src/window/show-when-ready.test.ts @@ -0,0 +1,57 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { showWindowWhenReady } from "./show-when-ready.js"; + +class Window extends EventEmitter { + webContents = new EventEmitter(); + shows = 0; + destroyed = false; + show() { + this.shows += 1; + } + isDestroyed() { + return this.destroyed; + } + close() { + this.destroyed = true; + this.emit("closed"); + } +} + +describe("showWindowWhenReady", () => { + it("shows a loaded Linux window even when no initial frame was painted", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: true }); + expect(win.shows).toBe(0); + win.webContents.emit("did-finish-load"); + expect(win.shows).toBe(1); + win.emit("ready-to-show"); + expect(win.shows).toBe(1); + }); + + it("shows on the first frame without showing again after load", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: true }); + win.emit("ready-to-show"); + win.webContents.emit("did-finish-load"); + expect(win.shows).toBe(1); + }); + + it("preserves waiting for the initial frame when show on load is disabled", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: false }); + win.webContents.emit("did-finish-load"); + expect(win.shows).toBe(0); + win.emit("ready-to-show"); + expect(win.shows).toBe(1); + }); + + it("keeps a closed window closed when readiness arrives later", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: true }); + win.close(); + win.webContents.emit("did-finish-load"); + win.emit("ready-to-show"); + expect(win.shows).toBe(0); + }); +}); diff --git a/packages/desktop/src/window/show-when-ready.ts b/packages/desktop/src/window/show-when-ready.ts new file mode 100644 index 0000000000..e49c8518af --- /dev/null +++ b/packages/desktop/src/window/show-when-ready.ts @@ -0,0 +1,25 @@ +interface ReadyToShowWindow { + once(event: "ready-to-show", listener: () => void): unknown; + webContents: { + once(event: "did-finish-load", listener: () => void): unknown; + }; + show(): void; + isDestroyed(): boolean; +} + +export function showWindowWhenReady( + win: ReadyToShowWindow, + options: { showOnLoad: boolean }, +): void { + let shown = false; + const show = () => { + if (shown || win.isDestroyed()) return; + shown = true; + win.show(); + }; + + win.once("ready-to-show", show); + // On Linux a hidden window can wait for compositor frames until it is shown. + // Loading the document is sufficient to reveal it without depending on a frame. + if (options.showOnLoad) win.webContents.once("did-finish-load", show); +} diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 41f4d1f0e9..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.3", + "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 99624f726f..02e9e28daa 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.11.0-beta.3", + "version": "0.11.2", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts index 2349854d85..a943e6dd4a 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -144,6 +144,125 @@ const title = "Hello"; ); }); + it("highlights Astro markup containing only an empty expression", () => { + const code = "<p>{}</p>"; + + const tokens = highlightCode(code, "Empty.astro").flat(); + + expect(tokens.map((token) => token.text).join("")).toBe(code); + expect(tokens).toContainEqual({ text: "p", style: "tag" }); + }); + + it.each([ + "<p>{title} {} {title}</p>", + '<p class="{} {title}">{title}</p>', + "<p class={} title={title}>{title}</p>", + ])("highlights Astro containing an empty expression: %s", (markup) => { + const code = `---\nconst title = "Hello";\n---\n${markup}\n<style>.page { color: red; }</style>`; + + const lines = highlightCode(code, "Empty.astro"); + + expect(lines).toHaveLength(code.split("\n").length); + for (const [index, tokens] of lines.entries()) { + expect(tokens.map((token) => token.text).join("")).toBe(code.split("\n")[index]); + } + expect(lines.flat()).toContainEqual({ text: "const", style: "keyword" }); + expect(lines.flat()).toContainEqual({ text: "p", style: "tag" }); + expect(lines.flat()).toContainEqual({ text: "color", style: "property" }); + expect(lines.flat()).toContainEqual({ text: "title", style: "variable" }); + }); + + it("keeps an Astro expression open through nested template strings", () => { + const code = "<p>{`a ${`}`} b`}</p>"; + + const tokens = highlightCode(code, "Nested.astro").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "`}`", style: "string" }, + { text: " b`", style: "string" }, + ]), + ); + }); + + it("highlights Vue SFCs across script, template, and style", () => { + const code = [ + '<script lang="ts">', + " let count: number = 1;", + "</script>", + "", + "<template>", + ' <p v-if="count > 0" :class="badge">{{ { count: 1 } }}</p>', + " <span>{{ 'a}}b' }} {{ { inner: 2 }}}</span>", + "</template>", + "", + "<style>", + " .badge {", + " color: red;", + " }", + "</style>", + ].join("\n"); + + const tokens = highlightCode(code, "Counter.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "let", style: "keyword" }, + { text: "number", style: "type" }, + { text: "template", style: "tag" }, + { text: "v-if", style: "attribute" }, + { text: "count", style: "definition" }, + { text: "1", style: "number" }, + { text: "'a}}b'", style: "string" }, + { text: "inner", style: "definition" }, + { text: "2", style: "number" }, + { text: "badge", style: "class" }, + { text: "color", style: "property" }, + ]), + ); + }); + + it("highlights a Vue SFC that contains an empty interpolation", () => { + const code = ["<template>", " <p>{{}} {{ count }}</p>", "</template>"].join("\n"); + + const tokens = highlightCode(code, "Empty.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "template", style: "tag" }, + { text: "count", style: "variable" }, + ]), + ); + }); + + it("highlights quoted Vue directive values as code", () => { + const code = "<template><p v-if=\"count > 0\" :class='badge'>x</p></template>"; + + const tokens = highlightCode(code, "Directive.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "count", style: "variable" }, + { text: "0", style: "number" }, + { text: "badge", style: "variable" }, + ]), + ); + expect(tokens).not.toContainEqual({ text: '"count > 0"', style: "string" }); + }); + + it("keeps a Vue interpolation open through nested template strings", () => { + const code = "<template><p>{{ `a ${`}}`} b` }}</p></template>"; + + const tokens = highlightCode(code, "Nested.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "`}}`", style: "string" }, + { text: " b`", style: "string" }, + ]), + ); + }); + it("highlights TSX code with correct dialect", () => { const code = 'const el = <div className="test">hello</div>;'; const result = highlightCode(code, "test.tsx"); diff --git a/packages/highlight/src/__tests__/parsers.test.ts b/packages/highlight/src/__tests__/parsers.test.ts index 1d0d0ebcdd..b24872eb94 100644 --- a/packages/highlight/src/__tests__/parsers.test.ts +++ b/packages/highlight/src/__tests__/parsers.test.ts @@ -25,6 +25,7 @@ describe("isLanguageSupported", () => { expect(isLanguageSupported("test.ex")).toBe(true); expect(isLanguageSupported("Counter.svelte")).toBe(true); expect(isLanguageSupported("Page.astro")).toBe(true); + expect(isLanguageSupported("App.vue")).toBe(true); }); it("returns false for unsupported file extensions", () => { @@ -67,6 +68,7 @@ describe("getSupportedExtensions", () => { expect(extensions).toContain("json"); expect(extensions).toContain("svelte"); expect(extensions).toContain("astro"); + expect(extensions).toContain("vue"); }); }); diff --git a/packages/highlight/src/astro/parser.ts b/packages/highlight/src/astro/parser.ts index 444235488f..cef0748b5a 100644 --- a/packages/highlight/src/astro/parser.ts +++ b/packages/highlight/src/astro/parser.ts @@ -14,6 +14,7 @@ import { import { parser as cssParser } from "@lezer/css"; import { parser as htmlParser } from "@lezer/html"; import { parser as jsParser } from "@lezer/javascript"; +import { findClosingBrace } from "../js-scanner.js"; interface Range { from: number; @@ -23,88 +24,11 @@ interface Range { const jsxParser = jsParser.configure({ dialect: "ts jsx" }); const typescriptParser = jsParser.configure({ dialect: "ts" }); -function isSpace(code: number): boolean { - return code === 32 || code === 9 || code === 10 || code === 13; -} - -function isRegexStart(text: string, position: number): boolean { - let previous = position - 1; - while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; - if (previous < 0) return true; - - const character = text[previous]; - if (/[)\]}<"'`\d]/.test(character)) return false; - const code = text.charCodeAt(previous); - if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; - if (!/[A-Za-z_$]/.test(character)) return true; - - let start = previous; - while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; - const keyword = text.slice(start + 1, previous + 1); - return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( - keyword, - ); -} - -function skipQuotedText(text: string, opening: number): number { - const quote = text.charCodeAt(opening); - for (let position = opening + 1; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 92) position++; - else if (code === quote) return position; - } - return text.length - 1; -} - -function skipLineComment(text: string, opening: number): number { - const newline = text.indexOf("\n", opening + 2); - return newline >= 0 ? newline : text.length - 1; -} - -function skipBlockComment(text: string, opening: number): number { - const closing = text.indexOf("*/", opening + 2); - return closing >= 0 ? closing + 1 : text.length - 1; -} - -function skipRegex(text: string, opening: number): number { - let isInCharacterClass = false; - for (let position = opening + 1; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 10 || code === 13) return position; - if (code === 92) position++; - else if (isInCharacterClass && code === 93) isInCharacterClass = false; - else if (!isInCharacterClass && code === 91) isInCharacterClass = true; - else if (!isInCharacterClass && code === 47) return position; - } - return text.length - 1; -} - -function findClosingBrace(text: string, opening: number): number { - let depth = 0; - for (let position = opening; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 47 && text.charCodeAt(position + 1) === 47) { - position = skipLineComment(text, position); - } else if (code === 47 && text.charCodeAt(position + 1) === 42) { - position = skipBlockComment(text, position); - } else if (code === 47 && isRegexStart(text, position)) { - position = skipRegex(text, position); - } else if (code === 34 || code === 39 || code === 96) { - position = skipQuotedText(text, position); - } else if (code === 123) { - depth++; - } else if (code === 125 && --depth === 0) { - return position; - } - } - return -1; -} - function findExpressions(text: string): Range[] { const ranges: Range[] = []; for (let position = 0; position < text.length; position++) { if (text.charCodeAt(position) !== 123) continue; - const closing = findClosingBrace(text, position); + const closing = findClosingBrace(text, position + 1); if (closing < 0) break; ranges.push({ from: position, to: closing }); position = closing; @@ -121,10 +45,12 @@ function maskExpressions(text: string): string { } function expressionOverlays(node: SyntaxNodeRef, input: Input): Range[] | null { - const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({ - from: node.from + from + 1, - to: node.from + to, - })); + const overlays = findExpressions(input.read(node.from, node.to)) + .filter(({ from, to }) => to > from + 1) + .map(({ from, to }) => ({ + from: node.from + from + 1, + to: node.from + to, + })); return overlays.length > 0 ? overlays : null; } diff --git a/packages/highlight/src/js-scanner.ts b/packages/highlight/src/js-scanner.ts new file mode 100644 index 0000000000..fb7f70ed77 --- /dev/null +++ b/packages/highlight/src/js-scanner.ts @@ -0,0 +1,100 @@ +// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}` +// interpolations) for the brace that closes it, without parsing the code. + +function isSpace(code: number): boolean { + return code === 32 || code === 9 || code === 10 || code === 13; +} + +function isRegexStart(text: string, position: number): boolean { + let previous = position - 1; + while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; + if (previous < 0) return true; + + const character = text[previous]; + if (/[)\]}<"'`\d]/.test(character)) return false; + const code = text.charCodeAt(previous); + if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; + if (!/[A-Za-z_$]/.test(character)) return true; + + let start = previous; + while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; + const keyword = text.slice(start + 1, previous + 1); + return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( + keyword, + ); +} + +// Template strings nested deeper than this are treated as running to the end. +const MAX_TEMPLATE_NESTING = 32; + +function skipQuotedText(text: string, opening: number, nesting = 0): number { + const quote = text.charCodeAt(opening); + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 92) position++; + else if (code === quote) return position; + else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) { + position = skipTemplateSubstitution(text, position + 2, nesting); + } + } + return text.length - 1; +} + +function skipTemplateSubstitution(text: string, start: number, nesting: number): number { + if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1; + const closing = findClosingBrace(text, start, () => true, nesting + 1); + return closing >= 0 ? closing : text.length - 1; +} + +function skipLineComment(text: string, opening: number): number { + const newline = text.indexOf("\n", opening + 2); + return newline >= 0 ? newline : text.length - 1; +} + +function skipBlockComment(text: string, opening: number): number { + const closing = text.indexOf("*/", opening + 2); + return closing >= 0 ? closing + 1 : text.length - 1; +} + +function skipRegex(text: string, opening: number): number { + let isInCharacterClass = false; + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 10 || code === 13) return position; + if (code === 92) position++; + else if (isInCharacterClass && code === 93) isInCharacterClass = false; + else if (!isInCharacterClass && code === 91) isInCharacterClass = true; + else if (!isInCharacterClass && code === 47) return position; + } + return text.length - 1; +} + +// Finds the first `}` outside nested braces, strings, comments, and regexes +// for which `isEnd` holds. +export function findClosingBrace( + text: string, + start: number, + isEnd: (position: number) => boolean = () => true, + nesting = 0, +): number { + let depth = 0; + for (let position = start; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 47 && text.charCodeAt(position + 1) === 47) { + position = skipLineComment(text, position); + } else if (code === 47 && text.charCodeAt(position + 1) === 42) { + position = skipBlockComment(text, position); + } else if (code === 47 && isRegexStart(text, position)) { + position = skipRegex(text, position); + } else if (code === 34 || code === 39 || code === 96) { + position = skipQuotedText(text, position, nesting); + } else if (code === 123) { + depth++; + } else if (code === 125 && depth === 0) { + if (isEnd(position)) return position; + } else if (code === 125) { + depth--; + } + } + return -1; +} diff --git a/packages/highlight/src/parsers.ts b/packages/highlight/src/parsers.ts index 9ccb3129be..06c3129733 100644 --- a/packages/highlight/src/parsers.ts +++ b/packages/highlight/src/parsers.ts @@ -18,6 +18,7 @@ import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; import { csharpLanguage } from "./csharp/language.js"; import { astroParser } from "./astro/parser.js"; +import { vueParser } from "./vue/parser.js"; import { nixLanguage } from "./nix/language.js"; import { parser as svelteBaseParser } from "./svelte/parser.js"; import { configureNesting, defaultNesting } from "./svelte/nesting.js"; @@ -56,6 +57,8 @@ const languagesByExtension: Record<string, Language> = { svelte: language(svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) })), // Astro astro: language(astroParser), + // Vue + vue: language(vueParser), // XML xml: language(xmlParser), // Java diff --git a/packages/highlight/src/vue/parser.ts b/packages/highlight/src/vue/parser.ts new file mode 100644 index 0000000000..5c54825b61 --- /dev/null +++ b/packages/highlight/src/vue/parser.ts @@ -0,0 +1,136 @@ +import { + type Input, + type NestedParse, + type SyntaxNode, + type SyntaxNodeRef, + parseMixed, +} from "@lezer/common"; +import { parser as cssParser } from "@lezer/css"; +import { parser as htmlParser } from "@lezer/html"; +import { parser as jsParser } from "@lezer/javascript"; +import { findClosingBrace } from "../js-scanner.js"; + +interface Range { + from: number; + to: number; +} + +const typescriptParser = jsParser.configure({ dialect: "ts" }); +const expressionParser = jsParser.configure({ top: "SingleExpression" }); + +function findInterpolationEnd(text: string, start: number): number { + return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125); +} + +function findExpressions(text: string): Range[] { + const ranges: Range[] = []; + for (let position = 0; position < text.length; position++) { + if (text.charCodeAt(position) !== 123) continue; + if (text.charCodeAt(position + 1) !== 123) continue; + const closing = findInterpolationEnd(text, position + 2); + if (closing < 0) break; + // Lezer rejects empty inner ranges, so `{{}}` gets no nested parse. + if (closing > position + 2) ranges.push({ from: position + 2, to: closing }); + position = closing + 1; + } + return ranges; +} + +function findDirectiveExpression(value: string): Range | null { + const quote = value[0]; + const isQuoted = (quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote); + const inner = isQuoted ? value.slice(1, -1) : value; + const trimmed = inner.trim(); + if (!trimmed) return null; + const from = (isQuoted ? 1 : 0) + inner.indexOf(trimmed); + return { from, to: from + trimmed.length }; +} + +function getOpenTagAttributes(node: SyntaxNode, input: Input): Record<string, string> { + const attributes: Record<string, string> = Object.create(null); + const openTag = node.getChild("OpenTag"); + if (!openTag) return attributes; + + for (const attribute of openTag.getChildren("Attribute")) { + const name = attribute.getChild("AttributeName"); + if (!name) continue; + const value = + attribute.getChild("AttributeValue") || attribute.getChild("UnquotedAttributeValue"); + const key = input.read(name.from, name.to).toLowerCase(); + attributes[key] = value ? input.read(value.from, value.to).replace(/^["']|["']$/g, "") : ""; + } + return attributes; +} + +function isDirectiveAttribute(attribute: string): boolean { + return ( + attribute.startsWith("v-") || + attribute.startsWith(":") || + attribute.startsWith("@") || + attribute.startsWith(".") + ); +} + +function directiveOverlay( + node: SyntaxNodeRef, + parent: SyntaxNode, + input: Input, +): NestedParse | null { + const attributeName = parent.getChild("AttributeName"); + if (!attributeName) return null; + if (!isDirectiveAttribute(input.read(attributeName.from, attributeName.to).toLowerCase())) { + return null; + } + + const range = findDirectiveExpression(input.read(node.from, node.to)); + if (!range) return null; + return { + parser: expressionParser, + overlay: [{ from: node.from + range.from, to: node.from + range.to }], + }; +} + +function textOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({ + from: node.from + from, + to: node.from + to, + })); + return overlays.length > 0 ? { parser: expressionParser, overlay: overlays } : null; +} + +function templateOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (parent?.name === "Attribute") return directiveOverlay(node, parent, input); + if (node.name !== "Text") return null; + return textOverlay(node, input); +} + +function scriptLanguage(attributes: Record<string, string>): NestedParse | null { + if (attributes.src) return null; + + const language = (attributes.lang || attributes.type || "").toLowerCase(); + if (language.includes("tsx")) return { parser: jsParser.configure({ dialect: "ts jsx" }) }; + if (language.includes("typescript") || language === "ts") return { parser: typescriptParser }; + if (language.includes("jsx")) return { parser: jsParser.configure({ dialect: "jsx" }) }; + return { parser: jsParser }; +} + +function scriptOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (!parent) return null; + return scriptLanguage(getOpenTagAttributes(parent, input)); +} + +function nestedLanguage(node: SyntaxNodeRef, input: Input): NestedParse | null { + const name = node.name; + if (name === "Text" || name === "UnquotedAttributeValue" || name === "AttributeValue") { + return templateOverlay(node, input); + } + if (name === "StyleText") return { parser: cssParser }; + if (name === "ScriptText") return scriptOverlay(node, input); + return null; +} + +export const vueParser = htmlParser.configure({ + wrap: parseMixed(nestedLanguage), +}); diff --git a/packages/plugin/package.json b/packages/plugin/package.json index efb069a90a..2797c32211 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.11.0-beta.3", + "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.3", - "@getpaseo/protocol": "0.11.0-beta.3", + "@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.3", - "@getpaseo/protocol": "0.11.0-beta.3", + "@getpaseo/client": "0.11.2", + "@getpaseo/protocol": "0.11.2", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 38197e4cb6..5b1ce4f3dc 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -4,6 +4,7 @@ import { isBuiltin } from "node:module"; import { fileURLToPath } from "node:url"; import ts from "typescript"; import { describe, expect, it } from "vitest"; +import * as server from "./server/index.js"; const sourceDirectory = path.dirname(fileURLToPath(import.meta.url)); const entries = { @@ -64,6 +65,10 @@ function boundaryViolations(entry: string, runtime: "shared" | "server" | "clien } describe("plugin SDK import boundaries", () => { + it("publishes process capabilities without their Windows implementation helpers", () => { + expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); + }); + it("classifies every published entry", () => { const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort()); diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index fb96e4da16..fc78d2a8c4 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawnProcess, terminateProcess } from "../process.js"; import { Readable, Writable } from "node:stream"; import { ClientSideConnection, @@ -358,8 +359,6 @@ class AcpRuntime { private emit: (event: ProviderEvent) => void; private readonly messages = new Map<string, string>(); private readonly toolCalls = new Map<string, AcpToolCallSnapshot>(); - // Running tool rows published by transformers; the turn's terminal closes any left open. - private readonly vendorToolCalls = new Map<string, ToolCallTimelineItem>(); private readonly pendingCompactions = new Set<string>(); private readonly permissions = new Map< string, @@ -402,9 +401,9 @@ class AcpRuntime { args: options.options.command.slice(1), env: process.env, }; - child = spawn(launch.command, launch.args, { + child = spawnProcess(launch.command, launch.args, { env: { ...launch.env, ...options.env }, - stdio: ["pipe", "pipe", "pipe"], + stdio: "pipe", }); spawnFailure = new Promise<never>((_resolve, reject) => child!.once("error", reject)); child.stderr.on("data", () => undefined); @@ -596,13 +595,10 @@ class AcpRuntime { turnId, state: "started", }); - // The SDK dispatches notifications without awaiting them, so the prompt response can resolve - // while earlier session updates are still queued. Apply them before the turn ends. const settled = this.call( this.connection.prompt({ sessionId: this.nativeSessionId, prompt }), ).then( - async (response): Promise<void> => { - await this.drainNotifications(); + (response): void => { const state = response.stopReason === "cancelled" ? "canceled" : "completed"; this.fallbackChunkIds.clear(); this.terminalizeTransientItems(state); @@ -614,8 +610,7 @@ class AcpRuntime { }); return undefined; }, - async (error): Promise<void> => { - await this.drainNotifications(); + (error): void => { this.fallbackChunkIds.clear(); this.terminalizeTransientItems("failed"); this.emit({ @@ -740,9 +735,9 @@ class AcpRuntime { if (!child || this.processFailed) return; if (child.exitCode !== null || child.signalCode !== null) return; const closed = new Promise<void>((resolve) => child.once("close", () => resolve())); - child.kill("SIGTERM"); + await terminateProcess(child, "SIGTERM"); if (await settlesWithin(closed, 1_000)) return; - child.kill("SIGKILL"); + await terminateProcess(child); if (!(await settlesWithin(closed, 1_000))) { throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`); } @@ -1032,17 +1027,9 @@ class AcpRuntime { this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, - item: terminalToolItem(toolTimelineItem(snapshot), state), + item: toolTimelineItem(completed), }); } - for (const item of this.vendorToolCalls.values()) { - this.emit({ - type: "timeline.item", - sessionId: this.options.boundarySessionId, - item: terminalToolItem(item, state), - }); - } - this.vendorToolCalls.clear(); for (const id of this.pendingCompactions) { this.emit({ type: "timeline.item", @@ -1089,10 +1076,6 @@ class AcpRuntime { if (this.configTransaction) this.stagedTransformerConfig = update.config; else this.emitConfigSnapshot(update.config); } else if (update.type === "timeline") { - if (update.item.type === "tool_call") { - if (update.item.status === "running") this.vendorToolCalls.set(update.item.id, update.item); - else this.vendorToolCalls.delete(update.item.id); - } this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, @@ -1400,9 +1383,7 @@ function configValues( return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); } -type ToolCallTimelineItem = Extract<ProviderTimelineItem, { type: "tool_call" }>; - -function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem { +function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { const input = jsonRecord(snapshot.input); const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; const detail = isEdit @@ -1430,18 +1411,6 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem { return { ...base, status, error: null }; } -// A row the turn left open ends with the turn. The wire contract requires a non-null error on a -// failed tool call, so a canceled turn cancels its rows instead of failing them. -function terminalToolItem( - item: ToolCallTimelineItem, - state: "completed" | "failed" | "canceled", -): ToolCallTimelineItem { - if (state === "failed") { - return { ...item, status: "failed", error: item.error ?? { message: "Turn failed" } }; - } - return { ...item, status: state, error: null }; -} - function jsonValue(value: unknown) { return z.json().parse(value); } diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index 13d69416bb..a62b2f2c48 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -619,146 +619,6 @@ lines.on("line", (line) => { await connection.close(); }); - it.each([ - ["end_turn", "completed", "completed"], - ["cancelled", "canceled", "canceled"], - ] as const)( - "closes a transformer's running tool row when the turn ends with %s", - async (stopReason, turnState, rowStatus) => { - const harness = connectorHarness({ - handleMessage(instance, message) { - if (!("method" in message) || !("id" in message)) return false; - if (message.method !== "session/prompt") return false; - instance.notify("vendor/phase", { id: "phase-1", label: "Preparing" }); - instance.respond(message as AcpRequestMessage, { stopReason }); - return true; - }, - }); - const registration = runAcpProvider({ - id: "vendor-row-acp", - label: "Vendor row ACP", - connector: harness.connector, - transformers: [ - { - notification(notification) { - if (notification.method !== "vendor/phase") return null; - return { - type: "timeline", - item: { - type: "tool_call", - id: "phase-1", - callId: "phase-1", - name: "Vendor phase", - detail: { type: "plain_text", label: "Preparing" }, - status: "running", - error: null, - }, - }; - }, - }, - ], - }); - const connection = await registration.connect({ - versions: [1], - capabilities: ["prompt.message"], - }); - const events: ProviderEvent[] = []; - connection.onEvent((event) => events.push(event)); - await connection.send(openInput()); - await waitForEvent(events, (event) => event.type === "session.ready"); - - await connection.send({ - type: "session.prompt", - sessionId: "session-1", - prompt: { - clientMessageId: "first", - delivery: "auto", - input: { type: "message", content: [{ type: "text", text: "first" }] }, - }, - }); - await waitForEvent( - events, - (event) => event.type === "session.turn" && event.state === turnState, - ); - - const rows = events.flatMap((event) => - event.type === "timeline.item" && event.item.id === "phase-1" ? [event.item] : [], - ); - expect(rows.map((row) => row.type === "tool_call" && row.status)).toEqual([ - "running", - rowStatus, - ]); - const closed = events.findIndex( - (event) => - event.type === "timeline.item" && - event.item.id === "phase-1" && - event.item.type === "tool_call" && - event.item.status === rowStatus, - ); - const terminal = events.findIndex( - (event) => event.type === "session.turn" && event.state === turnState, - ); - expect(closed).toBeLessThan(terminal); - // The wire contract only accepts a failed tool call with a non-null error. - expect(rows.at(-1)).toMatchObject({ error: null }); - await connection.close(); - }, - ); - - it("cancels an in-progress ACP tool call with the canceled turn instead of failing it without an error", async () => { - const harness = connectorHarness({ - handleMessage(instance, message) { - if (!("method" in message) || !("id" in message)) return false; - if (message.method !== "session/prompt") return false; - instance.notify("session/update", { - sessionId: "connector-session", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-1", - title: "Run tests", - kind: "execute", - status: "in_progress", - }, - }); - instance.respond(message as AcpRequestMessage, { stopReason: "cancelled" }); - return true; - }, - }); - const registration = runAcpProvider({ - id: "cancel-tool-acp", - label: "Cancel tool ACP", - connector: harness.connector, - }); - const connection = await registration.connect({ - versions: [1], - capabilities: ["prompt.message"], - }); - const events: ProviderEvent[] = []; - connection.onEvent((event) => events.push(event)); - await connection.send(openInput()); - await waitForEvent(events, (event) => event.type === "session.ready"); - - await connection.send({ - type: "session.prompt", - sessionId: "session-1", - prompt: { - clientMessageId: "first", - delivery: "auto", - input: { type: "message", content: [{ type: "text", text: "first" }] }, - }, - }); - await waitForEvent( - events, - (event) => event.type === "session.turn" && event.state === "canceled", - ); - - const rows = events.flatMap((event) => - event.type === "timeline.item" && event.item.id === "tool-1" ? [event.item] : [], - ); - expect(rows.at(-1)).toMatchObject({ type: "tool_call", status: "canceled", error: null }); - await connection.close(); - }); - it("serializes configuration mutations", async () => { let releaseMutation: (() => void) | null = null; const configOptions = [ diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index dc211f61bf..9180023d19 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -22,3 +22,5 @@ export type { PluginBeforeRequests, PluginLifecycleRegistration, } from "./lifecycle.js"; + +export { spawnProcess, execCommand, terminateProcess } from "./process.js"; diff --git a/packages/plugin/src/server/process-internal/windows-command.test.ts b/packages/plugin/src/server/process-internal/windows-command.test.ts new file mode 100644 index 0000000000..3eb5e2e63b --- /dev/null +++ b/packages/plugin/src/server/process-internal/windows-command.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { quoteWindowsArgument, quoteWindowsCommand } from "./windows-command.js"; + +describe("quoteWindowsCommand", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("quotes a Windows path with spaces", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( + '"C:\\Program Files\\Anthropic\\claude.exe"', + ); + }); + + test("does not double-quote an already-quoted path", () => { + setPlatform("win32"); + expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( + '"C:\\Program Files\\Anthropic\\claude.exe"', + ); + }); + + test("returns the command unchanged when there are no spaces", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); + }); + + test("escapes ampersands", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); + }); + + test("escapes pipes", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); + }); + + test("does not double percent signs", () => { + setPlatform("win32"); + // cmd.exe only collapses %% → % inside batch files; on the command line + // it stays literal, which corrupts git --format atoms etc. + expect(quoteWindowsCommand("100%")).toBe("100%"); + }); + + test("preserves git --format atoms verbatim", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe( + "--format=%^(refname^)%09%^(committerdate:unix^)", + ); + }); + + test("escapes carets", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); + }); + + test("escapes multiple metacharacters", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe( + "build^&^(test^|deploy^)^!^<output^>", + ); + }); + + test("quotes commands with spaces after escaping metacharacters", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( + '"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"', + ); + }); + + test("returns the command unchanged on non-Windows platforms", () => { + setPlatform("darwin"); + expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); + }); +}); + +describe("quoteWindowsArgument", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("quotes a Windows argument with spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("does not double-quote an already-quoted argument", () => { + setPlatform("win32"); + expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("returns the argument unchanged when there are no spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("--version")).toBe("--version"); + }); + + test("returns the argument unchanged on non-Windows platforms", () => { + setPlatform("darwin"); + expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); + }); +}); diff --git a/packages/server/src/utils/windows-command.ts b/packages/plugin/src/server/process-internal/windows-command.ts similarity index 100% rename from packages/server/src/utils/windows-command.ts rename to packages/plugin/src/server/process-internal/windows-command.ts diff --git a/packages/plugin/src/server/process.test.ts b/packages/plugin/src/server/process.test.ts new file mode 100644 index 0000000000..e9b3000da5 --- /dev/null +++ b/packages/plugin/src/server/process.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { execCommand, spawnProcess } from "./index.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function writeArgumentReader(): Promise<string> { + const root = await mkdtemp(path.join(os.tmpdir(), "plugin process arguments ")); + roots.push(root); + const script = path.join(root, "read arguments.mjs"); + await writeFile(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + return script; +} + +const argumentsToPreserve = [ + "argument with spaces", + "--format=%(refname)%09%(committerdate:unix)", + "100%", + "feature&bugfix", + "feature|bugfix", + "feature^bugfix", +]; + +// A bare extensionless command selects the Windows shell; an absolute node.exe path would +// skip that path. The real child checks quoting through the public process capabilities. +test("execCommand delivers shell arguments to the real child unchanged", async () => { + const script = await writeArgumentReader(); + const { stdout } = await execCommand("node", [script, ...argumentsToPreserve]); + expect(JSON.parse(stdout)).toEqual(argumentsToPreserve); +}); + +test("spawnProcess delivers shell arguments to the real child unchanged", async () => { + const script = await writeArgumentReader(); + const child = spawnProcess("node", [script, ...argumentsToPreserve], { stdio: "pipe" }); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + await new Promise<void>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Argument reader exited with ${code}`)); + }); + }); + expect(JSON.parse(stdout)).toEqual(argumentsToPreserve); +}); diff --git a/packages/plugin/src/server/process.ts b/packages/plugin/src/server/process.ts new file mode 100644 index 0000000000..b2ba70ac12 --- /dev/null +++ b/packages/plugin/src/server/process.ts @@ -0,0 +1,119 @@ +import { + execFile, + spawn, + type ChildProcess, + type ChildProcessWithoutNullStreams, + type SpawnOptions, +} from "node:child_process"; +import { extname } from "node:path"; +import { promisify } from "node:util"; +import { + isWindowsCommandScript, + quoteWindowsArgument, + quoteWindowsCommand, +} from "./process-internal/windows-command.js"; +const execFileAsync = promisify(execFile); +export interface ExecCommandOptions { + env?: NodeJS.ProcessEnv; + cwd?: string; + encoding?: BufferEncoding; + killSignal?: NodeJS.Signals; + timeout?: number; + maxBuffer?: number; + shell?: boolean | string; + signal?: AbortSignal; +} +interface ExecCommandResult { + stdout: string; + stderr: string; +} +function hasPathSeparator(value: string): boolean { + return value.includes("/") || value.includes("\\"); +} + +function shouldUseWindowsShell( + command: string, + requestedShell?: boolean | string, +): boolean | string { + if (isWindowsCommandScript(command)) { + return true; + } + if (requestedShell !== undefined) { + return requestedShell; + } + return process.platform === "win32" && !hasPathSeparator(command) && !extname(command); +} + +/** Launch a CLI with Windows command-script handling and argv quoting. The caller owns env. */ +export function spawnProcess( + command: string, + args: string[], + options: SpawnOptions & { stdio: "pipe" }, +): ChildProcessWithoutNullStreams; +export function spawnProcess(command: string, args: string[], options?: SpawnOptions): ChildProcess; +export function spawnProcess( + command: string, + args: string[], + options?: SpawnOptions, +): ChildProcess { + const spawnOptions = options ?? {}; + const isWindows = process.platform === "win32"; + const shell = shouldUseWindowsShell(command, spawnOptions.shell); + + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; + + return spawn(resolvedCommand, resolvedArgs, { + ...spawnOptions, + env: options?.env, + shell, + signal: options?.signal, + windowsHide: true, + }); +} + +export async function execCommand( + command: string, + args: string[], + options?: ExecCommandOptions, +): Promise<ExecCommandResult> { + const isWindows = process.platform === "win32"; + const shell = shouldUseWindowsShell(command, options?.shell); + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; + + return execFileAsync(resolvedCommand, resolvedArgs, { + cwd: options?.cwd, + env: options?.env, + encoding: options?.encoding ?? "utf8", + signal: options?.signal, + killSignal: options?.killSignal, + timeout: options?.timeout, + maxBuffer: options?.maxBuffer, + shell, + windowsHide: true, + }) as Promise<ExecCommandResult>; +} + +/** Force-stop a CLI, including descendants behind Windows command-script launchers. */ +export async function terminateProcess( + child: ChildProcess, + signal: NodeJS.Signals = "SIGKILL", +): Promise<void> { + if (process.platform !== "win32") { + child.kill(signal); + return; + } + if (child.pid === undefined) return; + try { + await execCommand("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + shell: false, + timeout: 5000, + }); + } catch (error) { + // The process can finish between its close check and taskkill. + if (!(error instanceof Error && "code" in error && error.code === 128)) throw error; + } +} diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 4ad2661d67..fa23ca9c91 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -58,6 +58,8 @@ export interface UsageAccount { /** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */ key: string; label?: string; + /** Harness owning this login, e.g. Codex, OpenCode, Pi or OMP. */ + harness?: string; /** Store locator, opaque to the daemon. */ input: JsonValue; } diff --git a/packages/protocol/package.json b/packages/protocol/package.json index eb5ad255d4..8ce97a1025 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.11.0-beta.3", + "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.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index 5c62e90669..f266b65988 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => { expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); }, ); + it("preserves manifest metadata in plugin list responses", () => { + const message = { + type: "plugin.list.response", + payload: { + requestId: "metadata", + plugins: [ + { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + path: "/plugins/review", + enabled: false, + status: "disabled", + }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }); + it("parses plugin timeline append messages and advertises the capability", () => { expect( SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 3394c8e15f..ca3ab86135 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + AgentTimelineItemPayloadSchema, FileExplorerRequestSchema, PaseoWorktreeArchiveRequestSchema, parseServerInfoStatusPayload, @@ -520,3 +521,30 @@ describe("viewed timeline subscription messages", () => { }); }); }); + +test("agent messages are an optional semantic payload on existing tool rows", () => { + const legacy = { + type: "tool_call", + callId: "delivery", + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Done" }, + }; + const agentMessage = { + event: "finished", + sender: { id: "server::worker", title: "Reviewer" }, + text: "Done", + }; + expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy); + expect(AgentTimelineItemPayloadSchema.parse({ ...legacy, agentMessage })).toEqual({ + ...legacy, + agentMessage, + }); + expect( + AgentTimelineItemPayloadSchema.safeParse({ + ...legacy, + agentMessage: { ...agentMessage, sender: { id: 42 } }, + }).success, + ).toBe(false); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0214502263..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 @@ -6289,6 +6323,17 @@ export const UsageReportEntrySchema = z.object({ sourceLabel: z.string(), icon: z.string().optional(), report: UsageReportSchema, + loginErrors: z + .array( + z.object({ + harness: z.string(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + ) + .optional(), }); export const UsageListReportsUpdateMessageSchema = z.object({ type: z.literal("usage.list_reports.update"), @@ -6621,6 +6666,9 @@ export const PluginNpmInstallationSchema = z.object({ export const PluginListItemSchema = z.object({ id: PluginIdSchema, + name: z.string().optional(), + icon: z.string().optional(), + media: z.array(z.string()).optional(), description: z.string().optional(), path: z.string(), enabled: z.boolean(), diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index fe2a79916f..2298d680a4 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -389,3 +389,50 @@ test("blocked setup preserves the legacy failed shape and optional provenance", legacySnapshot.parse(failed), ); }); + +test("usage login errors are additive and older reports still parse", () => { + const entry = { + id: "codex:account", + account: {}, + fetchedAt: "2026-10-05T00:00:00.000Z", + sourceId: "codex", + sourceLabel: "Codex", + report: { status: "error", error: "Usage API returned 500" }, + }; + const legacy = z.object({ + type: z.literal("usage.list_reports.update"), + payload: z.object({ + requestId: z.string(), + report: z.object({ + id: z.string(), + account: z.object({ label: z.string().optional() }), + fetchedAt: z.string(), + sourceId: z.string(), + sourceLabel: z.string(), + icon: z.string().optional(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("available"), windows: z.array(z.unknown()) }), + z.object({ status: z.literal("unavailable"), problem: z.unknown() }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + }), + }); + const oldMessage = { + type: "usage.list_reports.update", + payload: { requestId: "usage", report: entry }, + }; + const newMessage = { + ...oldMessage, + payload: { + ...oldMessage.payload, + report: { + ...entry, + loginErrors: [{ harness: "Codex", report: entry.report }], + }, + }, + }; + expect(SessionOutboundMessageSchema.parse(oldMessage)).toEqual(oldMessage); + expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage); + expect(legacy.parse(newMessage)).toEqual(oldMessage); +}); diff --git a/packages/protocol/src/paseo-tool-call-detail.ts b/packages/protocol/src/paseo-tool-call-detail.ts index a922a78442..4b06d49923 100644 --- a/packages/protocol/src/paseo-tool-call-detail.ts +++ b/packages/protocol/src/paseo-tool-call-detail.ts @@ -47,6 +47,7 @@ const AGENT_FIELDS = [ "modeId", "background", "notifyOnFinish", + "internal", "settings", "labels", ] as const; @@ -196,6 +197,7 @@ const FIELD_LABELS: Readonly<Record<string, string>> = { newMode: "New mode", nextRunAt: "Next run", notifyOnFinish: "Notify on finish", + internal: "Internal", prNumber: "Change request", projectId: "Project", removedDirectory: "Removed directory", diff --git a/packages/protocol/src/plugin-overview.test.ts b/packages/protocol/src/plugin-overview.test.ts new file mode 100644 index 0000000000..c4023965da --- /dev/null +++ b/packages/protocol/src/plugin-overview.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { pluginOverviewUrl } from "./plugin-overview.js"; + +describe("plugin overview destinations", () => { + it.each([ + undefined, + "", + "./docs", + "../docs", + "/login", + "//example.com", + "#section", + "?login", + "http://example.com", + "mailto:hello@example.com", + "ftp://example.com", + "javascript:alert(1)", + "data:image/png;base64,AAAA", + "vbscript:msgbox(1)", + "javascript:alert(1)", + "javascript%3Aalert(1)", + "javascript%253Aalert(1)", + "java\tscript:alert(1)", + " https://example.com", + "https://example.com\n", + "https:example.com", + "https:///example.com", + "https://paseo.sh@evil.example", + "https://example.com\\@evil.example", + "https://", + ])("leaves %s as text", (url) => { + expect(pluginOverviewUrl(url)).toBeUndefined(); + }); + + it.each([ + "https://example.com/path?q=a&b=c#section", + "HTTPS://example.com/path", + "https://example.com/%3Cscript%3E", + ])("allows %s without decoding it again", (url) => { + expect(pluginOverviewUrl(url)).toBe(url); + }); +}); diff --git a/packages/protocol/src/plugin-overview.ts b/packages/protocol/src/plugin-overview.ts new file mode 100644 index 0000000000..2507b511b0 --- /dev/null +++ b/packages/protocol/src/plugin-overview.ts @@ -0,0 +1,49 @@ +/** Presentation contract for author-controlled registry content, independent of the UI runtime. */ +export const pluginOverviewPolicy = { + rawHtml: false, + elements: [ + "p", + "br", + "hr", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "ul", + "ol", + "li", + "strong", + "em", + "del", + "pre", + "code", + "a", + "img", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + ], + link: { rel: "noopener noreferrer nofollow", target: "_blank" }, +} as const; + +/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */ +export function pluginOverviewUrl(value: string | undefined): string | undefined { + // URL parsers discard controls and normalize backslashes; reject those ambiguous spellings. + // eslint-disable-next-line no-control-regex + if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value)) + return undefined; + try { + const url = new URL(value); + if (url.protocol !== "https:" || !url.hostname || url.username || url.password) + return undefined; + return value; + } catch { + return undefined; + } +} diff --git a/packages/protocol/src/plugin-registry.test.ts b/packages/protocol/src/plugin-registry.test.ts new file mode 100644 index 0000000000..5692c4c56e --- /dev/null +++ b/packages/protocol/src/plugin-registry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { pluginMediaKind } from "./plugin-registry.js"; + +describe("plugin media kind", () => { + it.each([ + ["https://cdn.example.com/a/preview.png", "image"], + ["https://cdn.example.com/a/preview.JPG", "image"], + ["https://cdn.example.com/a/preview.jpeg", "image"], + ["https://cdn.example.com/a/preview.webp", "image"], + ["https://cdn.example.com/a/preview.gif", "image"], + ["https://cdn.example.com/a/demo.mp4", "video"], + ["https://cdn.example.com/a/demo.WebM", "video"], + ["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"], + ["https://cdn.example.com/mp4/preview.png?format=mp4", "image"], + ] as const)("reads %s as %s from its path extension", (url, kind) => { + expect(pluginMediaKind(url)).toBe(kind); + }); +}); diff --git a/packages/protocol/src/plugin-registry.ts b/packages/protocol/src/plugin-registry.ts index 36c5354c88..90854962ba 100644 --- a/packages/protocol/src/plugin-registry.ts +++ b/packages/protocol/src/plugin-registry.ts @@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({ repository: z.object({ url: z.string().url(), commit: z.string().optional() }), artifact: PluginRegistryArtifactSchema, icon: z.string().url().optional(), - screenshots: z.array(z.string().url()), + /** Images and videos in display order; see `pluginMediaKind`. */ + media: z.array(z.string().url()), submittedAt: z.string(), reviewedAt: z.string(), updatedAt: z.string(), publishedAt: z.string(), installs: z.number().int().nonnegative().optional(), }); +const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]); + +/** Whether a registry media entry is a video or an image, from its path extension. */ +export function pluginMediaKind(url: string): "image" | "video" { + const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? ""; + return VIDEO_EXTENSIONS.has(extension) ? "video" : "image"; +} export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() }); export const PluginRegistryIndexSchema = z.object({ schemaVersion: z.literal(1), @@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({ z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }), ), plugins: z.array(PublishedPluginSchema), + /** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */ + featured: z.array(z.string()), generatedAt: z.string(), }); export type PluginRegistryIdentity = z.infer<typeof PluginRegistryIdentitySchema>; diff --git a/packages/protocol/src/plugin-source-reference.test.ts b/packages/protocol/src/plugin-source-reference.test.ts index b8ce6e7346..83f41ce3df 100644 --- a/packages/protocol/src/plugin-source-reference.test.ts +++ b/packages/protocol/src/plugin-source-reference.test.ts @@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest"; import { parsePluginSourceReference } from "./plugin-source-reference.js"; describe("plugin source references", () => { + it.each([ + ".", + "..", + "./owner/slug", + "../owner/slug", + "/owner/slug", + "~/owner/slug", + "~", + "C:\\", + "C:/", + "D:\\plugins\\repository", + "D:/plugins/repository", + "\\\\server\\share", + "\\\\server\\share\\plugins\\review", + ".\\plugins\\review", + "..\\plugins\\review", + ])("recognizes the explicit directory %s by shape", (source) => { + expect(parsePluginSourceReference(source)).toEqual({ + kind: "directory", + source, + pluginPath: undefined, + }); + expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({ + kind: "directory", + source, + pluginPath: "plugins/review", + }); + }); + it.each([ ["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"], ["npm:review@^1.2.0", "npm:review@^1.2.0", undefined], @@ -16,6 +45,7 @@ describe("plugin source references", () => { ["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"], ["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined], ["owner/repository", "owner/repository", undefined], + ["plugins/foo", "plugins/foo", undefined], ["owner/repository:plugins/review", "owner/repository", "plugins/review"], [ "https://example.test:8443/owner/repository.git", @@ -39,10 +69,8 @@ describe("plugin source references", () => { "file:///D:/plugins/repository", "plugins/review", ], - ["D:\\plugins\\repository", "D:\\plugins\\repository", undefined], - ["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"], ])("parses %s", (reference, source, pluginPath) => { - expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath }); + expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath }); }); }); diff --git a/packages/protocol/src/plugin-source-reference.ts b/packages/protocol/src/plugin-source-reference.ts index 229dc1df88..15171531f0 100644 --- a/packages/protocol/src/plugin-source-reference.ts +++ b/packages/protocol/src/plugin-source-reference.ts @@ -1,19 +1,27 @@ import { parsePluginRegistryReference } from "./plugin-registry.js"; import type { PluginInstallation } from "./messages.js"; export interface PluginSourceReference { + kind: "directory" | "managed"; source: string; pluginPath: string | undefined; } export function parsePluginSourceReference(reference: string): PluginSourceReference { - if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined }; const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0]; const source = prefix ? reference.slice(prefix.length) : reference; const parsed = splitPluginPath(source, { scp: prefix !== "npm:" }); - return { ...parsed, source: `${prefix ?? ""}${parsed.source}` }; + if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) { + return { kind: "directory", ...parsed }; + } + if (parsePluginRegistryReference(reference)) + return { kind: "managed", source: reference, pluginPath: undefined }; + return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` }; } -function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference { +function splitPluginPath( + reference: string, + options: { scp: boolean }, +): Pick<PluginSourceReference, "source" | "pluginPath"> { const separator = reference.lastIndexOf(":"); if (separator === -1) return { source: reference, pluginPath: undefined }; diff --git a/packages/protocol/src/terminal-snapshot.test.ts b/packages/protocol/src/terminal-snapshot.test.ts index 0262f0e45f..96d82a9205 100644 --- a/packages/protocol/src/terminal-snapshot.test.ts +++ b/packages/protocol/src/terminal-snapshot.test.ts @@ -47,3 +47,33 @@ describe("renderTerminalSnapshotToAnsi", () => { expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP"); }); }); + +describe("renderTerminalSnapshotToAnsi wide chars", () => { + it("skips wide-char continuation cells so the glyph keeps its two columns", () => { + // The daemon captures "통" as the glyph followed by an empty continuation + // cell. Emitting anything for that cell pushes the rest of the row right. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]], + cursor: { row: 0, col: 5 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!"); + }); + + it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => { + // Old daemons already collapsed the continuation to " ". The serializer + // cannot tell that apart from a real space, so it must not guess. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [cells("통 합 !")], + cursor: { row: 0, col: 6 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !"); + }); +}); diff --git a/packages/protocol/src/terminal-snapshot.ts b/packages/protocol/src/terminal-snapshot.ts index f978cd884b..1860067573 100644 --- a/packages/protocol/src/terminal-snapshot.ts +++ b/packages/protocol/src/terminal-snapshot.ts @@ -87,6 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string { for (let index = 0; index < length; index += 1) { const cell = row[index] ?? { char: " " }; + // An empty char marks a wide-char continuation cell. The glyph in the + // previous cell already advances the cursor two columns, so emitting + // anything here would shift the rest of the row one column right. + if (cell.char === "") { + continue; + } const nextStyle = getTerminalStyle(cell); if (!terminalStylesEqual(previousStyle, nextStyle)) { output.push(styleToAnsi(nextStyle)); diff --git a/packages/protocol/src/tool-call-display.ts b/packages/protocol/src/tool-call-display.ts index 6930fe187a..ab597b5acd 100644 --- a/packages/protocol/src/tool-call-display.ts +++ b/packages/protocol/src/tool-call-display.ts @@ -1,10 +1,11 @@ +import { buildAgentMessageDisplay } from "./agent-message-display.js"; import type { ToolCallTimelineItem } from "./agent-types.js"; import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js"; import { stripCwdPrefix } from "./path-utils.js"; export type ToolCallDisplayInput = Pick< ToolCallTimelineItem, - "name" | "status" | "error" | "metadata" | "detail" + "name" | "status" | "error" | "metadata" | "detail" | "agentMessage" > & { cwd?: string; }; @@ -151,6 +152,9 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay } export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel { + if (input.agentMessage) { + return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName }; + } const canonicalDisplay = buildCanonicalDetailDisplay(input); const unknownDetailOverride = buildUnknownDetailOverride(input); const displayName = diff --git a/packages/protocol/src/websocket-message-limit.ts b/packages/protocol/src/websocket-message-limit.ts new file mode 100644 index 0000000000..9e2bb48e21 --- /dev/null +++ b/packages/protocol/src/websocket-message-limit.ts @@ -0,0 +1,5 @@ +/** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */ +export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024; + +/** Largest encrypted frame the relay accepts. */ +export const MAX_RELAY_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024; diff --git a/packages/protocol/tests/fixtures/plugin-overview.json b/packages/protocol/tests/fixtures/plugin-overview.json new file mode 100644 index 0000000000..bbe8575ac8 --- /dev/null +++ b/packages/protocol/tests/fixtures/plugin-overview.json @@ -0,0 +1,1202 @@ +[ + { + "name": "script", + "markdown": "<script>alert(1)</script>", + "links": [], + "images": [] + }, + { + "name": "image event", + "markdown": "<img src=\"https://example.com/x\" onerror=\"alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "svg event", + "markdown": "<svg onload=\"alert(1)\"></svg>", + "links": [], + "images": [] + }, + { + "name": "iframe", + "markdown": "<iframe src=\"https://example.com\"></iframe>", + "links": [], + "images": [] + }, + { + "name": "HTML javascript link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "HTML block", + "markdown": "<div>\n<img src=x onerror=alert(1)>\n</div>", + "links": [], + "images": [] + }, + { + "name": "comment", + "markdown": "<!-- <img src=x onerror=alert(1)> -->", + "links": [], + "images": [] + }, + { + "name": "meta refresh", + "markdown": "<meta http-equiv=\"refresh\" content=\"0;url=https://example.com\">", + "links": [], + "images": [] + }, + { + "name": "base", + "markdown": "<base href=\"https://example.com\">", + "links": [], + "images": [] + }, + { + "name": "form", + "markdown": "<form action=\"https://example.com\"><input name=\"password\"></form>", + "links": [], + "images": [] + }, + { + "name": "style expression", + "markdown": "<p style=\"width:expression(alert(1));position:fixed\">click</p>", + "links": [], + "images": [] + }, + { + "name": "style block", + "markdown": "<style>body{display:none}</style>", + "links": [], + "images": [] + }, + { + "name": "closing script", + "markdown": "</script><script>alert(1)</script>", + "links": [], + "images": [] + }, + { + "name": "HTML entities", + "markdown": "<img src=x onerror=alert(1)>", + "links": [], + "images": [] + }, + { + "name": "nested HTML entities", + "markdown": "&lt;script&gt;alert(1)&lt;/script&gt;", + "links": [], + "images": [] + }, + { + "name": "javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript HTML link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "javascript HTML image", + "markdown": "<img src=\"javascript:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript link", + "markdown": "[click](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript image", + "markdown": "![picture](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference link", + "markdown": "[click][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML link", + "markdown": "<a href=\"JaVaScRiPt:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML image", + "markdown": "<img src=\"JaVaScRiPt:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "data HTML link", + "markdown": "[click](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML image", + "markdown": "![picture](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML reference link", + "markdown": "[click][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML reference image", + "markdown": "![picture][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML link", + "markdown": "<a href=\"data:text/html;base64,PHNjcmlwdD4=\">click</a>", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML image", + "markdown": "<img src=\"data:text/html;base64,PHNjcmlwdD4=\">", + "links": [], + "images": [] + }, + { + "name": "data image link", + "markdown": "[click](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image image", + "markdown": "![picture](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image reference link", + "markdown": "[click][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image reference image", + "markdown": "![picture][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image HTML link", + "markdown": "<a href=\"data:image/png;base64,AAAA\">click</a>", + "links": [], + "images": [] + }, + { + "name": "data image HTML image", + "markdown": "<img src=\"data:image/png;base64,AAAA\">", + "links": [], + "images": [] + }, + { + "name": "vbscript link", + "markdown": "[click](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript image", + "markdown": "![picture](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript reference link", + "markdown": "[click][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript reference image", + "markdown": "![picture][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML link", + "markdown": "<a href=\"vbscript:msgbox(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML image", + "markdown": "<img src=\"vbscript:msgbox(1)\">", + "links": [], + "images": [] + }, + { + "name": "http link", + "markdown": "[click](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http image", + "markdown": "![picture](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http reference link", + "markdown": "[click][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http reference image", + "markdown": "![picture][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http HTML link", + "markdown": "<a href=\"http://example.com/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "http HTML image", + "markdown": "<img src=\"http://example.com/path\">", + "links": [], + "images": [] + }, + { + "name": "ftp link", + "markdown": "[click](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp image", + "markdown": "![picture](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp reference link", + "markdown": "[click][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp reference image", + "markdown": "![picture][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp HTML link", + "markdown": "<a href=\"ftp://example.com/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "ftp HTML image", + "markdown": "<img src=\"ftp://example.com/path\">", + "links": [], + "images": [] + }, + { + "name": "mailto link", + "markdown": "[click](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto image", + "markdown": "![picture](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto reference link", + "markdown": "[click][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto reference image", + "markdown": "![picture][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto HTML link", + "markdown": "<a href=\"mailto:attacker@example.com\">click</a>", + "links": [], + "images": [] + }, + { + "name": "mailto HTML image", + "markdown": "<img src=\"mailto:attacker@example.com\">", + "links": [], + "images": [] + }, + { + "name": "protocol relative link", + "markdown": "[click](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative image", + "markdown": "![picture](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference link", + "markdown": "[click][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference image", + "markdown": "![picture][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML link", + "markdown": "<a href=\"//example.com/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML image", + "markdown": "<img src=\"//example.com/path\">", + "links": [], + "images": [] + }, + { + "name": "relative link", + "markdown": "[click](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative image", + "markdown": "![picture](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative reference link", + "markdown": "[click][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative HTML link", + "markdown": "<a href=\"./image.png\">click</a>", + "links": [], + "images": [] + }, + { + "name": "relative HTML image", + "markdown": "<img src=\"./image.png\">", + "links": [], + "images": [] + }, + { + "name": "parent relative link", + "markdown": "[click](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative image", + "markdown": "![picture](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative reference link", + "markdown": "[click][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML link", + "markdown": "<a href=\"../login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML image", + "markdown": "<img src=\"../login\">", + "links": [], + "images": [] + }, + { + "name": "root relative link", + "markdown": "[click](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative image", + "markdown": "![picture](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative reference link", + "markdown": "[click][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative reference image", + "markdown": "![picture][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative HTML link", + "markdown": "<a href=\"/login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "root relative HTML image", + "markdown": "<img src=\"/login\">", + "links": [], + "images": [] + }, + { + "name": "fragment link", + "markdown": "[click](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment image", + "markdown": "![picture](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment reference link", + "markdown": "[click][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment reference image", + "markdown": "![picture][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment HTML link", + "markdown": "<a href=\"#login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "fragment HTML image", + "markdown": "<img src=\"#login\">", + "links": [], + "images": [] + }, + { + "name": "query link", + "markdown": "[click](?login)", + "links": [], + "images": [] + }, + { + "name": "query image", + "markdown": "![picture](?login)", + "links": [], + "images": [] + }, + { + "name": "query reference link", + "markdown": "[click][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query reference image", + "markdown": "![picture][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query HTML link", + "markdown": "<a href=\"?login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "query HTML image", + "markdown": "<img src=\"?login\">", + "links": [], + "images": [] + }, + { + "name": "entity javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML image", + "markdown": "<img src=\"javascript:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "entity letter link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML image", + "markdown": "<img src=\"javascript:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "nested entity link", + "markdown": "[click](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity image", + "markdown": "![picture](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity reference link", + "markdown": "[click][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML link", + "markdown": "<a href=\"javascript&#58;alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML image", + "markdown": "<img src=\"javascript&#58;alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "percent encoded link", + "markdown": "[click](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded image", + "markdown": "![picture](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML link", + "markdown": "<a href=\"javascript%3Aalert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML image", + "markdown": "<img src=\"javascript%3Aalert(1)\">", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded link", + "markdown": "[click](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded image", + "markdown": "![picture](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML link", + "markdown": "<a href=\"javascript%253Aalert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML image", + "markdown": "<img src=\"javascript%253Aalert(1)\">", + "links": [], + "images": [] + }, + { + "name": "tab scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML link", + "markdown": "<a href=\"java script:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML image", + "markdown": "<img src=\"java script:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "newline scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML link", + "markdown": "<a href=\"java script:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML image", + "markdown": "<img src=\"java script:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials link", + "markdown": "[click](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials image", + "markdown": "![picture](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference link", + "markdown": "[click][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference image", + "markdown": "![picture][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML link", + "markdown": "<a href=\"https://paseo.sh@evil.example/login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML image", + "markdown": "<img src=\"https://paseo.sh@evil.example/login\">", + "links": [], + "images": [] + }, + { + "name": "backslash link", + "markdown": "[click](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash image", + "markdown": "![picture](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash reference link", + "markdown": "[click][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash reference image", + "markdown": "![picture][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash HTML link", + "markdown": "<a href=\"https:\\evil.example/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "backslash HTML image", + "markdown": "<img src=\"https:\\evil.example/path\">", + "links": [], + "images": [] + }, + { + "name": "missing authority link", + "markdown": "[click](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority image", + "markdown": "![picture](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority reference link", + "markdown": "[click][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority reference image", + "markdown": "![picture][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML link", + "markdown": "<a href=\"https:evil.example/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML image", + "markdown": "<img src=\"https:evil.example/path\">", + "links": [], + "images": [] + }, + { + "name": "javascript autolink", + "markdown": "<javascript:alert(1)>", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript autolink", + "markdown": "<JaVaScRiPt:alert(1)>", + "links": [], + "images": [] + }, + { + "name": "data HTML autolink", + "markdown": "<data:text/html;base64,PHNjcmlwdD4=>", + "links": [], + "images": [] + }, + { + "name": "data image autolink", + "markdown": "<data:image/png;base64,AAAA>", + "links": [], + "images": [] + }, + { + "name": "vbscript autolink", + "markdown": "<vbscript:msgbox(1)>", + "links": [], + "images": [] + }, + { + "name": "http autolink", + "markdown": "<http://example.com/path>", + "links": [], + "images": [] + }, + { + "name": "ftp autolink", + "markdown": "<ftp://example.com/path>", + "links": [], + "images": [] + }, + { + "name": "mailto autolink", + "markdown": "<mailto:attacker@example.com>", + "links": [], + "images": [] + }, + { + "name": "HTTPS link", + "markdown": "[click](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "HTTPS image", + "markdown": "![picture](https://example.com/image.png)", + "links": [], + "images": ["https://example.com/image.png"] + }, + { + "name": "HTTPS autolink", + "markdown": "<https://example.com/path>", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTPS", + "markdown": "https://example.com/path", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTP", + "markdown": "http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "deceptive RTL text", + "markdown": "[paseo.sh‮evil](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "title injection", + "markdown": "[click](https://example.com/path \"\\\" onmouseover=alert(1)\")", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "table alignment", + "markdown": "| A |\n| :-: |\n| safe |", + "links": [], + "images": [] + }, + { + "name": "heading", + "markdown": "## Heading", + "links": [], + "images": [] + }, + { + "name": "IRC link", + "markdown": "[click](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC image", + "markdown": "![picture](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC autolink", + "markdown": "<irc://example.com/channel>", + "links": [], + "images": [] + }, + { + "name": "IRCS link", + "markdown": "[click](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS image", + "markdown": "![picture](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS autolink", + "markdown": "<ircs://example.com/channel>", + "links": [], + "images": [] + }, + { + "name": "XMPP link", + "markdown": "[click](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP image", + "markdown": "![picture](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP autolink", + "markdown": "<xmpp:attacker@example.com>", + "links": [], + "images": [] + }, + { + "name": "tel link", + "markdown": "[click](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel image", + "markdown": "![picture](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel autolink", + "markdown": "<tel:12345>", + "links": [], + "images": [] + }, + { + "name": "file link", + "markdown": "[click](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file image", + "markdown": "![picture](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file autolink", + "markdown": "<file:///etc/passwd>", + "links": [], + "images": [] + }, + { + "name": "blob link", + "markdown": "[click](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob image", + "markdown": "![picture](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob autolink", + "markdown": "<blob:https://example.com/id>", + "links": [], + "images": [] + }, + { + "name": "named entity link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity autolink", + "markdown": "<javascript:alert(1)>", + "links": [], + "images": [] + }, + { + "name": "double encoded entity link", + "markdown": "[click](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity image", + "markdown": "![picture](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity autolink", + "markdown": "<javascript&colon;alert(1)>", + "links": [], + "images": [] + } +] diff --git a/packages/relay/package.json b/packages/relay/package.json index 8e31759e57..d53a1a870e 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.11.0-beta.3", + "version": "0.11.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/.env.example b/packages/server/.env.example index ba031a16b5..55a2bded69 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -11,9 +11,10 @@ OPENAI_TTS_BASE_URL= # TTS Configuration (optional - defaults shown) TTS_VOICE=alloy -# Available voices: alloy, echo, fable, onyx, nova, shimmer +# Any voice the server knows: OpenAI ships alloy, echo, fable, onyx, nova, shimmer TTS_MODEL=tts-1 -# Available models: tts-1 (faster), tts-1-hd (higher quality) +# OpenAI ships tts-1 (faster) and tts-1-hd (higher quality); self-hosted +# servers accept whatever model id they serve. Case sensitive. # Server Configuration # Location for runtime state (agents.json, etc.). Defaults to ~/.paseo diff --git a/packages/server/package.json b/packages/server/package.json index 8218960c6a..beffa3b4ce 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.11.0-beta.3", + "version": "0.11.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/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/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.11.0-beta.3", - "@getpaseo/highlight": "0.11.0-beta.3", - "@getpaseo/plugin": "0.11.0-beta.3", - "@getpaseo/protocol": "0.11.0-beta.3", - "@getpaseo/relay": "0.11.0-beta.3", + "@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 bdee5f6f7d..6f9754994a 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -27,6 +27,9 @@ async function runSupervisorFixture(options: { fileSizeLimitBlocks?: number; /** Occupy the log path with a directory so the supervisor cannot open daemon.log. */ 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; @@ -82,10 +85,17 @@ 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"], }); + if (options.closeOutput) { + child.stdout.destroy(); + child.stderr.destroy(); + } + let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); @@ -116,10 +126,19 @@ async function runSupervisorFixture(options: { }); }); - const log = await readFile(logPath, "utf8"); + const log = await readLogIfWritten(logPath); return { code, signal, elapsedMs: Date.now() - startedAt, log, stdout, stderr }; } +async function readLogIfWritten(logPath: string): Promise<string> { + try { + return await readFile(logPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } +} + describe("supervisor durable logging", () => { test("resolves rotation defaults", () => { const paseoHome = path.join(path.sep, "tmp", "paseo-home"); @@ -189,6 +208,37 @@ describe("supervisor durable logging", () => { expect(result.stderr).toContain('"worker-json-stderr"'); }); + 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); + }); + process.stdout.write("first stdout line\\n"); + process.stderr.write("first stderr line\\n"); + setTimeout(() => { + process.stdout.write("later stdout line\\n"); + process.send?.({ type: "paseo:shutdown", reason: "closed_output_probe" }); + }, 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"'); + }, + ); + test("preserves raw non-JSON stdout and stderr lines", async () => { const result = await runSupervisorFixture({ workerSource: ` diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 1f8c1374c6..ec56822619 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -166,6 +166,15 @@ function createDurableLog( }; } +// The program that started the daemon can close its end of stdout or stderr, as +// `paseo daemon run | head -1` does. Drop the output nobody reads and keep supervising +// the worker; daemon.log still records it. +function ignoreClosedOutput(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + export function runSupervisor(options: SupervisorOptions): SupervisorController { const restartOnCrash = options.restartOnCrash ?? false; const workerArgs = options.workerArgs ?? process.argv.slice(2); @@ -482,6 +491,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController process.on("SIGINT", () => forwardSignal("SIGINT")); process.on("SIGTERM", () => forwardSignal("SIGTERM")); + process.stdout.on("error", ignoreClosedOutput); + process.stderr.on("error", ignoreClosedOutput); process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); writeLifecycleLog(options.startupMessage); diff --git a/packages/server/src/executable-resolution/executable-resolution.test.ts b/packages/server/src/executable-resolution/executable-resolution.test.ts index 4434089e98..2ee0bd90df 100644 --- a/packages/server/src/executable-resolution/executable-resolution.test.ts +++ b/packages/server/src/executable-resolution/executable-resolution.test.ts @@ -3,12 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { - executableExists, - findExecutable, - quoteWindowsArgument, - quoteWindowsCommand, -} from "./executable-resolution.js"; +import { executableExists, findExecutable } from "./executable-resolution.js"; import { windowsExecutableResolution } from "./windows.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -217,118 +212,3 @@ describe("executableExists", () => { expect(executableExists("/missing/codex", () => false)).toBeNull(); }); }); - -describe("quoteWindowsCommand", () => { - const originalPlatform = process.platform; - - function setPlatform(value: string) { - Object.defineProperty(process, "platform", { value, writable: true }); - } - - afterEach(() => { - setPlatform(originalPlatform); - }); - - test("quotes a Windows path with spaces", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( - '"C:\\Program Files\\Anthropic\\claude.exe"', - ); - }); - - test("does not double-quote an already-quoted path", () => { - setPlatform("win32"); - expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( - '"C:\\Program Files\\Anthropic\\claude.exe"', - ); - }); - - test("returns the command unchanged when there are no spaces", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); - }); - - test("escapes ampersands", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); - }); - - test("escapes pipes", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); - }); - - test("does not double percent signs", () => { - setPlatform("win32"); - // cmd.exe only collapses %% → % inside batch files; on the command line - // it stays literal, which corrupts git --format atoms etc. - expect(quoteWindowsCommand("100%")).toBe("100%"); - }); - - test("preserves git --format atoms verbatim", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe( - "--format=%^(refname^)%09%^(committerdate:unix^)", - ); - }); - - test("escapes carets", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); - }); - - test("escapes multiple metacharacters", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe( - "build^&^(test^|deploy^)^!^<output^>", - ); - }); - - test("quotes commands with spaces after escaping metacharacters", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( - '"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"', - ); - }); - - test("returns the command unchanged on non-Windows platforms", () => { - setPlatform("darwin"); - expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); - }); -}); - -describe("quoteWindowsArgument", () => { - const originalPlatform = process.platform; - - function setPlatform(value: string) { - Object.defineProperty(process, "platform", { value, writable: true }); - } - - afterEach(() => { - setPlatform(originalPlatform); - }); - - test("quotes a Windows argument with spaces", () => { - setPlatform("win32"); - expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( - '"C:\\Program Files\\Anthropic\\cli.js"', - ); - }); - - test("does not double-quote an already-quoted argument", () => { - setPlatform("win32"); - expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( - '"C:\\Program Files\\Anthropic\\cli.js"', - ); - }); - - test("returns the argument unchanged when there are no spaces", () => { - setPlatform("win32"); - expect(quoteWindowsArgument("--version")).toBe("--version"); - }); - - test("returns the argument unchanged on non-Windows platforms", () => { - setPlatform("darwin"); - expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); - }); -}); diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index 75f230409f..97bb8af3a3 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -1,12 +1,10 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; +import { delimiter, resolve } from "node:path"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { execCommand } from "../utils/spawn.js"; -import { isWindowsCommandScript } from "../utils/windows-command.js"; import { windowsExecutableResolution } from "./windows.js"; -export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js"; - type Which = ( command: string, options: { all: true; path?: string; pathExt?: string }, @@ -82,7 +80,6 @@ export async function probeExecutable( timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 64 * 1024, - shell: isWindowsCommandScript(executablePath), }); return true; } catch (error) { @@ -124,6 +121,29 @@ export function executableExists( return exists(executablePath) ? executablePath : null; } +// PATH lookup that never runs the candidate, unlike findExecutable which probes +// each one with `--version`. A terminal spawn path cannot afford that probe: it +// would execute the profile command once before the terminal even opens. +// `path` overrides the PATH to search, so a caller can resolve against the +// environment its child process will actually get rather than the daemon's own. +export async function findExecutableWithoutProbing( + name: string, + options: { path?: string; cwd: string }, +): Promise<string | null> { + if (!name) { + return null; + } + const path = + options.path === undefined + ? undefined + : options.path + .split(delimiter) + .map((entry) => resolve(options.cwd, entry)) + .join(delimiter); + const candidates = await enumerateCandidatesViaLibrary(name, { PATH: path }); + return candidates[0] ?? null; +} + export interface ExecutableResolutionOptions { probeTimeoutMs?: number; env?: ProcessEnvRecord; diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 15ad9db90e..58a8a8b4f1 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -39,6 +39,45 @@ function row(seq: number, item: AgentTimelineItem): AgentTimelineRow { } describe("curateAgentActivity", () => { + it("keeps reasoning before the assistant text that followed it", () => { + const timeline: AgentTimelineItem[] = [ + { type: "reasoning", text: "Check the commands first." }, + { type: "assistant_message", text: "I will list the commands." }, + toolCallItem({ + callId: "shell-order", + name: "Bash", + detail: { + type: "shell", + command: "ls packages/cli/src/commands/agent/", + output: "", + exitCode: 0, + }, + }), + ]; + + expect(curateAgentActivity(timeline)).toBe( + "[Thought] Check the commands first.\nI will list the commands.\n[Shell] ls packages/cli/src/commands/agent/", + ); + }); + + it("preserves alternating text kinds, labels, and fragment concatenation", () => { + const timeline: AgentTimelineItem[] = [ + { type: "assistant_message", text: "First message." }, + { type: "reasoning", text: "First " }, + { type: "reasoning", text: "thought." }, + { type: "assistant_message", text: "Second " }, + { type: "assistant_message", text: "message." }, + { type: "reasoning", text: "Second thought." }, + ]; + + expect(curateAgentActivity(timeline, { labelAssistantMessages: true })).toBe( + "[Assistant] First message.\n[Thought] First thought.\n[Assistant] Second message.\n[Thought] Second thought.", + ); + expect(curateAgentActivity(timeline, { maxItems: 2 })).toBe( + "Second message.\n[Thought] Second thought.", + ); + }); + it("renders user/assistant/reasoning entries", () => { const timeline: AgentTimelineItem[] = [ { type: "user_message", text: "Hello" }, @@ -438,3 +477,49 @@ second line'`, ).toThrow("Selected assistant message is no longer available."); }); }); + +it("bounds fork history to 100,000 characters while preserving the newest messages", () => { + const result = buildAgentForkContextAttachment({ + rows: [ + row(1, { type: "user_message", text: "old-message " + "x".repeat(1_100_000) }), + row(2, { type: "assistant_message", messageId: "checkpoint", text: "Latest answer." }), + row(3, { type: "user_message", text: "Beyond the selected checkpoint." }), + ], + boundaryMessageId: "checkpoint", + }); + expect(result.attachment.text.length).toBeLessThanOrEqual(100_000); + expect(result.attachment.text).toContain("[Earlier chat history omitted]"); + expect(result.attachment.text).toContain("[Assistant] Latest answer."); + expect(result.attachment.text).not.toContain("old-message"); + expect(result.attachment.text).not.toContain("Beyond the selected checkpoint."); + expect(result.attachment.text).toMatch(/^<chat-history-summary>\n/); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); +}); + +it("bounds one oversized fork message and metadata without losing its beginning and end", () => { + const result = buildAgentForkContextAttachment({ + rows: [ + row(1, { + type: "assistant_message", + text: "Beginning. " + "x".repeat(1_100_000) + " The end.", + }), + ], + agentTitle: "T".repeat(200_000), + cwd: "/" + "p".repeat(200_000), + }); + expect(result.attachment.text.length).toBe(100_000); + expect(result.attachment.text).toContain("[Assistant] Beginning."); + expect(result.attachment.text).toContain("[Message truncated]"); + expect(result.attachment.text).toContain("The end."); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); +}); + +it("preserves every message in fork history below the character budget", () => { + const text = "Complete conversation " + "x".repeat(90_000); + const result = buildAgentForkContextAttachment({ + rows: [row(1, { type: "user_message", text })], + }); + expect(result.attachment.text).toBe( + `<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] ${text}\n</chat-history-summary>`, + ); +}); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 3bcab550e7..efd0a8e48d 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -8,6 +8,9 @@ import { projectTimelineRows } from "./timeline-projection.js"; const DEFAULT_MAX_ITEMS = 0; const MAX_TOOL_INPUT_CHARS = 400; const MAX_TOOL_SUMMARY_CHARS = 200; +const MAX_FORK_CONTEXT_CHARS = 100_000; +const MAX_FORK_METADATA_CHARS = 1_000; +const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n"; interface ActivityCuratorOptions { maxItems?: number; @@ -22,36 +25,20 @@ interface ActivityEntry { type TextAgentAttachment = Extract<AgentAttachment, { type: "text" }>; -function appendText(buffer: string, text: string): string { - const normalized = text.trim(); - if (!normalized) { - return buffer; - } - if (!buffer) { - return normalized; - } - return `${buffer}\n${normalized}`; -} - function activityEntry(text: string): ActivityEntry { return { text }; } -function flushBuffers( +function appendTextEntry( entries: ActivityEntry[], - buffers: { message: string; thought: string }, + item: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>, options?: ActivityCuratorOptions, ) { - if (buffers.message.trim()) { - const text = buffers.message.trim(); - entries.push(activityEntry(options?.labelAssistantMessages ? `[Assistant] ${text}` : text)); - } - if (buffers.thought.trim()) { - const text = buffers.thought.trim(); - entries.push(activityEntry(`[Thought] ${text}`)); - } - buffers.message = ""; - buffers.thought = ""; + const text = item.text.trim(); + if (!text) return; + let prefix = options?.labelAssistantMessages ? "[Assistant] " : ""; + if (item.type === "reasoning") prefix = "[Thought] "; + entries.push(activityEntry(`${prefix}${text}`)); } function formatToolInputJson(input: unknown): string | null { @@ -144,7 +131,6 @@ function curateProjectedActivityEntries( const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items; const entries: ActivityEntry[] = []; - const buffers = { message: "", thought: "" }; for (const item of recentItems) { if (!shouldIncludeItem(item, options)) { @@ -153,17 +139,13 @@ function curateProjectedActivityEntries( switch (item.type) { case "user_message": - flushBuffers(entries, buffers, options); entries.push(activityEntry(`[User] ${item.text.trim()}`)); break; case "assistant_message": - buffers.message = appendText(buffers.message, item.text); - break; case "reasoning": - buffers.thought = appendText(buffers.thought, item.text); + appendTextEntry(entries, item, options); break; case "tool_call": { - flushBuffers(entries, buffers, options); entries.push(formatToolCallEntry(item, options)); if (item.detail.type === "sub_agent" && item.detail.log.trim()) { entries.push(activityEntry(item.detail.log.trim())); @@ -171,7 +153,6 @@ function curateProjectedActivityEntries( break; } case "todo": - flushBuffers(entries, buffers, options); entries.push(activityEntry("[Tasks]")); for (const entry of item.items) { const checkbox = entry.completed ? "[x]" : "[ ]"; @@ -180,18 +161,14 @@ function curateProjectedActivityEntries( } break; case "error": - flushBuffers(entries, buffers, options); entries.push(activityEntry(`[Error] ${item.message}`)); break; case "compaction": - flushBuffers(entries, buffers, options); entries.push(activityEntry("[Compacted]")); break; } } - flushBuffers(entries, buffers, options); - return entries; } @@ -276,7 +253,7 @@ function selectForkContextRows(input: { function trimContextMetadata(value: string | null | undefined): string | null { const trimmed = value?.trim(); - return trimmed ? trimmed : null; + return trimmed ? trimmed.slice(0, MAX_FORK_METADATA_CHARS) : null; } function buildForkContextText(input: { @@ -296,6 +273,30 @@ function buildForkContextText(input: { return `<chat-history-summary>\n${header.join("\n")}\n\n${input.body}\n</chat-history-summary>`; } +function fitForkContextEntries(entries: ActivityEntry[], budget: number): string { + const length = entries.reduce((total, entry) => total + entry.text.length + 1, -1); + if (length <= budget) return entries.map((entry) => entry.text).join("\n"); + + const selected: string[] = []; + let remaining = budget - FORK_HISTORY_OMITTED.length; + for (let index = entries.length - 1; index >= 0; index--) { + const text = entries[index]!.text; + const size = text.length + (selected.length > 0 ? 1 : 0); + if (size > remaining) { + if (selected.length === 0) { + const marker = "\n[Message truncated]\n"; + const available = remaining - marker.length; + const head = Math.ceil(available / 2); + selected.push(text.slice(0, head) + marker + text.slice(-(available - head))); + } + break; + } + selected.push(text); + remaining -= size; + } + return FORK_HISTORY_OMITTED + selected.toReversed().join("\n"); +} + export function buildAgentForkContextAttachment(input: { rows: readonly AgentTimelineRow[]; cursorBoundary?: ForkCursorBoundary | null; @@ -319,10 +320,10 @@ export function buildAgentForkContextAttachment(input: { includeKinds: ["user_message", "assistant_message", "tool_call"], includeExternalToolInput: false, }); + const metadata = { agentTitle: input.agentTitle, cwd: input.cwd }; + const budget = MAX_FORK_CONTEXT_CHARS - buildForkContextText({ body: "", ...metadata }).length; const body = - entries.length > 0 - ? entries.map((entry) => entry.text).join("\n") - : "No chat history to display."; + entries.length > 0 ? fitForkContextEntries(entries, budget) : "No chat history to display."; return { attachment: { type: "text", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 6396fc589f..763659e9f4 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,3 +1,8 @@ +import { + prepareAgentMessage, + formatAgentMessage, + formatSystemNotificationPrompt, +} from "./agent-messages/index.js"; import { expect, test, vi } from "vitest"; import { spawn } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -18,7 +23,7 @@ import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; import { toAgentPayload } from "./agent-projections.js"; import { projectTimelineRows } from "./timeline-projection.js"; import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; -import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js"; +import { startAgentRun } from "./agent-prompt.js"; import { StaleProviderSessionError } from "./stale-provider-session-error.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js"; import type { StoredAgentRecord } from "./agent-storage.js"; @@ -3949,12 +3954,23 @@ test("a prompt after provider replacement reopens the stale session", async () = clients: { [provider]: replacement }, }); - const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger); + const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger, { + runOptions: { clientMessageId: "stale-session-retry" }, + }); expect(dispatch.disposition).toBe("turn_started"); await replacement.waitForRetryStart(); const result = await manager.waitForAgentEvent(created.id); expect(result.status).toBe("idle"); expect(replacement.resumeOverrides).toHaveLength(1); + const timeline = manager.getTimeline(created.id); + expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([ + { text: "continue after reload", clientMessageId: "stale-session-retry" }, + ]); + expect( + timeline.some( + (item) => item.type === "assistant_message" && item.text.includes("[System Error]"), + ), + ).toBe(false); } finally { await manager.closeAgent(created.id).catch(() => undefined); await manager.flush().catch(() => undefined); @@ -7694,6 +7710,81 @@ test("listAgents excludes internal agents", async () => { const agents = manager.listAgents(); expect(agents).toHaveLength(1); expect(agents[0]?.config.title).toBe("Normal Agent"); + + // Opting in lists both, and the internal one is marked. + const everyAgent = manager.listAgents({ includeInternal: true }); + expect(everyAgent.map((agent) => [agent.config.title, agent.internal])).toEqual([ + ["Normal Agent", false], + ["Internal Agent", true], + ]); +}); + +test("workspace ownership does not turn an ordinary agent into a disposable helper", async () => { + const agentId = "00000000-0000-4000-8000-000000000115"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const beforeHookCalls: string[] = []; + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + idFactory: () => agentId, + pluginLifecycle: { + emit: () => {}, + before: async (name, request) => { + beforeHookCalls.push(name); + return request; + }, + }, + }); + + await manager.createAgent({ provider: "codex", cwd: workdir, title: "Helper" }, undefined, { + workspaceId: "wks_internal", + }); + + expect(manager.getAgent(agentId)?.internal).toBe(false); + expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]); + expect(await storage.get(agentId)).not.toBeNull(); + // Workspace ownership preserves ordinary agent lifecycle hooks. + expect(beforeHookCalls).toContain("agent.create"); +}); + +test("global subscribers receive internal agent events only when they opt in", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000116"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + idFactory: () => internalAgentId, + }); + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Agent", internal: true }, + undefined, + { workspaceId: undefined }, + ); + + const hidden: string[] = []; + const optedIn: string[] = []; + const unsubscribeHidden = manager.subscribe((event) => { + if (event.type === "agent_state") hidden.push(event.agent.id); + }); + const unsubscribeOptedIn = manager.subscribe( + (event) => { + if (event.type === "agent_state") optedIn.push(event.agent.id); + }, + { includeInternal: true }, + ); + + expect(hidden).toEqual([]); + expect(optedIn).toEqual([internalAgentId]); + + await manager.archiveAgent(internalAgentId); + expect(hidden).toEqual([]); + expect(optedIn.length).toBeGreaterThan(1); + unsubscribeHidden(); + unsubscribeOptedIn(); }); test("getAgent returns internal agents by ID", async () => { @@ -7726,6 +7817,124 @@ test("getAgent returns internal agents by ID", async () => { expect(agent?.internal).toBe(true); }); +test("archiveAgent closes an internal agent without writing a record", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000108"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => internalAgentId, + }); + + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }, + undefined, + { workspaceId: undefined }, + ); + + const { archivedAt } = await manager.archiveAgent(internalAgentId); + + expect(Number.isNaN(Date.parse(archivedAt))).toBe(false); + expect(manager.getAgent(internalAgentId)).toBeNull(); + expect(await storage.get(internalAgentId)).toBeNull(); + expect(await storage.list()).toEqual([]); + expect(manager.getRetiredInternalAgent(internalAgentId)).toEqual({ + record: expect.objectContaining({ + id: internalAgentId, + title: "Internal Agent", + internal: true, + archivedAt, + lastStatus: "idle", + }), + lastMessage: null, + }); + expect(manager.getRetiredInternalAgent("00000000-0000-4000-8000-000000000999")).toBeNull(); +}); + +test("archiveAgent on an internal parent still archives its attached children", async () => { + const parentAgentId = "00000000-0000-4000-8000-000000000109"; + const childAgentId = "00000000-0000-4000-8000-000000000110"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const ids = [parentAgentId, childAgentId]; + let agentCounter = 0; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => ids[agentCounter++] ?? randomUUID(), + }); + + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Parent", internal: true }, + undefined, + { workspaceId: undefined }, + ); + await manager.createAgent({ provider: "codex", cwd: workdir, title: "Child" }, undefined, { + workspaceId: undefined, + labels: { "paseo.parent-agent-id": parentAgentId }, + }); + + await manager.archiveAgent(parentAgentId); + + expect(manager.getAgent(parentAgentId)).toBeNull(); + expect(manager.getAgent(childAgentId)).toBeNull(); + const child = await storage.get(childAgentId); + expect(child?.archivedAt).toEqual(expect.any(String)); + expect(await storage.get(parentAgentId)).toBeNull(); +}); + +test("archiveAgent on a parent archives its live internal children", async () => { + const parentAgentId = "00000000-0000-4000-8000-000000000111"; + const childAgentId = "00000000-0000-4000-8000-000000000112"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const ids = [parentAgentId, childAgentId]; + let agentCounter = 0; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => ids[agentCounter++] ?? randomUUID(), + }); + + await manager.createAgent({ provider: "codex", cwd: workdir, title: "Parent" }, undefined, { + workspaceId: undefined, + }); + // An internal child is never in storage, so only the live scan can reach it. + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Child", internal: true }, + undefined, + { workspaceId: undefined, labels: { "paseo.parent-agent-id": parentAgentId } }, + ); + expect(manager.getAgent(childAgentId)).not.toBeNull(); + + await manager.archiveAgent(parentAgentId); + + expect(manager.getAgent(parentAgentId)).toBeNull(); + expect(manager.getAgent(childAgentId)).toBeNull(); + expect(manager.getRetiredInternalAgent(childAgentId)?.record.archivedAt).toEqual( + expect.any(String), + ); + expect(await storage.get(childAgentId)).toBeNull(); +}); + test("subscribe does not emit state events for internal agents to global subscribers", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -8136,9 +8345,20 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist { workspaceId: undefined }, ); - await expect(manager.runAgent(agent.id, "fail before turn id")).rejects.toThrow( - "Invalid request: missing field `text`", - ); + await expect( + manager.runAgent(agent.id, "fail before turn id", { + clientMessageId: "rejected-prompt", + }), + ).rejects.toThrow("Invalid request: missing field `text`"); + expect(manager.getTimeline(agent.id)).toEqual([ + { + type: "user_message", + text: "fail before turn id", + clientMessageId: "rejected-prompt", + messageId: "rejected-prompt", + }, + { type: "assistant_message", text: "[System Error] Invalid request: missing field `text`" }, + ]); await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual( expect.objectContaining({ @@ -11467,3 +11687,173 @@ test("usage session is a pure read of the live adapter and disappears on close", } expect(manager.usageSession(agent.id)).toBeNull(); }); + +test.each(["live", "history"])( + "agent messages become notification tools during %s ingestion", + async (path) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-message-provenance-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const item: AgentTimelineItem = { + type: "user_message", + text: '<paseo-system version="1" kind="agent-message" source-agent-id="remote::sender" message-id="delivery-1">\nReview <changes> & report back.\n</paseo-system>', + messageId: "provider-message", + }; + const codex = fakeCodexEmitting( + path === "live" ? { turnItems: [item] } : { historyItems: [item] }, + ); + const manager = new AgentManager({ clients: { codex }, registry: storage, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + if (path === "live") await manager.runAgent(agent.id, { text: "start" }); + else await manager.hydrateTimelineFromProvider(agent.id, { force: true }); + expect(manager.getTimeline(agent.id)).toContainEqual( + expect.objectContaining({ + type: "tool_call", + callId: "paseo-agent-message:delivery-1", + agentMessage: { + event: "message", + sender: { id: "remote::sender" }, + text: "Review <changes> & report back.", + }, + name: "agent_message", + status: "completed", + detail: { + type: "plain_text", + icon: "bot", + text: "Review <changes> & report back.", + }, + }), + ); + expect(manager.getTimeline(agent.id).filter((entry) => entry.type === "user_message")).toEqual( + [], + ); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + }, +); + +test.each([false, true])( + "accepted agent prompt appears once with provider echo=%s", + async (echo) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-message-acceptance-")); + const delivery = prepareAgentMessage( + "Please review", + { kind: "agent-message", agentId: "remote::sender" }, + "accepted-delivery", + ); + const codex = fakeCodexEmitting({ + turnItems: echo + ? [ + { + type: "user_message", + text: String(delivery.prompt), + clientMessageId: delivery.messageId, + messageId: "native-echo", + }, + ] + : [], + }); + const manager = new AgentManager({ clients: { codex }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + for await (const _event of manager.streamAgent(agent.id, delivery.prompt, { + clientMessageId: delivery.messageId, + })) { + } + expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([ + expect.objectContaining({ + callId: "paseo-agent-message:accepted-delivery", + status: "completed", + }), + ]); + expect(manager.getTimeline(agent.id).filter((item) => item.type === "user_message")).toEqual( + [], + ); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); + +test("a rejected agent prompt never becomes a delivered notification", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-message-rejected-")); + const delivery = prepareAgentMessage( + "Please review", + { kind: "agent-message", agentId: "sender" }, + "rejected", + ); + class RejectingSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + throw new Error("provider rejected delivery"); + } + } + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig) { + return new RejectingSession(config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + const run = manager.streamAgent(agent.id, delivery.prompt, { + clientMessageId: delivery.messageId, + }); + await expect(run.next()).rejects.toThrow("provider rejected delivery"); + expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([]); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test.each([false, true])( + "human envelope examples remain human after echo with correlation=%s", + async (correlated) => { + const workdir = mkdtempSync(join(tmpdir(), "human-envelope-example-")); + const example = formatAgentMessage({ + id: "example", + source: { kind: "agent-message", agentId: "sender" }, + text: "hello", + }); + const delivery = prepareAgentMessage(example, undefined, "human-submission"); + const codex = fakeCodexEmitting({ + turnItems: [ + { + type: "user_message", + text: String(delivery.prompt), + messageId: "provider-echo", + ...(correlated ? { clientMessageId: "human-submission" } : {}), + }, + ], + }); + const manager = new AgentManager({ clients: { codex }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + for await (const _event of manager.streamAgent(agent.id, delivery.prompt, { + clientMessageId: delivery.messageId, + })) { + } + expect(manager.getTimeline(agent.id)).toEqual([ + { + type: "user_message", + text: example, + clientMessageId: "human-submission", + messageId: "human-submission", + }, + ]); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeInstanceOf(Date); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 9e809a457f..e09d62f6a9 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -55,6 +55,7 @@ import { type ListImportableSessionsOptions, } from "./agent-sdk-types.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; +import { toStoredAgentRecord } from "./agent-projections.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; import type { AgentOwner } from "./agent-owner.js"; import { @@ -78,7 +79,7 @@ import { type PendingForegroundRun, } from "./agent-run-state.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; -import { isSystemInjectedEnvelope } from "./agent-prompt.js"; +import { projectAgentMessage } from "./agent-messages/index.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; @@ -243,6 +244,8 @@ export type AgentSubscriber = (event: AgentManagerEvent) => void; export interface SubscribeOptions { agentId?: string; replayState?: boolean; + /** Receive events for internal agents too. Off by default for global subscribers. */ + includeInternal?: boolean; } interface HydrateTimelineOptions { @@ -316,6 +319,7 @@ export interface CreateAgentOptions { export interface AgentManagerOptions { pluginLifecycle?: PluginLifecycle; + /** An internal workspace makes every agent created inside it internal. */ clients?: ProviderClientMap; providerDefinitions?: ProviderEnabledMap; idFactory?: () => string; @@ -568,6 +572,7 @@ function attachPersistenceCwd( interface SubscriptionRecord { callback: AgentSubscriber; agentId: string | null; + includeInternal: boolean; } interface SteerEventBarrier { @@ -652,13 +657,12 @@ function buildExplicitTimelineSeedForRegister( function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] { const rows: AgentTimelineRow[] = []; for (const entry of entries) { - if (entry.item.type === "user_message" && isSystemInjectedEnvelope(entry.item.text)) { - continue; - } + const item = projectAgentMessage(entry.item); + if (!item) continue; rows.push({ seq: rows.length + 1, timestamp: entry.timestamp ?? new Date().toISOString(), - item: limitAgentTimelineItemContent(entry.item), + item: limitAgentTimelineItemContent(item), }); } return rows; @@ -694,7 +698,7 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str } function shouldDetachFromArchivedParent( - parent: StoredAgentRecord, + parent: Pick<StoredAgentRecord, "workspaceId">, child: StoredAgentRecord, ): boolean { const isCrossWorkspace = @@ -714,8 +718,25 @@ function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatc return patch; } +/** + * How long an archived internal agent stays readable by id. Storage plays this + * role for public agents; internal agents never reach it, and a one-shot helper + * can finish and auto-archive before its creator's `waitForFinish` arrives. + */ +const RETIRED_INTERNAL_AGENT_TTL_MS = 10 * 60 * 1000; +const RETIRED_INTERNAL_AGENT_LIMIT = 500; + +export interface RetiredInternalAgent { + record: ArchivedStoredAgentRecord; + lastMessage: string | null; +} + export class AgentManager { private readonly pluginLifecycle: PluginLifecycle | undefined; + private readonly retiredInternalAgents = new Map< + string, + RetiredInternalAgent & { expiresAt: number } + >(); private readonly clients = new Map<AgentProvider, AgentClient>(); private readonly providerEnabled = new Map<AgentProvider, boolean>(); private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>(); @@ -949,6 +970,7 @@ export class AgentManager { const record: SubscriptionRecord = { callback, agentId: targetAgentId, + includeInternal: options?.includeInternal === true, }; this.subscribers.add(record); @@ -962,9 +984,9 @@ export class AgentManager { }); } } else { - // For global subscribers, skip internal agents during replay + // Global subscribers skip internal agents unless they opted in. for (const agent of this.agents.values()) { - if (agent.internal) { + if (agent.internal && !record.includeInternal) { continue; } callback({ @@ -984,12 +1006,26 @@ export class AgentManager { return this.subscribers.size; } - listAgents(): ManagedAgent[] { + listAgents(options?: { includeInternal?: boolean }): ManagedAgent[] { return Array.from(this.agents.values()) - .filter((agent) => !agent.internal) + .filter((agent) => options?.includeInternal === true || !agent.internal) .map((agent) => Object.assign({}, agent)); } + /** Archived internal agents still readable by id; see getRetiredInternalAgent. */ + listRetiredInternalAgents(): RetiredInternalAgent[] { + const now = Date.now(); + const retired: RetiredInternalAgent[] = []; + for (const [agentId, entry] of this.retiredInternalAgents) { + if (entry.expiresAt <= now) { + this.retiredInternalAgents.delete(agentId); + continue; + } + retired.push({ record: entry.record, lastMessage: entry.lastMessage }); + } + return retired; + } + async listImportableSessions( options?: ImportablePersistedAgentQueryOptions, ): Promise<ManagedImportableSessionsResult> { @@ -1673,6 +1709,42 @@ export class AgentManager { } } + /** + * The archived snapshot of an internal agent, for a while after archive. + * Null once it has expired or was never an internal agent. + */ + getRetiredInternalAgent(agentId: string): RetiredInternalAgent | null { + const entry = this.retiredInternalAgents.get(agentId); + if (!entry) { + return null; + } + if (entry.expiresAt <= Date.now()) { + this.retiredInternalAgents.delete(agentId); + return null; + } + return { record: entry.record, lastMessage: entry.lastMessage }; + } + + private retireInternalAgent(entry: RetiredInternalAgent): void { + const now = Date.now(); + for (const [agentId, retired] of this.retiredInternalAgents) { + if (retired.expiresAt <= now) { + this.retiredInternalAgents.delete(agentId); + } + } + while (this.retiredInternalAgents.size >= RETIRED_INTERNAL_AGENT_LIMIT) { + const oldest = this.retiredInternalAgents.keys().next().value; + if (oldest === undefined) { + break; + } + this.retiredInternalAgents.delete(oldest); + } + this.retiredInternalAgents.set(entry.record.id, { + ...entry, + expiresAt: now + RETIRED_INTERNAL_AGENT_TTL_MS, + }); + } + closeAgent(agentId: string): Promise<void> { const existing = this.inFlightAgentCloses.get(agentId); if (existing) { @@ -1758,6 +1830,23 @@ export class AgentManager { requestedArchivedAt?: string, ): Promise<{ archivedAt: string }> { const agent = this.requireAgent(agentId); + if (agent.internal) { + // Nothing about an internal agent is on disk, and archiving must not put + // it there. Its final snapshot is kept in memory for a while (see + // getRetiredInternalAgent); the runtime is closed and the committed + // timeline dropped, so `getAgent` returns null from here on. + const archivedAt = requestedArchivedAt ?? new Date().toISOString(); + const lastMessage = await this.getLastAssistantMessage(agentId); + const record = buildArchivedAgentRecord( + toStoredAgentRecord(agent, { title: agent.config.title ?? null, internal: true }), + { archivedAt, updatedAt: archivedAt }, + ); + await this.closeAgentRuntime(agentId); + await this.deleteAgentState(agentId); + this.retireInternalAgent({ record, lastMessage }); + await this.cascadeArchiveChildren(record); + return { archivedAt }; + } if (!this.registry) { throw new Error("Agent storage is not configured"); } @@ -1776,7 +1865,7 @@ export class AgentManager { await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive"); this.discardRetainedAgentState(agentId); - await this.cascadeArchiveChildren(agentId); + await this.cascadeArchiveChildren(stored); return { archivedAt }; } @@ -1785,16 +1874,15 @@ export class AgentManager { // label pointing back at the caller. Archiving the parent cascades to those // children so subagent fleets don't outlive their orchestrator. Detached // handoff agents omit this label, so they stand outside the cascade. - private async cascadeArchiveChildren(parentAgentId: string): Promise<void> { + private async cascadeArchiveChildren( + parent: Pick<StoredAgentRecord, "id" | "workspaceId">, + ): Promise<void> { const registry = this.registry; if (!registry) { return; } + const parentAgentId = parent.id; const records = await registry.list(); - const parent = records.find((record) => record.id === parentAgentId); - if (!parent) { - throw new Error(`Archived parent ${parentAgentId} not found in storage`); - } for (const record of records) { if (record.archivedAt) { continue; @@ -1824,6 +1912,21 @@ export class AgentManager { } }); } + // Internal children never reach storage, so the scan above cannot see them. + // They have no tab to keep open and no listing to detach into, so they + // always archive with the parent. + for (const child of Array.from(this.agents.values())) { + if (!child.internal || getParentAgentIdFromLabels(child.labels) !== parentAgentId) { + continue; + } + await this.runLifecycleMutation(child.id, async () => { + const current = this.agents.get(child.id); + if (!current || getParentAgentIdFromLabels(current.labels) !== parentAgentId) { + return; + } + await this.archiveAgentUnlocked(current.id); + }); + } } private async markRecordArchived( @@ -2199,7 +2302,7 @@ export class AgentManager { if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord); await this.fireAgentArchived(agentId); - await this.cascadeArchiveChildren(agentId); + await this.cascadeArchiveChildren(record); return nextRecord; } @@ -2361,7 +2464,7 @@ export class AgentManager { return false; } if (options?.clientMessageId) { - this.recordSubmittedPrompt(agent, prompt, options.clientMessageId); + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted"); this.emitState(agent); } const dispatch = (event: AgentStreamEvent): void => { @@ -2457,6 +2560,12 @@ export class AgentManager { agent.pendingReplacement = false; const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; pendingRun.start = { status: "failed", error: errorMsg }; + // A terminal rejection belongs after the submitted prompt even though no provider turn exists. + if (options?.clientMessageId) { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", { + messageId: options.clientMessageId, + }); + } await this.handleStreamEvent(agent, { type: "turn_failed", provider: agent.provider, @@ -2546,7 +2655,7 @@ export class AgentManager { ) : undefined; if (options?.clientMessageId) { - this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted", { messageId: options.clientMessageId, turnId, providerMessageId: @@ -2852,7 +2961,7 @@ export class AgentManager { if (!clientMessageId) { return; } - this.recordSubmittedPrompt(agent, prompt, clientMessageId, { + this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", { messageId: clientMessageId, turnId: expectedTurnId, }); @@ -3511,7 +3620,7 @@ export class AgentManager { // Legacy/imported chats need their existing history before startup rows. await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory); } else { - for (const entry of session.initialTimeline) { + for (const entry of buildImportedTimelineRows(session.initialTimeline)) { this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp }); } } @@ -4018,10 +4127,9 @@ export class AgentManager { for await (const rawEvent of agent.session.streamHistory()) { const event = limitAgentStreamEventContent(rawEvent); if (event.type === "timeline") { - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { - continue; - } - historyEvents.push(event); + const item = projectAgentMessage(event.item); + if (!item) continue; + historyEvents.push({ ...event, item }); } else if (event.type === "provider_subagent") { providerSubagentEvents.push(event); } @@ -4086,10 +4194,9 @@ export class AgentManager { if (event.type !== "timeline") { continue; } - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { - continue; - } - historyEvents.push(event); + const item = projectAgentMessage(event.item); + if (!item) continue; + historyEvents.push({ ...event, item }); } } catch (error) { this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history"); @@ -4421,12 +4528,14 @@ export class AgentManager { }): Promise<void> { const { agent, event, options, flags } = params; - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { + const item = projectAgentMessage(event.item); + if (!item) { flags.shouldDispatchEvent = false; flags.shouldNotifyWaiters = false; return; } + event.item = item; if ( event.item.type === "user_message" && event.item.clientMessageId && @@ -4697,19 +4806,34 @@ export class AgentManager { agent: ActiveManagedAgent, prompt: AgentPromptInput, clientMessageId: string, + outcome: "accepted" | "rejected", options?: { messageId?: string; providerMessageId?: string; turnId?: string }, ): void { - if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) { - return; - } - this.touchUpdatedAt(agent); - agent.lastUserMessageAt = new Date(); - const item: AgentTimelineItem = { + const item = projectAgentMessage({ type: "user_message", text: submittedPromptText(prompt), clientMessageId, ...(options?.messageId ? { messageId: options.messageId } : {}), - }; + }); + if (!item) return; + // Human attempts stay in history on rejection; delivery notifications require acceptance. + if (outcome === "rejected" && item.type !== "user_message") return; + this.recordSubmittedPromptItem(agent, item, options); + } + + private recordSubmittedPromptItem( + agent: ActiveManagedAgent, + item: AgentTimelineItem, + options?: { providerMessageId?: string; turnId?: string }, + ): void { + if ( + item.type === "user_message" && + item.clientMessageId && + this.timelineStore.getSubmittedUserMessage(agent.id, item.clientMessageId) + ) + return; + this.touchUpdatedAt(agent); + if (item.type === "user_message") agent.lastUserMessageAt = new Date(); this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, options?.turnId, options); } @@ -4722,11 +4846,14 @@ export class AgentManager { if (!clientMessageId) return null; let existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); if (!existing) { - this.recordSubmittedPrompt(agent, item.text, clientMessageId, { - messageId: clientMessageId, - ...(messageId ? { providerMessageId: messageId } : {}), - ...(turnId ? { turnId } : {}), - }); + this.recordSubmittedPromptItem( + agent, + { ...item, messageId: clientMessageId }, + { + ...(messageId ? { providerMessageId: messageId } : {}), + ...(turnId ? { turnId } : {}), + }, + ); existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); } if (!existing || existing.item.type !== "user_message") return null; @@ -5061,8 +5188,12 @@ export class AgentManager { ) { continue; } - // Skip internal agents for global subscribers (those without a specific agentId) - if (!subscriber.agentId && this.eventBelongsToInternalAgent(event)) { + // Global subscribers (no agentId) skip internal agents unless they opted in. + if ( + !subscriber.agentId && + !subscriber.includeInternal && + this.eventBelongsToInternalAgent(event) + ) { continue; } subscriber.callback(event); diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index d1422f5b6b..cf9711a9df 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -215,22 +215,19 @@ function createMcpRecordingAgentClients(recorder: LaunchRecorder) { }; } -async function assertAgentNotRunning(options: { - client: McpClient; - agentId: string; -}): Promise<void> { - const statusResult = await options.client.callTool({ - name: "get_agent_status", - args: { agentId: options.agentId }, - }); - const payload = getStructuredContent(statusResult); - if (!payload) { - throw new Error("get_agent_status returned no structured payload"); - } - const status = payload.status; - if (status === "running" || status === "initializing") { - throw new Error(`Agent still running after blocking create_agent (status=${status})`); - } +async function waitForAgentFinish(options: { client: McpClient; agentId: string }): Promise<void> { + await expect + .poll( + async () => { + const result = await options.client.callTool({ + name: "get_agent_status", + args: { agentId: options.agentId }, + }); + return getStructuredContent(result)?.status; + }, + { timeout: 10000 }, + ) + .toBe("idle"); } describe("agent MCP end-to-end (offline)", () => { @@ -276,7 +273,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt, - background: false, }, }); @@ -284,7 +280,7 @@ describe("agent MCP end-to-end (offline)", () => { agentId = typeof payload?.agentId === "string" ? payload.agentId : null; expect(agentId).toBeTruthy(); - await assertAgentNotRunning({ client, agentId: agentId! }); + await waitForAgentFinish({ client, agentId: agentId! }); if (existsSync(filePath)) { const contents = await readFile(filePath, "utf8"); @@ -377,7 +373,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const payload = getStructuredContent(result); @@ -452,7 +447,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const payload = getStructuredContent(result); @@ -476,7 +470,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const disabledPayload = getStructuredContent(disabledResult); @@ -541,7 +534,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const payload = getStructuredContent(result); @@ -601,7 +593,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "codex/gpt-5.4-mini", mode: "full-access", initialPrompt: "Run exactly: sleep 30", - background: true, }, }); @@ -772,7 +763,6 @@ describe("agent MCP end-to-end (offline)", () => { const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`); - let agentId: string | null = null; try { const result = await client.callTool({ name: "create_agent", @@ -782,29 +772,13 @@ describe("agent MCP end-to-end (offline)", () => { provider: "codex/gpt-5.4-mini", mode: "full-access", initialPrompt: "Run exactly: sleep 30", - background: true, }, }); - const payload = getStructuredContent(result); - agentId = typeof payload?.agentId === "string" ? payload.agentId : null; - expect(agentId).toBeTruthy(); - - await assertAgentNotRunning({ client, agentId: agentId! }); - const statusResult = await client.callTool({ - name: "get_agent_status", - args: { agentId }, - }); - const statusPayload = getStructuredContent(statusResult); - expect(statusPayload?.status).toBe("error"); - const snapshot = statusPayload?.snapshot; - const lastError = - snapshot && typeof snapshot === "object" ? Reflect.get(snapshot, "lastError") : undefined; - expect(lastError).toContain("Initial turn failed to start"); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("Initial turn failed to start"); + expect(getStructuredContent(result)?.agentId).toBeUndefined(); } finally { - if (agentId) { - await client.callTool({ name: "kill_agent", args: { agentId } }); - } await client.close(); await daemon.stop(); await rm(paseoHome, { recursive: true, force: true }); @@ -880,7 +854,6 @@ describe("agent MCP end-to-end (offline)", () => { initialPrompt: "say done and stop", worktreeName: "mcp-worktree-setup-test", baseBranch: "main", - background: true, }, }), timeoutMs: 2500, diff --git a/packages/server/src/server/agent/agent-messages/index.ts b/packages/server/src/server/agent/agent-messages/index.ts new file mode 100644 index 0000000000..1543bf33b4 --- /dev/null +++ b/packages/server/src/server/agent/agent-messages/index.ts @@ -0,0 +1,191 @@ +import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import type { + AgentPromptContentBlock, + AgentPromptInput, + AgentTimelineItem, +} from "../agent-sdk-types.js"; + +const agentIdSchema = z.string().min(1); +const sourceSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("agent-message"), + agentId: agentIdSchema, + title: z.string().min(1).optional(), + }), + z.object({ + kind: z.literal("agent-notification"), + agentId: agentIdSchema, + title: z.string().min(1).optional(), + event: z.enum(["finished", "errored", "permission-required", "closed"]), + }), +]); +export type AgentPromptSource = z.infer<typeof sourceSchema>; + +interface AgentMessage { + id: string; + source: AgentPromptSource | null; + text: string; +} + +function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +function unescapeXml(value: string): string | null { + if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null; + return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => { + switch (entity) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return '"'; + default: + return "'"; + } + }); +} + +export function formatAgentMessage(message: AgentMessage): string { + const { source } = message; + const attributes = source + ? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"` + : 'kind="user-message"'; + const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : ""; + const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : ""; + return `<paseo-system version="1" ${attributes} message-id="${escapeXml(message.id)}"${title}${event}>\n${escapeXml(message.text)}\n</paseo-system>`; +} + +export function parseAgentMessage(text: string): AgentMessage | null { + const envelope = /^<paseo-system((?:\s+[a-z-]+="[^"<>]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec( + text, + ); + if (!envelope) return null; + const attributes = new Map<string, string>(); + for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) { + const value = unescapeXml(match[2]); + if (value === null || attributes.has(match[1])) return null; + attributes.set(match[1], value); + } + if (attributes.get("version") !== "1") return null; + const id = attributes.get("message-id"); + const body = unescapeXml(envelope[2]); + if (!id || body === null) return null; + if (attributes.get("kind") === "user-message") { + if (attributes.has("source-agent-id") || attributes.has("event")) return null; + return { id, source: null, text: body }; + } + const source = sourceSchema.safeParse({ + kind: attributes.get("kind"), + agentId: attributes.get("source-agent-id"), + event: attributes.get("event"), + title: attributes.get("source-agent-title"), + }); + if (!source.success) return null; + return { id, source: source.data, text: body }; +} + +function renderMessageText(block: AgentPromptContentBlock): string[] { + if (block.type === "image") return []; + if (block.type === "text") return [block.text]; + return [renderPromptAttachmentAsText(block)]; +} + +export function prepareAgentMessage( + prompt: AgentPromptInput, + source?: AgentPromptSource, + id?: string, +): { prompt: AgentPromptInput; messageId?: string } { + if (!source) { + const submittedText = + typeof prompt === "string" + ? prompt + : prompt + .flatMap((block) => + block.type === "text" && !("mimeType" in block) ? [block.text] : [], + ) + .join("\n") + .trim(); + // Quote reserved envelopes only when they arrived through the human send path. + // This provenance must survive provider history, not only the accepted local row. + if (!parseAgentMessage(submittedText) && !isSystemInjectedEnvelope(submittedText)) { + return { prompt, messageId: id }; + } + } + const messageId = id ?? randomUUID(); + const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n"); + // Render textual attachments before wrapping, so provider replay still contains one + // complete envelope instead of an envelope followed by provider-rendered context. + const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text }); + return { + messageId, + prompt: + typeof prompt === "string" + ? envelope + : [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")], + }; +} + +/** The same projection owns provider echoes, acceptance, import, and replay. */ +export function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null { + if (item.type !== "user_message") return item; + const message = parseAgentMessage(item.text); + if (!message) { + // COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once + // legacy notification history is retired. Schedules still use this envelope. + return isSystemInjectedEnvelope(item.text) ? null : item; + } + const { source } = message; + if (!source) { + return { ...item, text: message.text, clientMessageId: message.id }; + } + return { + type: "tool_call", + callId: `paseo-agent-message:${message.id}`, + agentMessage: { + event: source.kind === "agent-message" ? "message" : source.event, + sender: { id: source.agentId, ...(source.title ? { title: source.title } : {}) }, + text: message.text, + }, + // COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08 + // once clients can accept a dedicated timeline variant. Older clients still + // parse this existing shape and can expand the delivered body. + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: message.text, icon: "bot" }, + }; +} + +/** + * Wrap a body in <paseo-system>…</paseo-system> so the receiving agent + * recognizes the prompt as system-injected context — not a user turn. + * Used by schedule fires; agent messages use the versioned envelope. + */ +export function formatSystemNotificationPrompt(reason: string): string { + return `<paseo-system>\n${reason}\n</paseo-system>`; +} + +const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/; + +export function isSystemInjectedEnvelope(text: string): boolean { + return SYSTEM_ENVELOPE_PATTERN.test(text); +} diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index f72bd84db1..ec4186b022 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -407,6 +407,17 @@ describe("toAgentPayload", () => { }); }); + it("carries a stored lastError onto the payload", () => { + const record = toStoredAgentRecord( + createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }), + ); + + expect(buildStoredAgentPayload(record, ["codex"]).lastError).toBe("boom"); + expect(buildStoredAgentPayload({ ...record, lastError: null }, ["codex"])).not.toHaveProperty( + "lastError", + ); + }); + it("omits lastUsage when not available", () => { const agent = createManagedAgent({ lastUsage: undefined }); const payload = toAgentPayload(agent); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 05f932aea9..3f2a9b7775 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -139,6 +139,7 @@ export function toAgentPayload( persistence: projectPersistenceHandleForWire(agent.persistence), title: options?.title ?? null, labels: agent.labels, + ...(agent.internal ? { internal: true } : {}), }; const usage = sanitizeUsage(agent.lastUsage); @@ -247,7 +248,9 @@ export function buildStoredAgentPayload( attentionReason: record.attentionReason ?? null, attentionTimestamp: record.attentionTimestamp ?? null, archivedAt: record.archivedAt ?? null, + ...(record.internal ? { internal: true } : {}), labels: normalizeLabels(record.labels), + ...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}), ...(providerAvailable ? {} : { providerUnavailable: true }), }; } diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 08bd66a79c..dcdfdb1cbf 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -1,3 +1,11 @@ +import { + formatSystemNotificationPrompt, + isSystemInjectedEnvelope, + parseAgentMessage, + formatAgentMessage, + prepareAgentMessage, + projectAgentMessage, +} from "./agent-messages/index.js"; import { expect, it, test, vi } from "vitest"; import pino, { type Logger } from "pino"; import { randomUUID } from "node:crypto"; @@ -8,12 +16,7 @@ import { join } from "node:path"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; -import { - formatSystemNotificationPrompt, - isSystemInjectedEnvelope, - setupFinishNotification, - waitForAgentRunStartWithTimeout, -} from "./agent-prompt.js"; +import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js"; import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; import type { AgentClient, @@ -219,6 +222,11 @@ function createFinishNotificationScenario( type: "agent_state", agent: childAgent, }); + subscriber?.({ + type: "agent_stream", + agentId: "child-agent", + event: { type: "turn_completed", provider: "codex" }, + }); }, async finishChildAndReadParentPrompt() { const parentPrompt = new Promise<string>((resolve) => { @@ -272,11 +280,16 @@ test("finish notifications tell the parent the child's last assistant message", scenario.startWatchingChild(); const parentPrompt = await scenario.finishChildAndReadParentPrompt(); - expect(parentPrompt).toEqual( - formatSystemNotificationPrompt( - "Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>", - ), - ); + expect(parseAgentMessage(parentPrompt)).toEqual({ + id: expect.any(String), + source: { + kind: "agent-notification", + agentId: "child-agent", + title: "Child Agent", + event: "finished", + }, + text: "Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>", + }); expect(scenario.steerAttemptCount()).toBe(1); }); @@ -303,9 +316,16 @@ test("closing a watched child notifies the caller", async () => { scenario.startWatchingChild(); const parentPrompt = await scenario.closeChildAndReadParentPrompt(); - expect(parentPrompt).toEqual( - formatSystemNotificationPrompt("Agent child-agent (Child Agent) was closed."), - ); + expect(parseAgentMessage(parentPrompt)).toEqual({ + id: expect.any(String), + source: { + kind: "agent-notification", + agentId: "child-agent", + title: "Child Agent", + event: "closed", + }, + text: "Agent child-agent (Child Agent) was closed.", + }); }); test("finish notifications survive permission responses", async () => { @@ -318,9 +338,9 @@ test("finish notifications survive permission responses", async () => { expect(scenario.parentPrompts()).toHaveLength(1); }); expect(scenario.parentPrompts()[0]).toContain("needs permission."); - const permissionPayload = scenario - .parentPrompts()[0] - .match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1]; + const permissionPayload = parseAgentMessage(scenario.parentPrompts()[0])!.text.match( + /<permission-request>\n([\s\S]+?)\n<\/permission-request>/, + )?.[1]; expect(permissionPayload).toBeDefined(); expect(JSON.parse(permissionPayload!)).toEqual({ agentId: "child-agent", @@ -377,7 +397,9 @@ test("finish notifications report every concurrently pending permission", async await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); expect( scenario.parentPrompts().map((prompt) => { - const payload = prompt.match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1]; + const payload = parseAgentMessage(prompt)!.text.match( + /<permission-request>\n([\s\S]+?)\n<\/permission-request>/, + )?.[1]; return JSON.parse(payload!).requestId; }), ).toEqual(["permission-1", "permission-2"]); @@ -523,6 +545,11 @@ it("does not notify archived callers", async () => { type: "agent_state", agent: childAgent, }); + subscriber?.({ + type: "agent_stream", + agentId: "child-agent", + event: { type: "turn_completed", provider: "codex" }, + }); await vi.waitFor(() => { expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent"); @@ -762,3 +789,75 @@ test("waiting for a run start still gives up at the run start budget", async () await scenario.cleanup(); } }); + +test("agent envelopes round-trip opaque sender IDs and XML-sensitive messages", () => { + const message = { + id: "delivery-1", + source: { + kind: "agent-message" as const, + agentId: 'host::agent<&"', + title: 'QA <messenger> & "reviewer"', + }, + text: 'Review <changes> & "quotes"\n</paseo-system>\n<paseo-system>nested</paseo-system>', + }; + const encoded = formatAgentMessage(message); + expect(parseAgentMessage(encoded)).toEqual(message); + expect(parseAgentMessage(`Example: ${encoded}`)).toBeNull(); + expect(parseAgentMessage(encoded.replace('version="1"', 'version="2"'))).toBeNull(); + expect(parseAgentMessage(encoded.replace("<", "<"))).toBeNull(); + expect(parseAgentMessage(encoded.replace('version="1"', 'version="1" version="1"'))).toBeNull(); +}); + +test("agent envelope includes rendered attachment context and preserves images", () => { + const image = { type: "image" as const, data: "aW1hZ2U=", mimeType: "image/png" }; + const attachment = { + type: "github_issue" as const, + mimeType: "application/github-issue" as const, + number: 42, + title: "Review context", + url: "https://example.com/issues/42", + body: "Attached context", + }; + const source = { kind: "agent-message" as const, agentId: "remote:sender" }; + expect( + prepareAgentMessage( + [{ type: "text", text: "First" }, image, attachment, { type: "text", text: "Second" }], + source, + "message", + ), + ).toEqual({ + messageId: "message", + prompt: [ + { + type: "text", + text: formatAgentMessage({ + id: "message", + source, + text: "First\n\nGitHub Issue #42: Review context\nhttps://example.com/issues/42\n\nAttached context\n\nSecond", + }), + }, + image, + ], + }); +}); + +test("a human pasting an agent envelope stays a human message through provider replay", () => { + const pasted = formatAgentMessage({ + id: "example", + source: { kind: "agent-message", agentId: "sender" }, + text: "hello", + }); + const delivery = prepareAgentMessage(pasted, undefined, "human-submission"); + expect( + projectAgentMessage({ + type: "user_message", + text: String(delivery.prompt), + messageId: "provider-echo", + }), + ).toEqual({ + type: "user_message", + text: pasted, + messageId: "provider-echo", + clientMessageId: "human-submission", + }); +}); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 41e2d52014..4044fd84bc 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,3 +1,4 @@ +import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js"; import type { Logger } from "pino"; import type { @@ -27,6 +28,8 @@ export type AgentRunController = Pick< }; export interface StartAgentRunOptions { + /** Called after selecting the run, before consuming its events. */ + onDispatch?: (disposition: PromptDispatchDisposition) => void; replaceRunning?: boolean; activeTurnBehavior?: ActiveTurnBehavior; runOptions?: AgentRunOptions; @@ -113,6 +116,7 @@ export async function startAgentRun( // in-flight turn — replaceAgentRun would interrupt the running turn. The // intercept lives at this layer so it covers every prompt entrypoint. if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { + options?.onDispatch?.("out_of_band"); return { disposition: "out_of_band" }; } try { @@ -137,6 +141,7 @@ async function startAgentRunInner( const snapshot = agentManager.getAgent(agentId); const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options); if (steered?.disposition === "steered") { + options?.onDispatch?.("steered"); return steered; } const { iterator, replaced } = steered @@ -151,6 +156,7 @@ async function startAgentRunInner( }, "agent.session.start_stream.iterator_returned", ); + options?.onDispatch?.("turn_started"); void (async () => { try { try { @@ -206,27 +212,14 @@ export async function unarchiveAgentState( return true; } -/** - * Wrap a body in <paseo-system>…</paseo-system> so the receiving agent - * recognizes the prompt as system-injected context — not a user turn. - * Used by chat mentions, schedule fires, and notify-on-finish. - */ -export function formatSystemNotificationPrompt(reason: string): string { - return `<paseo-system>\n${reason}\n</paseo-system>`; -} - -const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/; - -export function isSystemInjectedEnvelope(text: string): boolean { - return SYSTEM_ENVELOPE_PATTERN.test(text); -} - export interface SendPromptToAgentParams { + onDispatch?: StartAgentRunOptions["onDispatch"]; agentManager: AgentManager; agentStorage: AgentStorage; agentId: string; /** Prompt to dispatch to the provider (may include image blocks or wrapped text). */ prompt: AgentPromptInput; + source?: AgentPromptSource; messageId?: string; activeTurnBehavior?: ActiveTurnBehavior; runOptions?: AgentRunOptions; @@ -244,10 +237,12 @@ export interface SendPromptToAgentParams { } export interface StartCreatedAgentInitialPromptParams { + agentStorage: AgentStorage; agentManager: AgentManager; agentId: string; snapshot?: ManagedAgent; prompt: AgentPromptInput | null; + source?: AgentPromptSource; runOptions?: AgentRunOptions; logger: Logger; } @@ -292,6 +287,18 @@ export async function waitForAgentRunStartWithTimeout( } } +async function resolvePromptSource( + source: AgentPromptSource | undefined, + manager: Pick<AgentManager, "getAgent">, + storage: AgentStorage, +): Promise<AgentPromptSource | undefined> { + if (!source) return undefined; + const title = ( + manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title + )?.trim(); + return title ? { ...source, title } : source; +} + /** * Full send-prompt orchestration: (optional unarchive) → load → (optional * mode change) → start run. @@ -309,29 +316,44 @@ export async function sendPromptToAgent( const unarchive = params.unarchive ?? true; const record = await params.agentStorage.get(params.agentId); + let archivedAtToRestore: string | null = null; if (record?.archivedAt) { if (!unarchive) { return { disposition: "turn_started" }; } - await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId); + if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) { + archivedAtToRestore = record.archivedAt; + } } - await ensureAgentLoaded(params.agentId, { - agentManager: params.agentManager, - agentStorage: params.agentStorage, - logger: params.logger, - }); + try { + await ensureAgentLoaded(params.agentId, { + agentManager: params.agentManager, + agentStorage: params.agentStorage, + logger: params.logger, + }); + } catch (error) { + // A send that could not load the agent leaves it where it was: still archived. + // Concurrent sends share this load, so none of them holds a live session. + if (archivedAtToRestore) { + await params.agentManager.archiveSnapshot(params.agentId, archivedAtToRestore); + } + throw error; + } if (params.sessionMode) { await params.agentManager.setAgentMode(params.agentId, params.sessionMode); } - const runOptions = params.messageId - ? { ...params.runOptions, clientMessageId: params.messageId } + const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage); + const delivery = prepareAgentMessage(params.prompt, source, params.messageId); + const runOptions = delivery.messageId + ? { ...params.runOptions, clientMessageId: delivery.messageId } : params.runOptions; - return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, { + return await startAgentRun(params.agentManager, params.agentId, delivery.prompt, params.logger, { replaceRunning: true, + onDispatch: params.onDispatch, activeTurnBehavior: params.activeTurnBehavior, clearPendingPermissions: params.clearPendingPermissions, runOptions, @@ -350,14 +372,17 @@ export async function startCreatedAgentInitialPrompt( return currentSnapshot; } + const delivery = prepareAgentMessage( + params.prompt, + await resolvePromptSource(params.source, params.agentManager, params.agentStorage), + params.runOptions?.clientMessageId, + ); const dispatchResult = await startAgentRun( params.agentManager, params.agentId, - params.prompt, + delivery.prompt, params.logger, - { - runOptions: params.runOptions, - }, + { runOptions: { ...params.runOptions, clientMessageId: delivery.messageId } }, ); if (dispatchResult.disposition === "turn_started") { @@ -377,10 +402,26 @@ export interface SetupFinishNotificationParams { childAgentId: string; callerAgentId: string; requireParentOwnership?: boolean; + /** A replacement must observe its new start, not the displaced running snapshot. */ + waitForTurnStart?: boolean; logger: Logger; } -type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed"; +type FinishNotificationReason = + | "finished" + | "errored" + | "needs permission" + | "was closed" + | "was canceled"; + +const finishNotificationEvents = { + finished: "finished", + errored: "errored", + "needs permission": "permission-required", + "was closed": "closed", + // Use the existing failure event so older clients still parse agent-message metadata. + "was canceled": "errored", +} as const; const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; @@ -430,16 +471,18 @@ interface NotifySafelyOptions { // next finish reaches the caller once. const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>(); -export function setupFinishNotification(params: SetupFinishNotificationParams): void { +export function setupFinishNotification(params: SetupFinishNotificationParams): () => void { const { agentManager, agentStorage, childAgentId, callerAgentId, requireParentOwnership = false, + waitForTurnStart = false, logger, } = params; let hasSeenRunning = false; + let hasSeenTurn = false; let stopped = false; const notifiedPermissionRequestIds = new Set<string>(); let unsubscribe: (() => void) | null = null; @@ -487,7 +530,12 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): agentManager, agentStorage, agentId: callerAgentId, - prompt: formatSystemNotificationPrompt(body), + prompt: body, + source: { + kind: "agent-notification", + agentId: childAgentId, + event: finishNotificationEvents[reason], + }, activeTurnBehavior: "steer", unarchive: false, logger, @@ -507,6 +555,35 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): }); } + function observeState(agent: ManagedAgent): void { + for (const requestId of notifiedPermissionRequestIds) { + if (!agent.pendingPermissions.has(requestId)) { + notifiedPermissionRequestIds.delete(requestId); + } + } + if (agent.lifecycle === "running") { + if (waitForTurnStart && !hasSeenTurn) return; + hasSeenTurn = true; + if (agent.pendingPermissions.size === 0) { + hasSeenRunning = true; + } + return; + } + // Prior terminal state is not this task's outcome. A failure before turn start + // is reported by the dispatch acknowledgement instead of a finish callback. + if (agent.lifecycle === "error" && hasSeenTurn) { + notifySafely("errored"); + return; + } + // Idle is published before the terminal stream event for both completion + // and cancellation. Let that event identify the outcome instead. + if (agent.lifecycle === "closed") { + notifySafely("was closed"); + return; + } + return; + } + unsubscribe = agentManager.subscribe( (event) => { if (stopped) { @@ -514,29 +591,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): } if (event.type === "agent_state") { - for (const requestId of notifiedPermissionRequestIds) { - if (!event.agent.pendingPermissions.has(requestId)) { - notifiedPermissionRequestIds.delete(requestId); - } - } - if (event.agent.lifecycle === "running") { - if (event.agent.pendingPermissions.size === 0) { - hasSeenRunning = true; - } - return; - } - if (event.agent.lifecycle === "error") { - notifySafely("errored"); - return; - } - if (event.agent.lifecycle === "idle" && hasSeenRunning) { - notifySafely("finished"); - return; - } - if (event.agent.lifecycle === "closed") { - notifySafely("was closed"); - return; - } + observeState(event.agent); return; } @@ -544,6 +599,24 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } + if (event.event.type === "turn_started") { + hasSeenTurn = true; + hasSeenRunning = true; + return; + } + + if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") { + // Replacing a turn keeps the child running through the displaced turn's + // cancellation. The task settles only when the child becomes idle. + if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return; + if (event.event.type === "turn_completed" && hasSeenRunning) { + notifySafely("finished"); + } else if (event.event.type === "turn_canceled" && hasSeenTurn) { + notifySafely("was canceled"); + } + return; + } + if (event.event.type === "permission_requested") { // A permission pause is an intermediate checkpoint. Forget the run // observed before it so an idle state during follow-up startup cannot @@ -578,11 +651,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): const childSnapshot = agentManager.getAgent(childAgentId); if (!childSnapshot || childSnapshot.lifecycle === "closed") { stop(); - return; + return stop; } - if (childSnapshot.lifecycle === "running") { + if (childSnapshot.lifecycle === "running" && !waitForTurnStart) { + hasSeenTurn = true; hasSeenRunning = true; - } else if (childSnapshot.lifecycle === "error") { - notifySafely("errored"); } + return stop; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 3e5e739577..34d36c9e16 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -628,6 +628,11 @@ export interface AgentCreateSessionOptions { * Defaults to true. Providers that cannot honor false should no-op. */ persistSession?: boolean; + /** + * Model ids added by provider configuration (`models` / `additionalModels`). + * Providers that validate against runtime-advertised models must accept these. + */ + configuredModelIds?: readonly string[]; } /** What a resumed session is for: driving the agent, or reading what it already did. */ @@ -637,6 +642,8 @@ export type AgentResumePurpose = "interactive" | "history"; export interface AgentResumeSessionOptions { /** Defaults to interactive. History loading may be read-only for archived native sessions. */ purpose?: AgentResumePurpose; + /** See AgentCreateSessionOptions.configuredModelIds. */ + configuredModelIds?: readonly string[]; } /** diff --git a/packages/server/src/server/agent/agent-stream-coalescer.test.ts b/packages/server/src/server/agent/agent-stream-coalescer.test.ts index c48a5ec8e4..1cdf4bf375 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.test.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.test.ts @@ -723,6 +723,57 @@ describe("AgentStreamCoalescer", () => { ]); }); + test("keeps a known tool call detail when a later update in the window has none", () => { + const { coalescer, flushes } = createHarness(); + primeLeadingEdge(coalescer, flushes); + + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Task", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }), + ); + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { type: "unknown", input: { description: "List files" }, output: null }, + }), + ); + + expect(flushes.map((flush) => flush.item)).toEqual([ + { + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }, + ]); + }); + test("preserves mixed text and tool call arrival order within a flush", async () => { const { coalescer, flushes } = createHarness(); primeLeadingEdge(coalescer, flushes); diff --git a/packages/server/src/server/agent/agent-stream-coalescer.ts b/packages/server/src/server/agent/agent-stream-coalescer.ts index ca52a5479e..c11d968228 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.ts @@ -1,4 +1,5 @@ import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "./agent-sdk-types.js"; +import { mergeToolCallItems } from "./timeline-projection.js"; export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 60; @@ -183,9 +184,16 @@ export class AgentStreamCoalescer { } const existingIndex = buffer.toolCallEntryIndexes.get(event.item.callId); + const existing = existingIndex !== undefined ? buffer.entries[existingIndex] : undefined; + // Merge rather than replace, so an update that carries less (an unknown detail after a + // labeled one) cannot erase what the timeline would have kept had both been flushed. + let item = event.item; + if (existing?.kind === "tool_call") { + item = mergeToolCallItems(existing.item, event.item); + } const entry: PendingToolCallEntry = { kind: "tool_call", - item: event.item, + item, provider: event.provider, ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), }; diff --git a/packages/server/src/server/agent/agent-timeline-content.test.ts b/packages/server/src/server/agent/agent-timeline-content.test.ts index f3b94e248e..c40b712fad 100644 --- a/packages/server/src/server/agent/agent-timeline-content.test.ts +++ b/packages/server/src/server/agent/agent-timeline-content.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; describe("agent timeline content", () => { - test("limits terminal input to the tool-call content budget", () => { + test("marks truncated plain-text tool content within the content budget", () => { const oversizedInput = "x".repeat(64 * 1024 + 1); const item = limitAgentTimelineItemContent({ @@ -19,6 +19,7 @@ describe("agent timeline content", () => { }, }); + expect(limitAgentTimelineItemContent(item)).toEqual(item); expect(item).toEqual({ type: "tool_call", callId: "terminal-session-4242", @@ -27,7 +28,7 @@ describe("agent timeline content", () => { error: null, detail: { type: "plain_text", - text: "x".repeat(64 * 1024), + text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]", icon: "square_terminal", }, }); diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index 8cf92381fa..d9bb08c0bc 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -42,11 +42,12 @@ function limitPlainText(item: AgentTimelineItem): AgentTimelineItem { ) { return item; } + const notice = "\n\n[Content truncated]"; return { ...item, detail: { ...item.detail, - text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH), + text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice, }, }; } diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 53a2177272..554bb9869e 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -7,13 +7,11 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, + CreatePaseoWorktreeSetupContinuationInput, + NormalizedGitOptions, } from "../worktree-session.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; -import type { - CreateAgentWorktreeTarget, - FirstAgentContext, - SessionOutboundMessage, -} from "../messages.js"; +import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js"; import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; @@ -59,18 +57,34 @@ export class CreateAgentLifecycleDispatch { async createWorktreeForRequest(input: { cwd: string; - target: CreateAgentWorktreeTarget | undefined; + target: NormalizedGitOptions | null; firstAgentContext: FirstAgentContext; - hasLegacyGitOptions: boolean; + setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; + background?: boolean; + callerWorkspaceId?: string; }): Promise<CreatePaseoWorktreeWorkflowResult | null> { - if (input.target && input.hasLegacyGitOptions) { - throw new Error("create_agent_request worktree cannot be combined with git options"); - } - if (!input.target) { - return null; - } - - return this.createWorktreeForTarget(input.cwd, input.target, input.firstAgentContext); + const target = input.target; + if (!target?.createWorktree) return null; + return this.dependencies.createPaseoWorktreeWorkflow( + { + cwd: input.cwd, + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, + firstAgentContext: input.firstAgentContext, + worktreeSlug: target.worktreeSlug, + refName: target.refName, + action: target.action, + checkoutSource: target.checkoutSource, + githubPrNumber: target.githubPrNumber, + runSetup: false, + paseoHome: this.dependencies.paseoHome, + worktreesRoot: this.dependencies.worktreesRoot, + }, + { + ...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}), + setupContinuation: input.setupContinuation, + }, + ); } registerAutoArchiveIfRequested(input: { @@ -111,47 +125,6 @@ export class CreateAgentLifecycleDispatch { }); } - private async createWorktreeForTarget( - cwd: string, - target: CreateAgentWorktreeTarget, - firstAgentContext: FirstAgentContext, - ): Promise<CreatePaseoWorktreeWorkflowResult> { - const baseInput = { - cwd, - firstAgentContext, - runSetup: false, - paseoHome: this.dependencies.paseoHome, - worktreesRoot: this.dependencies.worktreesRoot, - } as const; - - switch (target.mode) { - case "branch-off": - return this.dependencies.createPaseoWorktreeWorkflow( - { - ...baseInput, - worktreeSlug: target.newBranch, - action: "branch-off", - ...(target.base ? { refName: target.base } : {}), - }, - target.base ? { resolveDefaultBranch: async () => target.base! } : undefined, - ); - case "checkout-branch": - return this.dependencies.createPaseoWorktreeWorkflow({ - ...baseInput, - action: "checkout", - refName: target.branch, - }); - case "checkout-pr": - return this.dependencies.createPaseoWorktreeWorkflow({ - ...baseInput, - action: "checkout", - githubPrNumber: target.prNumber, - }); - default: - throw new Error("Unsupported create_agent_request worktree target"); - } - } - private registerAutoArchiveOnTerminalState( agentId: string, target: AutoArchiveTarget, diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index e8547d27ed..f12309da2a 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -11,6 +11,7 @@ import { AgentStorage } from "../agent-storage.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { createAgentCommand } from "./create.js"; import type { ManagedAgent } from "../agent-manager.js"; +import type { AgentSessionConfig } from "../agent-sdk-types.js"; const logger = createTestLogger(); @@ -211,7 +212,6 @@ test("mcp create accepts provider-only internal input and leaves model undefined workspaceId: "ws-create-test", title: "provider default", initialPrompt: "hello", - background: true, notifyOnFinish: false, }); @@ -329,7 +329,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async provider: "codex/gpt-5.4", title: "child", initialPrompt: "do the thing", - background: true, notifyOnFinish: false, callerAgentId: parent.id, worktree: { worktreeName: "feature", baseBranch: "main" }, @@ -378,7 +377,6 @@ test("mcp create exposes the created worktree before dispatching the initial pro cwd: workdir, title: "worktree callback", initialPrompt: "Say done.", - background: true, notifyOnFinish: false, worktree: { worktreeName: "feature", baseBranch: "main" }, onCreated: ({ agentId, createdWorktree: callbackWorktree }) => { @@ -471,3 +469,47 @@ test("session create keeps an explicit title after the initial prompt settles", await removeRealAgentManagerWorkdir({ agentManager, storage, workdir }); } }); + +test("session create with an internal config skips provider session persistence", async () => { + const snapshot = { + id: "agent-1", + provider: "codex", + cwd: "/tmp/paseo-create-test", + runtimeInfo: null, + } as ManagedAgent; + const createAgent = vi.fn(async () => snapshot); + const dependencies: Parameters<typeof createAgentCommand>[0] = { + agentManager: { + createAgent, + } as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"], + agentStorage: {} as Parameters<typeof createAgentCommand>[0]["agentStorage"], + logger: createTestLogger(), + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + }; + const input = { + kind: "session" as const, + workspaceId: "ws-create-test", + labels: {}, + provisionalTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }), + }; + + await createAgentCommand(dependencies, { + ...input, + config: { provider: "codex", cwd: "/tmp/paseo-create-test", internal: true }, + }); + await createAgentCommand(dependencies, { + ...input, + config: { provider: "codex", cwd: "/tmp/paseo-create-test" }, + }); + + expect(createAgent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ provider: "codex", internal: true }), + undefined, + expect.objectContaining({ persistSession: false }), + ); + expect(createAgent.mock.calls[1]?.[0]).not.toHaveProperty("internal"); + expect(createAgent.mock.calls[1]?.[2]).not.toHaveProperty("persistSession"); +}); diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 814a8f3aec..05f98285e1 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -1,3 +1,4 @@ +import type { AgentPromptSource } from "../agent-messages/index.js"; import type { Logger } from "pino"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; @@ -51,6 +52,7 @@ export interface CreateAgentCommandDependencies { export type EnsureWorkspaceForCreate = ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ) => Promise<string>; export interface CreateAgentFromSessionInput { @@ -61,6 +63,7 @@ export interface CreateAgentFromSessionInput { workspaceId: string; worktreeName?: string; initialPrompt?: string; + source?: AgentPromptSource; clientMessageId?: string; outputSchema?: Record<string, unknown>; images?: Array<{ data: string; mimeType: string }>; @@ -92,7 +95,6 @@ export interface CreateAgentFromMcpInput { mode?: string; unattended?: boolean; promptFailure?: CreateAgentPromptFailureMode; - background: boolean; notifyOnFinish: boolean; internal?: boolean; detached?: boolean; @@ -125,7 +127,6 @@ export type CreateAgentPromptFailureMode = "throw" | "log" | "return-error"; export interface CreateAgentCommandResult { snapshot: ManagedAgent; liveSnapshot: ManagedAgent; - background: boolean; initialPromptStarted: boolean; initialPromptError: unknown | null; createdWorktree?: CreatePaseoWorktreeWorkflowResult; @@ -164,9 +165,9 @@ interface ResolvedCreateAgent { config: AgentSessionConfig; createOptions: CreateAgentOptions; prompt?: AgentPromptInput; + source?: AgentPromptSource; runOptions?: AgentRunOptions; setupContinuation?: AgentWorktreeSetupContinuation; - background: boolean; promptFailure: CreateAgentPromptFailureMode; promptLogger?: Logger; createdWorktree?: CreatePaseoWorktreeWorkflowResult; @@ -187,10 +188,6 @@ export async function createAgentCommand( resolved.createOptions, ); - resolved.setupContinuation?.startAfterAgentCreate({ - agentId: snapshot.id, - }); - if (input.kind === "session") await input.onAgentReady?.(snapshot); let liveSnapshot = snapshot; @@ -199,28 +196,37 @@ export async function createAgentCommand( if (input.kind === "mcp") { input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null }); } + const stopNotification = + input.kind === "mcp" && + input.notifyOnFinish && + input.callerAgentId && + (resolved.prompt !== undefined || resolved.setupContinuation !== undefined) + ? setupFinishNotification({ + agentManager: dependencies.agentManager, + agentStorage: dependencies.agentStorage, + childAgentId: snapshot.id, + callerAgentId: input.callerAgentId, + requireParentOwnership: true, + logger: dependencies.logger, + }) + : undefined; + + resolved.setupContinuation?.startAfterAgentCreate({ + agentId: snapshot.id, + }); if (resolved.prompt !== undefined) { - const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot); + const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => { + stopNotification?.(); + throw error; + }); initialPromptStarted = sendResult.started; liveSnapshot = sendResult.liveSnapshot; initialPromptError = sendResult.error ?? null; } - if (input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && initialPromptStarted) { - setupFinishNotification({ - agentManager: dependencies.agentManager, - agentStorage: dependencies.agentStorage, - childAgentId: snapshot.id, - callerAgentId: input.callerAgentId, - requireParentOwnership: true, - logger: dependencies.logger, - }); - } - return { snapshot, liveSnapshot, - background: resolved.background, initialPromptStarted, initialPromptError, ...(resolved.createdWorktree ? { createdWorktree: resolved.createdWorktree } : {}), @@ -287,16 +293,20 @@ async function resolveSessionCreateAgent( initialPrompt: trimmedPrompt, env: input.env, initialTitle: input.provisionalTitle, + // Paseo never persists an internal agent, so nothing could resume its + // provider session either. Same as the daemon's own ephemeral helpers. + ...(sessionConfig.internal ? { persistSession: false } : {}), // A legacy git/worktreeName worktree creates a fresh workspace, so the // agent belongs to that workspace, not the source one. createdWorkspaceId // is the freshly created worktree's workspace. workspaceId: requireResolvedWorkspaceId(workspaceId), }, prompt: hasPromptContent ? prompt : undefined, + source: input.source, runOptions, setupContinuation, - background: true, - promptFailure: "throw", + // Registration commits creation. First-turn failures belong to the created agent. + promptFailure: "return-error", promptLogger: dependencies.logger.child({ clientMessageId: resolveClientMessageId(input.clientMessageId), }), @@ -319,6 +329,7 @@ async function resolveMcpCreateAgent( cwd, worktree: input.worktree, initialPrompt: input.initialPrompt ?? "", + callerWorkspaceId: parentAgent?.workspaceId, }); if (createdWorktree) input.onWorktreeCreated?.(createdWorktree); @@ -333,7 +344,12 @@ async function resolveMcpCreateAgent( resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: resolvedCwd }), createWorkspace: async () => ({ workspaceId: requireResolvedWorkspaceId( - await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt ?? ""), + await ensureWorkspaceForMcpCreate( + dependencies, + resolvedCwd, + input.initialPrompt ?? "", + parentAgent?.workspaceId, + ), ), cwd: resolvedCwd, }), @@ -364,9 +380,11 @@ async function resolveMcpCreateAgent( env: input.env, }, prompt: trimmedPrompt ? trimmedPrompt : undefined, + source: input.callerAgentId + ? { kind: "agent-message", agentId: input.callerAgentId } + : undefined, setupContinuation, createdWorktree, - background: input.background, promptFailure: input.promptFailure ?? "log", }; } @@ -441,11 +459,16 @@ async function ensureWorkspaceForMcpCreate( dependencies: CreateAgentCommandDependencies, cwd: string, initialPrompt: string, + callerWorkspaceId?: string, ): Promise<string | undefined> { if (!dependencies.ensureWorkspaceForCreate) { return undefined; } - return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }); + return dependencies.ensureWorkspaceForCreate( + cwd, + { prompt: initialPrompt }, + { callerWorkspaceId }, + ); } async function sendInitialPrompt( @@ -459,10 +482,12 @@ async function sendInitialPrompt( return { started: false, liveSnapshot: snapshot }; } const liveSnapshot = await startCreatedAgentInitialPrompt({ + agentStorage: dependencies.agentStorage, agentManager: dependencies.agentManager, agentId: snapshot.id, snapshot, prompt, + source: resolved.source, runOptions: resolved.runOptions, logger: resolved.promptLogger ?? dependencies.logger, }); @@ -472,7 +497,11 @@ async function sendInitialPrompt( throw error; } if (resolved.promptFailure === "return-error") { - return { started: false, liveSnapshot: snapshot, error }; + return { + started: false, + liveSnapshot: dependencies.agentManager.getAgent(snapshot.id) ?? snapshot, + error, + }; } dependencies.logger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); return { started: false, liveSnapshot: snapshot }; @@ -510,6 +539,7 @@ async function resolveMcpCwd(params: { dependencies: CreateAgentCommandDependencies; cwd: string; initialPrompt: string; + callerWorkspaceId?: string; worktree: CreateAgentFromMcpInput["worktree"]; }): Promise<{ resolvedCwd: string; @@ -540,6 +570,7 @@ async function resolveMcpCwd(params: { const createdWorktree = await createMcpWorktree({ input: { cwd: params.cwd, + callerWorkspaceId: params.callerWorkspaceId, worktreeSlug: worktree.worktreeName, branchName: worktree.branchName, refName: worktree.refName, diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts new file mode 100644 index 0000000000..2f51d4a4c1 --- /dev/null +++ b/packages/server/src/server/agent/directory.ts @@ -0,0 +1,65 @@ +import type { AgentSnapshotPayload } from "../messages.js"; +import type { WorkspaceRegistry } from "../workspace-registry.js"; +import type { AgentManager, ManagedAgent } from "./agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; + +/** Public agent discovery owns workspace visibility for live and durable records. */ +export class AgentDirectory { + constructor( + private readonly deps: { + manager: AgentManager; + storage: AgentStorage; + workspaces: Pick<WorkspaceRegistry, "get" | "list">; + projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>; + projectStored(record: StoredAgentRecord): AgentSnapshotPayload; + isProviderVisible(provider: string): boolean; + isStoredProviderAvailable(record: StoredAgentRecord): boolean; + }, + ) {} + + async includes( + agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">, + includeBackground = false, + ): Promise<boolean> { + if (agent.internal) return false; + if (includeBackground || !agent.workspaceId) return true; + return !(await this.deps.workspaces.get(agent.workspaceId))?.background; + } + + async list(filter?: { + labels?: Record<string, string>; + includeArchived?: boolean; + includeBackground?: boolean; + includeUnavailablePersisted?: boolean; + }): Promise<AgentSnapshotPayload[]> { + const live = this.deps.manager.listAgents(); + const liveIds = new Set(live.map((agent) => agent.id)); + const labels = Object.entries(filter?.labels ?? {}); + const backgroundIds = new Set( + filter?.includeBackground + ? [] + : (await this.deps.workspaces.list()) + .filter((workspace) => workspace.background) + .map((workspace) => workspace.workspaceId), + ); + const matches = (agent: { + archivedAt?: string | null; + workspaceId?: string; + labels?: Record<string, string>; + }) => + (filter?.includeArchived || !agent.archivedAt) && + (filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) && + labels.every(([key, value]) => agent.labels?.[key] === value); + const stored = (await this.deps.storage.list()) + .filter((record) => !record.internal && !liveIds.has(record.id) && matches(record)) + .filter( + (record) => + filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record), + ) + .map((record) => this.deps.projectStored(record)); + const active = await Promise.all( + live.filter(matches).map((agent) => this.deps.projectLive(agent)), + ); + return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider)); + } +} diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts index 6e5714d489..763e676f43 100644 --- a/packages/server/src/server/agent/lifecycle-command.test.ts +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -219,6 +219,25 @@ describe("agent lifecycle commands", () => { expect(manager.archivedAgentIds).toEqual(["agent-1"]); }); + test("archives a live internal agent without touching storage", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + manager.liveAgents.set("agent-1", { ...managedAgent("agent-1", "idle"), internal: true }); + + const result = await archiveAgentCommand( + { agentManager: manager, agentStorage: storage, logger }, + "agent-1", + ); + + expect(result).toEqual({ + agentId: "agent-1", + archivedAt: "2026-05-10T10:00:00.000Z", + record: null, + }); + expect(manager.archivedAgentIds).toEqual(["agent-1"]); + expect(storage.upserts).toEqual([]); + }); + test("archives a live agent when its graceful cancellation is rejected", async () => { const storage = new FakeLifecycleAgentStorage(); const manager = new FakeLifecycleAgentManager(storage); diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index cb172ce620..431cff0fe6 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -8,7 +8,7 @@ import { import type { StoredAgentRecord } from "./agent-storage.js"; import type { AgentProviderNotice } from "./agent-sdk-types.js"; -export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle">; +export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle" | "internal">; export interface LifecycleAgentManager { getAgent(agentId: string): LifecycleAgentSnapshot | null; @@ -112,7 +112,8 @@ export async function cancelAgentRunCommand( export interface ArchiveAgentResult { agentId: string; archivedAt: string; - record: StoredAgentRecord; + /** Null for an internal agent, which is closed and leaves no record behind. */ + record: StoredAgentRecord | null; } export async function archiveAgentCommand( @@ -124,7 +125,10 @@ export async function archiveAgentCommand( if (liveAgent) { await requestAgentRunCancellation(dependencies, agentId); await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined); - await dependencies.agentManager.archiveAgent(agentId); + const archived = await dependencies.agentManager.archiveAgent(agentId); + if (liveAgent.internal) { + return { agentId, archivedAt: archived.archivedAt, record: null }; + } record = await dependencies.agentStorage.get(agentId); } else { record = await archiveStoredAgent(dependencies, agentId); diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 50867f10d9..bfb6be7964 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -12,6 +12,8 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import type { AgentClient, AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { parseAgentMessage } from "./agent-messages/index.js"; interface StructuredContent { [key: string]: unknown; @@ -215,7 +217,6 @@ async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<s provider: "claude/claude-test-model", initialPrompt: "say done and stop", settings: { modeId: "bypassPermissions" }, - background: true, ...rest, }); return str(payload.agentId); @@ -267,28 +268,6 @@ async function killTerminalIfPresent(terminalId: string | null | undefined): Pro } } -async function archiveWorktreeIfPresent(params: { - cwd: string; - worktreePath?: string | null; - worktreeSlug?: string | null; -}): Promise<void> { - if (!params.worktreePath && !params.worktreeSlug) { - return; - } - try { - await topLevelClient.callTool({ - name: "archive_worktree", - args: { - cwd: params.cwd, - ...(params.worktreePath ? { worktreePath: params.worktreePath } : {}), - ...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}), - }, - }); - } catch { - // ignore cleanup errors - } -} - beforeAll(async () => { tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-")); parentAgentCwd = await makeCwd("parent-agent-cwd"); @@ -307,7 +286,6 @@ beforeAll(async () => { provider: "claude/claude-test-model", initialPrompt: "say done and stop", settings: { modeId: "bypassPermissions" }, - background: true, }); parentAgentId = str(parentPayload.agentId); @@ -335,6 +313,167 @@ afterAll(async () => { }); describe("Suite A: Core Fixes", () => { + test.each([ + ["Stop", "cancelAgent"], + ["Reload", "refreshAgent"], + ] as const)( + "%s tells the parent that a watched child was canceled", + async (_action, method) => { + let releaseChild!: () => void; + let childReachedGate!: () => void; + const heldTurn = new Promise<void>((resolve) => { + releaseChild = resolve; + }); + const childAtGate = new Promise<void>((resolve) => { + childReachedGate = resolve; + }); + const parentPrompts: string[] = []; + const daemon = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + onStartTurn(prompt, config) { + if (config.title === "Watching parent" && typeof prompt === "string") + parentPrompts.push(prompt); + }, + async beforeTurnComplete(prompt) { + if ( + typeof prompt === "string" && + parseAgentMessage(prompt).text === "Hold this child turn" + ) { + childReachedGate(); + await heldTurn; + } + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.2", + }); + let mcp: McpClient | undefined; + let unsubscribe: (() => void) | undefined; + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const parent = await client.createAgent({ + provider: "claude", + cwd: daemon.paseoHome, + title: "Watching parent", + modeId: "bypassPermissions", + }); + mcp = await createMcpClient( + buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: parent.id }), + ); + const created = await callToolStructured(mcp, "create_agent", { + relationship: { kind: "subagent" }, + workspace: { kind: "current" }, + title: "Watched child", + provider: "codex/gpt-5.4", + initialPrompt: "Hold this child turn", + notifyOnFinish: true, + settings: { modeId: "full-access" }, + }); + const childId = str(created.agentId); + await childAtGate; + expect(daemon.daemon.agentManager.getAgent(childId)?.lifecycle).toBe("running"); + const terminalEvents: string[] = []; + unsubscribe = daemon.daemon.agentManager.subscribe( + (event) => { + if ( + event.type === "agent_stream" && + event.agentId === childId && + (event.event.type === "turn_completed" || event.event.type === "turn_canceled") + ) + terminalEvents.push(event.event.type); + }, + { agentId: childId, replayState: false }, + ); + + await client[method](childId); + await waitFor({ + timeoutMs: 10000, + label: "parent cancellation notification", + check: () => (parentPrompts.length === 1 ? true : null), + }); + const notification = parseAgentMessage(parentPrompts[0]); + expect(terminalEvents).toEqual(["turn_canceled"]); + expect(notification.source).toEqual({ + kind: "agent-notification", + agentId: childId, + title: "Watched child", + event: "errored", + }); + expect(notification.text).toContain(`Agent ${childId} (Watched child) was canceled.`); + expect(notification.text).not.toContain("finished."); + await waitFor({ + timeoutMs: 10000, + label: "parent notification turn drained", + check: () => + daemon.daemon.agentManager.getAgent(parent.id)?.lifecycle === "idle" ? true : null, + }); + expect(parentPrompts).toHaveLength(1); + } finally { + releaseChild(); + unsubscribe?.(); + await mcp?.close(); + await client.close(); + await daemon.close(); + } + }, + 30000, + ); + + test.each([ + { outcome: "finished", prompt: "say done and stop", status: "idle" }, + { outcome: "errored", prompt: "Emit a turn failure", status: "error" }, + ])( + "a watched child still reports $outcome after send_agent_prompt", + async ({ outcome, prompt, status }) => { + const childId = await createChildAgent({ + title: "Follow-up child", + settings: { modeId: "bypassPermissions" }, + }); + try { + await waitFor({ + timeoutMs: 10000, + label: "initial child turn completed", + check: () => + daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null, + }); + await callToolStructured(agentScopedClient, "send_agent_prompt", { + agentId: childId, + prompt, + notifyOnFinish: true, + }); + const notifications = await waitFor({ + timeoutMs: 10000, + label: `${outcome} notification`, + check: () => { + const items = daemonHandle.daemon.agentManager + .getTimeline(parentAgentId) + .filter( + (item) => item.type === "tool_call" && item.agentMessage?.sender.id === childId, + ); + return items.length > 0 ? items : null; + }, + }); + expect(notifications).toEqual([ + expect.objectContaining({ + type: "tool_call", + agentMessage: { + event: outcome, + sender: { id: childId, title: "Follow-up child" }, + text: expect.stringContaining(`(Follow-up child) ${outcome}.`), + }, + }), + ]); + expect(daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle).toBe(status); + } finally { + await archiveAgentIfPresent(childId); + } + }, + 30000, + ); + test("AGENT_WAIT_TIMEOUT_MS is 30000", () => { expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000); }); @@ -845,117 +984,107 @@ describe("Suite D: Provider Tools", () => { }); }); -describe("Suite E: Worktree Tools", () => { - test("list_worktrees on empty repo", async () => { - const payload = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, +describe("Suite E: Workspace Tools", () => { + test("background worktree creation, discovery and archive use ordinary workspace ownership", async () => { + const created = await callToolStructured(topLevelClient, "create_workspace", { + isolation: "worktree", + path: worktreeRepoCwd, + baseBranch: "main", + branchName: `parity-background-${Date.now()}`, + background: true, }); - expect(payload.worktrees).toEqual([]); - }); - - test("create_worktree and list_worktrees", async () => { - let worktreePath: string | null = null; - const branchName = `parity-create-${Date.now()}`; + const workspaceId = str(created.workspaceId); try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - target: { - kind: "branch-off", - worktreeSlug: branchName, - baseBranch: "main", - }, + expect(created.background).toBe(true); + const ordinary = await callToolStructured(topLevelClient, "list_workspaces", {}); + expect( + recordArr(ordinary.workspaces).map((workspace) => workspace.workspaceId), + ).not.toContain(workspaceId); + const inclusive = await callToolStructured(topLevelClient, "list_workspaces", { + includeBackground: true, }); - worktreePath = str(created.worktreePath); - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, - }); - const worktrees = recordArr(listed.worktrees); - expect(worktrees).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: worktreePath, - branchName, - }), - ]), + expect(recordArr(inclusive.workspaces)).toContainEqual( + expect.objectContaining({ workspaceId, background: true }), ); + const child = await callToolStructured(topLevelClient, "create_agent", { + workspaceId, + title: "Background worker", + provider: "claude/claude-test-model", + initialPrompt: "Say done.", + }); + const agentId = str(child.agentId); + const workerClient = await createMcpClient( + `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${agentId}`, + ); + let terminalId: string; + try { + const terminal = await callToolStructured(workerClient, "create_terminal", { + name: "Background terminal", + }); + terminalId = str(terminal.id); + expect(terminal.cwd).toBe(created.cwd); + } finally { + await workerClient.close(); + } + const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId }); + expect((status.snapshot as StructuredContent).workspaceId).toBe(workspaceId); + const archived = await callToolStructured(topLevelClient, "archive_workspace", { + workspaceId, + }); + expect(archived.archivedAgentIds).toContain(agentId); + const terminals = await callToolStructured(topLevelClient, "list_terminals", { all: true }); + expect(recordArr(terminals.terminals).map((terminal) => terminal.id)).not.toContain( + terminalId, + ); + const remaining = await callToolStructured(topLevelClient, "list_workspaces", { + includeBackground: true, + }); + expect( + recordArr(remaining.workspaces).map((workspace) => workspace.workspaceId), + ).not.toContain(workspaceId); } finally { - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + await topLevelClient + .callTool({ name: "archive_workspace", args: { workspaceId } }) + .catch(() => undefined); } }); - test("archive_worktree removes worktree", async () => { - let worktreePath: string | null = null; - const branchName = `parity-archive-${Date.now()}`; + test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => { + const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", { + isolation: "local", + path: worktreeRepoCwd, + background: true, + }); + const parentWorkspaceId = str(parentWorkspace.workspaceId); + let scoped: McpClient | null = null; + const ownedWorkspaces: string[] = [parentWorkspaceId]; try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - target: { - kind: "branch-off", - worktreeSlug: branchName, - baseBranch: "main", - }, + const parent = await callToolStructured(topLevelClient, "create_agent", { + workspaceId: parentWorkspaceId, + title: "Background parent", + provider: "claude/claude-test-model", + initialPrompt: "Say done.", }); - worktreePath = str(created.worktreePath); - - await callToolStructured(topLevelClient, "archive_worktree", { - cwd: worktreeRepoCwd, - worktreePath, - }); - worktreePath = null; - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, - }); - const worktrees = recordArr(listed.worktrees); - expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false); - } finally { - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); - } - }); - - test("archive_worktree succeeds when caller cwd is inside the archived worktree", async () => { - let worktreePath: string | null = null; - let worktreeAgentId: string | null = null; - let worktreeScopedClient: McpClient | null = null; - const branchName = `parity-archive-self-cwd-${Date.now()}`; - - try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - target: { - kind: "branch-off", - worktreeSlug: branchName, - baseBranch: "main", - }, - }); - worktreePath = str(created.worktreePath); - worktreeAgentId = await createTopLevelAgent({ - cwd: worktreePath, - title: "Worktree scoped parity agent", - }); - worktreeScopedClient = await createMcpClient( - `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${encodeURIComponent( - worktreeAgentId, - )}`, + scoped = await createMcpClient( + `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${str(parent.agentId)}`, ); - - const archived = await callToolStructured(worktreeScopedClient, "archive_worktree", { - worktreePath, + const inherited = await callToolStructured(scoped, "create_workspace", { + isolation: "local", }); - expect(archived).toEqual({ success: true }); - worktreePath = null; - worktreeAgentId = null; - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, + ownedWorkspaces.push(str(inherited.workspaceId)); + expect(inherited.background).toBe(true); + const visible = await callToolStructured(scoped, "create_workspace", { + isolation: "local", + background: false, }); - const worktrees = recordArr(listed.worktrees); - expect(worktrees.map((worktree) => worktree.path)).not.toContain(created.worktreePath); + ownedWorkspaces.push(str(visible.workspaceId)); + expect(visible.background).toBe(false); } finally { - await worktreeScopedClient?.close(); - await archiveAgentIfPresent(worktreeAgentId); - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + await scoped?.close(); + for (const workspaceId of ownedWorkspaces.toReversed()) + await topLevelClient + .callTool({ name: "archive_workspace", args: { workspaceId } }) + .catch(() => undefined); } }); }); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ad2c4babd8..de0099cecf 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,3 +1,4 @@ +import { parseAgentMessage } from "./agent-messages/index.js"; import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; @@ -10,7 +11,7 @@ import { tmpdir } from "node:os"; import { z } from "zod"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { createAgentMcpServer } from "./mcp-server.js"; +import { createAgentMcpServer as createAgentMcpServerWithDependencies } from "./mcp-server.js"; import { AgentManager, type ManagedAgent } from "./agent-manager.js"; import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; @@ -832,6 +833,14 @@ function createPaseoWorktreeForMcpTest(options: { }; } +// Typed in-memory workspace port for catalogue tests without workspace setup. +function createAgentMcpServer(options: Parameters<typeof createAgentMcpServerWithDependencies>[0]) { + return createAgentMcpServerWithDependencies({ + workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} }, + ...options, + }); +} + describe("browser MCP tools", () => { const logger = createTestLogger(); @@ -1326,10 +1335,13 @@ describe("create_agent MCP tool", () => { title: "Top-level agent", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); - expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" }); + expect(ensureWorkspace).toHaveBeenCalledWith( + existingCwd, + { prompt: "Do work" }, + { callerWorkspaceId: undefined }, + ); expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ cwd: existingCwd }), undefined, @@ -1337,6 +1349,32 @@ describe("create_agent MCP tool", () => { ); }); + it("rejects public internal helpers and the old top-level execution background option", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const agentManager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + }); + try { + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "create_agent"); + const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" }; + await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/); + await expect(tool.handler({ ...input, background: true })).rejects.toThrow( + /Unrecognized key/, + ); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }); + it("rejects partial explicit workspace shape", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ @@ -1394,6 +1432,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, provider: "codex", currentModeId: "full-access", @@ -1444,7 +1483,6 @@ describe("create_agent MCP tool", () => { title: "Existing workspace", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); expect(response.structuredContent.workspaceId).toBe("wks_existing"); @@ -1481,7 +1519,7 @@ describe("create_agent MCP tool", () => { title: "Feature test", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, + settings: { features: { fast_mode: true } }, }; @@ -1534,7 +1572,6 @@ describe("create_agent MCP tool", () => { title: "Mode test", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); expect(response.structuredContent).toEqual( @@ -1860,7 +1897,6 @@ describe("create_agent MCP tool", () => { title: "Worktree agent", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); expect(broadcasts).toHaveLength(1); @@ -1948,7 +1984,6 @@ describe("create_agent MCP tool", () => { title: "Worktree agent", provider: "codex/gpt-5.4", initialPrompt: "Fix workspace creation naming", - background: true, }); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); @@ -2029,7 +2064,6 @@ describe("create_agent MCP tool", () => { title: "Agent title", provider: "codex/gpt-5.4", initialPrompt: "Build a workspace auto title flow", - background: true, }); const workspaceId = z.string().parse(createdWorkspaceIds[0]); await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow"); @@ -2131,7 +2165,6 @@ describe("create_agent MCP tool", () => { title: "Agent title", provider: "codex/gpt-5.4", initialPrompt: "Keep the manually renamed workspace title", - background: true, }); const renameWorkspaceTool = registeredTool(server, "rename_workspace"); await renameWorkspaceTool.handler({ @@ -2221,7 +2254,6 @@ describe("create_agent MCP tool", () => { title: "Explicit Agent Title", provider: "codex/gpt-5.4", initialPrompt: "Generate the workspace title anyway", - background: true, }); const workspaceId = z.string().parse(createdWorkspaceIds[0]); await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title"); @@ -2328,7 +2360,6 @@ describe("create_agent MCP tool", () => { title: "Directory agent", provider: "codex/gpt-5.4", initialPrompt: "Name a directory workspace from the prompt", - background: true, }); await waitForWorkspaceTitle( workspaceRecords, @@ -2435,7 +2466,6 @@ describe("create_agent MCP tool", () => { title: "Checkout agent", provider: "codex/gpt-5.4", initialPrompt: "Rename this checkout from the prompt", - background: true, }); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); @@ -2456,7 +2486,7 @@ describe("create_agent MCP tool", () => { }); expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({ version: 1, - baseRefName: "existing-feature", + baseRefName: "main", }); expect(generateCalls).toBe(1); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); @@ -2542,7 +2572,6 @@ describe("create_agent MCP tool", () => { title: "PR agent", provider: "codex/gpt-5.4", initialPrompt: "Rename this PR branch from prompt", - background: true, }); expect(createPaseoWorktree).toHaveBeenCalledWith( @@ -3054,6 +3083,47 @@ describe("create_agent MCP tool", () => { ]); }); + it("creates an background local workspace and leaves it out of list_workspaces", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const createCalls: Array<{ cwd: string; context: { background?: boolean } | undefined }> = []; + const backgroundWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-background", + projectId: "project-1", + cwd: process.cwd(), + kind: "directory", + displayName: "helper", + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:00:00.000Z", + background: true, + }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + workspaceRegistry: { + get: vi.fn(async () => backgroundWorkspace), + list: vi.fn(async () => [backgroundWorkspace]), + upsert: vi.fn(async () => undefined), + }, + createDirectoryWorkspace: async (cwd, _title, _projectId, context) => { + createCalls.push({ cwd, context }); + return backgroundWorkspace; + }, + logger, + }); + + const created = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), { + isolation: "local", + path: process.cwd(), + background: true, + }); + expect(created.structuredContent.workspaceId).toBe("ws-background"); + expect(createCalls).toEqual([{ cwd: process.cwd(), context: { background: true } }]); + + const listed = await registeredTool(server, "list_workspaces").handler({}); + expect(listed.structuredContent.workspaces).toEqual([]); + }); + it("accepts custom provider IDs in create_agent input validation", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ @@ -3082,6 +3152,7 @@ describe("create_agent MCP tool", () => { await mkdir(subdir, { recursive: true }); spies.agentManager.getAgent.mockReturnValue({ id: "voice-agent", + config: { title: "Voice" }, cwd: baseDir, workspaceId: "wks_voice", provider: "codex", @@ -3136,6 +3207,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3182,6 +3254,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3223,10 +3296,55 @@ describe("create_agent MCP tool", () => { ); }); + it("notifies the caller when a created child finishes its initial turn before the turn start returns", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-create-fast-child-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", false, { finishTurnsDuringStart: true }); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + + const response = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), { + ...subagentCurrentWorkspace(), + title: "Fast Child", + provider: "codex/gpt-5.4", + initialPrompt: "Finish immediately", + }); + const childId = z.object({ agentId: z.string() }).parse(response.structuredContent).agentId; + + await vi.waitFor(() => { + const parentPrompts = parentClient.sessions[0]!.prompts; + expect(parentPrompts).toHaveLength(1); + expect(parentPrompts[0]).toContain(childId); + expect(parentPrompts[0]).toContain("finished"); + }); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }); + it("creates detached caller agents without a parent label", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3279,6 +3397,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "claude", @@ -3593,6 +3712,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "claude", @@ -3645,6 +3765,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "claude", @@ -3693,6 +3814,11 @@ const HELD_TURN_CAPABILITIES = { supportsToolInvocations: false, } as const; +interface HeldTurnOptions { + /** Run the whole turn before the turn-start request returns. */ + finishTurnsDuringStart?: boolean; +} + /** * Provider session that records every prompt it receives. With `holdTurns`, a started * turn stays running until `finishTurn()` so a caller's bounded wait can run out first. @@ -3704,10 +3830,12 @@ class HeldTurnAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private activeTurnId: string | null = null; private turnStartGate: Promise<void> | null = null; + private nextStartError: Error | null = null; constructor( readonly provider: AgentProvider, private readonly holdTurns: boolean, + private readonly options: HeldTurnOptions = {}, ) {} /** Hold the next turn's acknowledgment, as a provider awaiting its turn-start request does. */ @@ -3723,12 +3851,44 @@ class HeldTurnAgentSession implements AgentSession { return { sessionId: this.id, finalText: "", timeline: [] }; } + rejectNextTurnStart(error: Error): void { + this.nextStartError = error; + } + + tryHandleOutOfBand(prompt: AgentPromptInput) { + const text = typeof prompt === "string" ? (parseAgentMessage(prompt)?.text ?? prompt) : null; + return text === "/side-command" ? { run: async () => {} } : null; + } + async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt)); await this.turnStartGate; + if (this.nextStartError) { + const error = this.nextStartError; + this.nextStartError = null; + throw error; + } const turnId = randomUUID(); + if (this.options.finishTurnsDuringStart) { + // The whole turn runs before the turn-start request returns, as a provider whose + // start acknowledgment arrives together with the turn's completion does. + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + return { turnId }; + } this.activeTurnId = turnId; + this.pushEvent({ + type: "timeline", + provider: this.provider, + turnId, + item: { + type: "user_message", + text: typeof prompt === "string" ? prompt : JSON.stringify(prompt), + messageId: randomUUID(), + }, + }); setTimeout(() => { + if (this.activeTurnId !== turnId) return; this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); if (!this.holdTurns && this.activeTurnId === turnId) { this.finishTurn(); @@ -3737,15 +3897,29 @@ class HeldTurnAgentSession implements AgentSession { return { turnId }; } - finishTurn(): void { + finishTurn(finalText?: string): void { const turnId = this.activeTurnId; if (!turnId) { throw new Error("No held turn to finish"); } this.activeTurnId = null; + if (finalText) + this.pushEvent({ + type: "timeline", + provider: this.provider, + turnId, + item: { type: "assistant_message", text: finalText }, + }); this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); } + failTurn(error: string): void { + const turnId = this.activeTurnId; + if (!turnId) throw new Error("No held turn to fail"); + this.activeTurnId = null; + this.pushEvent({ type: "turn_failed", provider: this.provider, error, turnId }); + } + subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.subscribers.add(callback); return () => { @@ -3809,6 +3983,7 @@ class HeldTurnAgentClient implements AgentClient { constructor( readonly provider: AgentProvider, private readonly holdTurns: boolean, + private readonly options: HeldTurnOptions = {}, ) {} async isAvailable(): Promise<boolean> { @@ -3816,7 +3991,7 @@ class HeldTurnAgentClient implements AgentClient { } async createSession(): Promise<AgentSession> { - const session = new HeldTurnAgentSession(this.provider, this.holdTurns); + const session = new HeldTurnAgentSession(this.provider, this.holdTurns, this.options); this.sessions.push(session); return session; } @@ -3838,6 +4013,7 @@ describe("send_agent_prompt MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3875,7 +4051,6 @@ describe("send_agent_prompt MCP tool", () => { throw new Error("Expected caller send_agent_prompt input to parse"); } expect(parsed.data).toMatchObject({ - background: true, notifyOnFinish: true, }); @@ -3888,7 +4063,7 @@ describe("send_agent_prompt MCP tool", () => { ); }); - it("keeps top-level prompts blocking by default", async () => { + it("acknowledges top-level prompts without a completion wait", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "child-agent", @@ -3916,23 +4091,20 @@ describe("send_agent_prompt MCP tool", () => { throw new Error("Expected top-level send_agent_prompt input to parse"); } expect(parsed.data).toMatchObject({ - background: false, notifyOnFinish: false, }); await tool.handler(parsed.data as Record<string, unknown>); expect(spies.agentManager.subscribe).not.toHaveBeenCalled(); - expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith( - "child-agent", - expect.objectContaining({ waitForActive: true }), - ); + expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); }); - it("does not arm a finish notification for blocking agent-scoped prompts", async () => { + it("allows agent-scoped callers to opt out of finish notifications", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3964,17 +4136,175 @@ describe("send_agent_prompt MCP tool", () => { await invokeToolWithParsedInput(tool, { agentId: "child-agent", prompt: "Follow up", - background: false, + notifyOnFinish: false, }); expect(spies.agentManager.subscribe).not.toHaveBeenCalled(); - expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith( - "child-agent", - expect.objectContaining({ waitForActive: true }), - ); + expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); }); - it("notifies the caller when a blocking agent-scoped prompt outlasts the wait", async () => { + it.each([ + { immediateFinish: false, failure: false }, + { immediateFinish: true, failure: false }, + { immediateFinish: false, failure: true }, + ])( + "notifies the retry outcome rather than the prior failed turn (immediate: $immediateFinish, failure: $failure)", + async ({ immediateFinish, failure }) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-retry-notification-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd, title: "Retry child" }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Old task", + notifyOnFinish: false, + }); + childClient.sessions[0]!.failTurn("Old task failed"); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("error")); + const unsubscribe = agentManager.subscribe( + (event) => { + if ( + immediateFinish && + event.type === "agent_state" && + event.agent.lifecycle === "running" + ) { + childClient.sessions[0]!.finishTurn("Retry done"); + } + }, + { agentId: child.id, replayState: false }, + ); + const response = await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Retry now", + }); + unsubscribe(); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "turn_started", + }); + if (!immediateFinish) { + expect(response.structuredContent.status).toBe("running"); + if (failure) childClient.sessions[0]!.failTurn("Retry failed"); + else childClient.sessions[0]!.finishTurn("Retry done"); + } + await vi.waitFor(() => { + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + expect(parentClient.sessions[0]!.prompts[0]).toContain( + `Agent ${child.id} (Retry child) ${failure ? "errored" : "finished"}.`, + ); + expect(parentClient.sessions[0]!.prompts[0]).not.toContain("Old task failed"); + }); + await vi.waitFor(() => expect(agentManager.getAgent(parent.id)?.lifecycle).toBe("idle")); + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }, + ); + + it.each(["out_of_band", "startup_failure"] as const)( + "preserves %s dispatch notification semantics", + async (scenario) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Ongoing task", + notifyOnFinish: false, + }); + if (scenario === "out_of_band") { + const response = await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "/side-command", + }); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "out_of_band", + status: "running", + }); + expect(childClient.sessions[0]!.prompts).toHaveLength(1); + expect(parseAgentMessage(childClient.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-message", agentId: parent.id }, + text: "Ongoing task", + }); + expect(parentClient.sessions[0]!.prompts).toHaveLength(0); + childClient.sessions[0]!.finishTurn("Ongoing task done"); + await vi.waitFor(() => { + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + expect(parentClient.sessions[0]!.prompts[0]).toContain("finished."); + }); + } else { + childClient.sessions[0]!.rejectNextTurnStart(new Error("Requested turn could not start")); + await expect( + invokeToolWithParsedInput(tool, { agentId: child.id, prompt: "Replacement task" }), + ).rejects.toThrow("Requested turn could not start"); + // A later real turn must not inherit a subscription from the rejected dispatch. + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Recovery", + notifyOnFinish: false, + }); + childClient.sessions[0]!.finishTurn("Recovered"); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle")); + expect(parentClient.sessions[0]!.prompts).toHaveLength(0); + } + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }, + ); + + it("acknowledges a pending agent-scoped turn and notifies its completion", async () => { const workdir = await mkdtemp(join(tmpdir(), "mcp-blocking-send-timeout-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const parentClient = new HeldTurnAgentClient("claude", false); @@ -4005,24 +4335,19 @@ describe("send_agent_prompt MCP tool", () => { }); const tool = registeredTool(server, "send_agent_prompt"); - vi.useFakeTimers(); const pending = invokeToolWithParsedInput(tool, { agentId: child.id, prompt: "Follow up", - background: false, }); - await vi.advanceTimersByTimeAsync(31_000); const response = await pending; expect(response.structuredContent).toMatchObject({ status: "running", guidance: "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", }); - expect(response.structuredContent.lastMessage).toContain("timed out after 30s"); + expect(response.structuredContent).not.toHaveProperty("lastMessage"); childClient.sessions[0]!.finishTurn(); - await vi.advanceTimersByTimeAsync(1_000); - vi.useRealTimers(); await vi.waitFor(() => { const parentPrompts = parentClient.sessions[0]!.prompts; @@ -4081,6 +4406,7 @@ describe("send_agent_prompt MCP tool", () => { const childSession = childClient.sessions[0]!; await vi.waitFor(() => expect(childSession.prompts).toHaveLength(2)); + expect(parentClient.sessions[0]!.prompts).toHaveLength(0); childSession.finishTurn(); function finishNotifications() { @@ -4091,6 +4417,10 @@ describe("send_agent_prompt MCP tool", () => { await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0)); await new Promise((resolve) => setTimeout(resolve, 100)); expect(finishNotifications()).toHaveLength(1); + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + expect(parseAgentMessage(parentClient.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-notification", agentId: childId, event: "finished" }, + }); } finally { await removeAgentStateDir(agentManager, storage, workdir); } @@ -4133,7 +4463,12 @@ describe("send_agent_prompt MCP tool", () => { agentId: child.id, prompt: "Follow up", }); - await vi.waitFor(() => expect(childSession.prompts).toEqual(["Follow up"])); + await vi.waitFor(() => expect(childSession.prompts).toHaveLength(1)); + expect(parseAgentMessage(childSession.prompts[0])).toEqual({ + id: expect.any(String), + source: { kind: "agent-message", agentId: parent.id }, + text: "Follow up", + }); acknowledgeTurnStart(); const response = await pending; @@ -4205,7 +4540,11 @@ describe("send_agent_prompt MCP tool", () => { }, ); unsubscribe(); - expect(response.structuredContent).toMatchObject({ success: true, status: "idle" }); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "turn_started", + }); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle")); await vi.waitFor(() => { const parentPrompts = parentClient.sessions[0]!.prompts; expect(parentPrompts).toHaveLength(1); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index bd22d37df3..2698a3b2ac 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -492,6 +492,7 @@ function wrapClientProvider( const listFeatures = inner.listFeatures?.bind(inner); const archiveNativeSession = inner.archiveNativeSession?.bind(inner); const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner); + const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id); return { provider, @@ -509,7 +510,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), resumeSession: async (handle, overrides, launchContext, options) => @@ -526,7 +527,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), fetchCatalog: async (options, context) => { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index f847745d69..33c9696900 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1,9 +1,11 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AgentSideConnection, ClientSideConnection, @@ -52,8 +54,10 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentCapabilityFlags, AgentPersistenceHandle, + AgentSession, ProviderRefreshContext, } from "../agent-sdk-types.js"; +import { buildProviderRegistry } from "../provider-registry.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js"; import { asInternals } from "../../test-utils/class-mocks.js"; @@ -3436,6 +3440,8 @@ interface ACPCloseInternals { child: ChildProcess | null; connection: unknown; sessionId: string | null; + activeForegroundTurnId: string | null; + agentCapabilities: { sessionCapabilities?: { close?: unknown } } | null; } async function startTerminal( @@ -3502,6 +3508,38 @@ describe("ACPAgentSession close() tree-kill", () => { expect(child.kill).not.toHaveBeenCalled(); }); + test("close() terminates the provider when cancel and closeSession never settle", async () => { + vi.useFakeTimers(); + try { + const terminator = new FakeTerminator(); + const session = createSession({ terminateProcess: terminator.terminate }); + const child = createTerminalChildStub(); + const cancel = vi.fn(() => new Promise<void>(() => undefined)); + const unstableCloseSession = vi.fn(() => new Promise<void>(() => undefined)); + const internals = asInternals<ACPCloseInternals>(session); + internals.child = child; + internals.sessionId = "session-1"; + internals.activeForegroundTurnId = "turn-1"; + internals.agentCapabilities = { sessionCapabilities: { close: {} } }; + internals.connection = { cancel, unstable_closeSession: unstableCloseSession }; + + let settled = false; + const closing = (async () => { + await session.close(); + settled = true; + })(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(settled).toBe(true); + expect(cancel).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(unstableCloseSession).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(terminator.terminated).toContain(child); + await closing; + } finally { + vi.useRealTimers(); + } + }); + test("close() terminates running terminal child processes", async () => { const terminator = new FakeTerminator(); const session = createSession({ terminateProcess: terminator.terminate }); @@ -4220,3 +4258,265 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () = }); }); }); + +const SILENT_CLOSE_ACP_AGENT = ` +import { readFileSync, writeFileSync } from "node:fs"; +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +writeFileSync(process.env.ACP_PID_FILE, String(process.pid)); +new AgentSideConnection( + () => ({ + async initialize() { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }; + }, + async newSession() { + return { sessionId: "silent-close-session" }; + }, + async authenticate() {}, + async cancel() {}, + unstable_closeSession() { + return new Promise(() => {}); + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function settlesWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> { + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise<false>((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }); + try { + return await Promise.race([promise.then(() => true), timedOut]); + } finally { + clearTimeout(timer); + } +} + +interface SilentCloseProvider { + session: ACPAgentSession; + readPid(): Promise<number>; + dispose(): Promise<void>; +} + +async function startSilentCloseProvider(): Promise<SilentCloseProvider> { + const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-silent-close-")); + const agentScript = path.join(dir, "agent.mjs"); + const pidFile = path.join(dir, "agent.pid"); + await writeFile(agentScript, SILENT_CLOSE_ACP_AGENT); + const session = new ACPAgentSession( + { provider: "silent-close-acp", cwd: dir }, + { + provider: "silent-close-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, agentScript], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + launchEnv: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ACP_PID_FILE: pidFile, + }, + }, + ); + const readPid = async () => Number(await readFile(pidFile, "utf8")); + return { + session, + readPid, + async dispose() { + const pid = await readPid().catch(() => null); + if (pid !== null && isProcessAlive(pid)) { + process.kill(pid, "SIGKILL"); + } + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +describe("ACPAgentSession close() with an unresponsive provider", () => { + let provider: SilentCloseProvider | null = null; + + afterEach(async () => { + await provider?.dispose(); + provider = null; + }); + + test("terminates a provider that never answers session/close", async () => { + provider = await startSilentCloseProvider(); + await provider.session.initializeNewSession(); + const pid = await provider.readPid(); + expect(isProcessAlive(pid)).toBe(true); + + expect(await settlesWithin(provider.session.close(), 8_000)).toBe(true); + expect(isProcessAlive(pid)).toBe(false); + }, 15_000); +}); + +// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but, +// like Cline, runs any model id it is given. +const PERMISSIVE_MODEL_ACP_AGENT = ` +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +let currentModelId = "advertised-default"; +const modelState = () => + process.env.ACP_ADVERTISE_MODELS === "none" + ? undefined + : { + currentModelId, + availableModels: [ + { modelId: "advertised-default", name: "Advertised default" }, + { modelId: "advertised-other", name: "Advertised other" }, + ], + }; +new AgentSideConnection( + () => ({ + async initialize() { + return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } }; + }, + async newSession() { + return { sessionId: "permissive-session", models: modelState() }; + }, + async loadSession() { + return { models: modelState() }; + }, + async unstable_setSessionModel({ modelId }) { + currentModelId = modelId; + return {}; + }, + async authenticate() {}, + async cancel() {}, + async prompt() { + return { stopReason: "end_turn" }; + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +describe("custom ACP provider with configured models", () => { + let dir: string; + let agentScript: string; + let session: AgentSession | null = null; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-configured-models-")); + agentScript = path.join(dir, "agent.mjs"); + await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT); + }); + + afterEach(async () => { + await session?.close(); + session = null; + await rm(dir, { recursive: true, force: true }); + }); + + function buildRegistry( + override: { + models?: Array<{ id: string; label: string }>; + additionalModels?: Array<{ id: string; label: string }>; + }, + agentEnv: Record<string, string> = {}, + ) { + return buildProviderRegistry(createTestLogger(), { + providerOverrides: { + permissive: { + extends: "acp", + label: "Permissive", + command: [process.execPath, agentScript], + env: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ...agentEnv, + }, + ...override, + }, + }, + }); + } + + async function createSessionOnModel( + registry: ReturnType<typeof buildRegistry>, + model: string, + ): Promise<AgentSession> { + const client = registry.permissive.createClient(createTestLogger()); + session = await client.createSession({ provider: "permissive", cwd: dir, model }); + return session; + } + + test("runs on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from a replacement models list that the agent does not advertise", async () => { + const registry = buildRegistry({ + models: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from additionalModels when the agent advertises no models", async () => { + const registry = buildRegistry( + { additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }] }, + { ACP_ADVERTISE_MODELS: "none" }, + ); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("resumes on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + const client = registry.permissive.createClient(createTestLogger()); + + session = await client.resumeSession( + { provider: "permissive", sessionId: "permissive-session", metadata: { cwd: dir } }, + { model: "stealth/pixel-canary" }, + ); + + expect((await session.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("keeps the agent's model when the requested model is neither advertised nor configured", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stale/removed-model"); + + expect((await created.getRuntimeInfo()).model).toBe("advertised-default"); + }); +}); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index de8055c787..169421fca9 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -76,6 +76,9 @@ import { type AgentPersistenceHandle, type AgentPromptContentBlock, type AgentPromptInput, + type AgentResumePurpose, + type AgentCreateSessionOptions, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -148,6 +151,18 @@ function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promis }); } +async function isDirectory(target: string): Promise<boolean> { + try { + return (await fs.stat(target)).isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + function isRecord(value: unknown): value is Record<string, unknown> { return value != null && typeof value === "object" && !Array.isArray(value); } @@ -301,6 +316,10 @@ export function buildACPClientCapabilities( const PROBE_ENV: Record<string, string> = { NO_BROWSER: "true" }; const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000; const ACP_PROBE_CLOSE_TIMEOUT_MS = 2_000; +// Archive and delete await close(). The ACP SDK does not reject a pending +// request when the provider never answers, so these calls must be bounded +// or the provider process is never terminated. +const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000; const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000; const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000; @@ -493,6 +512,8 @@ interface ACPAgentSessionOptions { capabilities: AgentCapabilityFlags; extensionCommandsParser?: ACPExtensionCommandsParser; handle?: AgentPersistenceHandle; + resumePurpose?: AgentResumePurpose; + configuredModelIds?: readonly string[]; agentId?: string; launchEnv?: Record<string, string>; waitForInitialCommands?: boolean; @@ -960,6 +981,7 @@ export class ACPAgentClient implements AgentClient { async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise<AgentSession> { this.assertProvider(config); const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {}); @@ -987,6 +1009,7 @@ export class ACPAgentClient implements AgentClient { supportsMcpServers: providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1002,6 +1025,7 @@ export class ACPAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise<AgentSession> { if (handle.provider !== this.provider) { throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`); @@ -1043,6 +1067,8 @@ export class ACPAgentClient implements AgentClient { providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, handle, + resumePurpose: options?.purpose, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1671,6 +1697,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly configuredModelIds: ReadonlySet<string>; private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; @@ -1696,6 +1723,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly terminalEntries = new Map<string, TerminalEntry>(); private readonly persistedHistory: AgentTimelineItem[] = []; private readonly initialHandle?: AgentPersistenceHandle; + private readonly resumePurpose: AgentResumePurpose; private readonly config: AgentSessionConfig; private child: ChildProcessWithoutNullStreams | null = null; @@ -1722,6 +1750,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private fallbackAssistantMessageId: string | null = null; private closed = false; private historyPending = false; + private historyRuntimeReleased = false; private replayingHistory = false; private bootstrapThreadEventPending = false; private readonly terminateProcess: ProcessTerminator; @@ -1738,6 +1767,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.configuredModelIds = new Set(options.configuredModelIds); this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; @@ -1749,6 +1779,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.agentId = options.agentId; this.launchEnv = options.launchEnv; this.initialHandle = options.handle; + this.resumePurpose = options.resumePurpose ?? "interactive"; this.config = { ...config, provider: options.provider }; this.currentMode = config.modeId ?? null; this.currentModel = config.model ?? null; @@ -1837,11 +1868,38 @@ export class ACPAgentSession implements AgentSession, ACPClient { } await this.applyConfiguredOverrides(); + + if (this.resumePurpose === "history") { + await this.releaseHistoryRuntime(); + } } catch (error) { await this.closeAfterInitializationFailure(error); } } + // History resumes exist to replay persisted events into the canonical + // timeline; the provider process is released once replay finishes so + // archived agents hold no resident runtime. Interactive work goes through + // reloadAgentSession, which spawns a fresh process. + private async releaseHistoryRuntime(): Promise<void> { + const child = this.child; + if (child) { + const result = await this.terminateProcess(child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }); + if (result === "kill-timeout") { + // Keep the child registered so close() can retry the shutdown. + this.logger.warn("ACP history resume could not release the provider process"); + return; + } + } + this.historyRuntimeReleased = true; + this.connection = null; + this.child = null; + this.settleCommandsReady(); + } + private async closeAfterInitializationFailure(error: unknown): Promise<never> { try { await this.close(); @@ -2171,12 +2229,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } + await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) }); + } + + private resolveModelSelection(modelId: string): ACPModelSelection { const selection = resolveACPModelSelection({ modelId, availableModels: this.availableModels, configOptions: this.configOptions, }); - await this.setModelWithSelection({ modelId, selection }); + if (selection.availableModel || !this.configuredModelIds.has(modelId)) { + return selection; + } + // A model the user added in provider config is applied even when the agent does not advertise it. + return { ...selection, availableModel: { modelId, name: modelId } }; } private async setModelWithSelection({ @@ -2190,21 +2256,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error("ACP session not initialized"); } - if (selection.hasAvailableModels) { - if (!selection.availableModel) { - this.warnInvalidSelection( - modelId, - `is not a valid ${this.provider} model. Available options: ${this.availableModels - ?.map((model) => model.modelId) - .join(", ")}`, - ); - return; - } - - if (typeof this.connection.unstable_setSessionModel !== "function") { - throw new Error(this.modelSelectionUnavailableMessage()); - } + if (selection.hasAvailableModels && !selection.availableModel) { + this.warnInvalidSelection( + modelId, + `is not a valid ${this.provider} model. Available options: ${this.availableModels + ?.map((model) => model.modelId) + .join(", ")}`, + ); + return; + } + if ( + selection.availableModel && + typeof this.connection.unstable_setSessionModel === "function" + ) { try { await this.connection.unstable_setSessionModel({ sessionId: this.sessionId, @@ -2455,10 +2520,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } - for (const pending of this.pendingPermissions.values()) { - pending.resolve({ outcome: { outcome: "cancelled" } }); - } - this.pendingPermissions.clear(); + this.cancelPendingPermissions(); if (this.activeForegroundTurnId) { await this.connection.cancel({ sessionId: this.sessionId }); @@ -2474,21 +2536,26 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.deliverTranslatedEvents(this.flushPendingUserMessage()); this.settleCommandsReady(); - for (const pending of this.pendingPermissions.values()) { - pending.resolve({ outcome: { outcome: "cancelled" } }); - } - this.pendingPermissions.clear(); + this.cancelPendingPermissions(); if (this.connection && this.sessionId) { try { if (this.activeForegroundTurnId) { - await this.connection.cancel({ sessionId: this.sessionId }); + await withTimeout( + this.connection.cancel({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP cancel timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch {} try { if (this.agentCapabilities?.sessionCapabilities?.close) { - await this.connection.unstable_closeSession({ sessionId: this.sessionId }); + await withTimeout( + this.connection.unstable_closeSession({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP closeSession timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch (error) { this.logger.debug({ err: error }, "ACP closeSession failed during shutdown"); @@ -2764,6 +2831,16 @@ export class ACPAgentSession implements AgentSession, ACPClient { return {}; } + // Loading an archived agent's history runs nothing in its working directory, which + // may have been removed with its worktree. session/load still names the original + // directory; only the process starts from the home directory. + private async resolveProcessCwd(): Promise<string> { + if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) { + return this.config.cwd; + } + return homedir(); + } + private async spawnProcess(): Promise<SpawnedACPProcess> { const prefix = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, @@ -2777,7 +2854,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { const command = prefix.command; const args = [...prefix.args, ...this.defaultCommand.slice(1)]; const child = spawnProcess(command, args, { - cwd: this.config.cwd, + cwd: await this.resolveProcessCwd(), ...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings, overlays: [this.launchEnv], @@ -2792,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) { @@ -2887,13 +2964,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { const configuredModelId = this.config.model; let switchedModel = false; if (configuredModelId && configuredModelId !== this.currentModel) { - const selection = resolveACPModelSelection({ - modelId: configuredModelId, - availableModels: this.availableModels, - configOptions: this.configOptions, - }); try { - await this.setModelWithSelection({ modelId: configuredModelId, selection }); + await this.setModelWithSelection({ + modelId: configuredModelId, + selection: this.resolveModelSelection(configuredModelId), + }); switchedModel = true; } catch (error) { if (!this.isModelSelectionUnavailableError(error)) { @@ -3298,6 +3373,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>, ): void { this.deliverTranslatedEvents(this.flushPendingUserMessage()); + // A turn that ends without completing leaves no way to answer its open + // permission requests, so tell the agent they were cancelled. + if (event.type !== "turn_completed") { + this.cancelPendingPermissions(); + } this.activeForegroundTurnId = null; this.fallbackAssistantMessageId = null; if (this.submittedUserMessageTurnId === event.turnId) { @@ -3306,6 +3386,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.pushEvent(event); } + private cancelPendingPermissions(): void { + for (const pending of this.pendingPermissions.values()) { + pending.resolve({ outcome: { outcome: "cancelled" } }); + } + this.pendingPermissions.clear(); + } + private emitBootstrapThreadEvent(): void { if (!this.bootstrapThreadEventPending || !this.sessionId) { return; 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 5069c3653f..5393926520 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -327,6 +327,57 @@ test("fails an auto mode turn when Claude Code uses Vertex", async () => { } }); +test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => { + const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "auto", + }); + + try { + await session.setMode("acceptEdits"); + + expect(await session.getCurrentMode()).toBe("acceptEdits"); + expect(sdkQueryFactory).toHaveBeenCalledTimes(1); + expect(sdkQueryFactory.mock.calls[0]?.[0]?.options?.permissionMode).toBe("acceptEdits"); + } finally { + restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock); + await session.close(); + } +}); + +test("does not keep a query started in a mode that Claude Code rejected", async () => { + const launchedModes: Array<string | undefined> = []; + sdkQueryFactory.mockImplementation(({ options }: { options: { permissionMode?: string } }) => { + launchedModes.push(options.permissionMode); + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + if (launchedModes.length === 1) { + queryMock.setPermissionMode.mockRejectedValue(new Error("bypass refused")); + } + return queryMock; + }); + const session = await createSession(); + + try { + await expect(session.setMode("bypassPermissions")).rejects.toThrow("bypass refused"); + await session.listCommands(); + + expect(await session.getCurrentMode()).toBe("default"); + expect(launchedModes).toEqual(["bypassPermissions", "default"]); + } finally { + await session.close(); + } +}); + test("logs redacted query summary and never leaks sentinel secrets", async () => { const envSecret = "PASEO_ENV_SENTINEL_SECRET"; const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET"; @@ -435,6 +486,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a } | null; input: { end: () => void } | null; queryRestartNeeded: boolean; + mainTurnInFlight: boolean; interruptActiveTurn: () => Promise<void>; } = asInternals(session); const interrupt = vi.fn(async () => undefined); @@ -447,6 +499,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a }; internal.input = { end }; internal.queryRestartNeeded = false; + internal.mainTurnInFlight = true; try { await internal.interruptActiveTurn(); @@ -1037,11 +1090,11 @@ test("preserves bypass capability across query restarts triggered by thinking ch expect(capturedOptions).toHaveLength(2); expect(capturedOptions[0]).toMatchObject({ - permissionMode: "default", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, }); expect(capturedOptions[1]).toMatchObject({ - permissionMode: "acceptEdits", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, effort: "high", }); @@ -1140,6 +1193,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); internal.turnState = "idle"; + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); await internal.routeSdkMessageFromPump( { type: "stream_event", @@ -1182,6 +1236,468 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou await session.close(); }); +interface SessionStateInternals { + turnState: "idle" | "foreground" | "autonomous"; + routeSdkMessageFromPump: ( + message: Record<string, unknown>, + activeQuery: QueryMock, + ) => Promise<void>; + autonomousTurn: { id: string } | null; + activeForegroundTurnId: string | null; +} + +function sessionState(state: "idle" | "running" | "requires_action") { + return { + type: "system", + subtype: "session_state_changed", + state, + uuid: `state-${state}`, + session_id: "sess-1", + }; +} + +/** Claude opening a turn of its own. Every main-session turn starts with one. */ +function claudeTurnInit() { + return { + type: "system", + subtype: "init", + session_id: "sess-1", + permissionMode: "default", + model: "opus", + }; +} + +function successResult() { + return { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }; +} + +function commandLifecycle( + commandUuid: string, + state: "queued" | "started" | "completed" | "cancelled", +) { + return { type: "command_lifecycle", command_uuid: commandUuid, state }; +} + +function unboundTextDelta(text: string) { + return { + type: "stream_event", + parent_tool_use_id: null, + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + }; +} + +// What a background subagent emits on the main stream with no parent tool use id, so by frame +// shape alone each looks like main-session work. Verified on the wire: none arrives inside a turn +// Claude opened, and no result follows any of them. +const helperTaskStarted = { + type: "system", + subtype: "task_started", + task_id: "helper-task", + tool_use_id: "toolu_helper", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Background helper", + is_backgrounded: true, + uuid: "uuid-task-started", + session_id: "sess-1", +}; +const helperTaskProgress = { + type: "system", + subtype: "task_progress", + task_id: "helper-task", + tool_use_id: "toolu_helper", + description: "Background helper", + usage: { total_tokens: 1200, tool_uses: 3, duration_ms: 900 }, + last_tool_name: "Bash", + uuid: "uuid-task-progress", + session_id: "sess-1", +}; +// A subagent's own background Bash job completing. +const subagentOwnedTaskNotification = { + type: "system", + subtype: "task_notification", + task_id: "bg-job-of-subagent", + tool_use_id: "toolu_subagent_bash", + status: "completed", + summary: "Background command completed", + output_file: "/tmp/bg-job-of-subagent.output", + uuid: "uuid-task-notification", + session_id: "sess-1", +}; +const helperCompleted = { + type: "system", + subtype: "task_notification", + task_id: "helper-task", + tool_use_id: "toolu_helper", + status: "completed", + summary: "Background helper finished", + output_file: "/tmp/helper-task.output", + uuid: "uuid-helper-completed", + session_id: "sess-1", +}; +const rateLimitEvent = { + type: "rate_limit_event", + rate_limit_info: { status: "allowed" }, + uuid: "uuid-rate-limit", + session_id: "sess-1", +}; + +function createPendingQueryMock(): QueryMock { + return createBaseQueryMock(vi.fn(() => new Promise<never>(() => {}))); +} + +test("a main-session frame opens no turn until Claude starts one", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Main-session output only ever arrives inside a turn Claude opened with system/init, so a + // stray unbound frame is not the start of one. + await internal.routeSdkMessageFromPump(unboundTextDelta("stray"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + // The wake-up case autonomous turns exist for: a completed background subagent makes the main + // session start a turn of its own, with no prompt from the user. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + +test("a background subagent's frames never open a turn while the main session is idle", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Claude Code 2.1.280 reports "running" for as long as any background subagent is alive. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + for (const frame of [ + helperTaskStarted, + helperTaskProgress, + subagentOwnedTaskNotification, + rateLimitEvent, + helperCompleted, + ]) { + await internal.routeSdkMessageFromPump(frame, queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + + // The next user message starts its own foreground turn rather than landing on a phantom one. + const { turnId } = await session.startTurn("PING"); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await session.close(); +}); + +test("Claude waking up after a helper finishes gets an autonomous turn that its result closes", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // The helper's completion arrives outside any turn. Claude then starts one to react to it. + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(helperCompleted, queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + expect(internal.turnState).toBe("autonomous"); + + await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("a steer folded into a turn keeps one turn, and a turn Claude starts after it gets its own", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const { turnId } = await session.startTurn("hello"); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + // A message pushed mid-turn is folded in at the next tool boundary: Claude acknowledges it, but + // starts no new turn. + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "queued"), queryMock); + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "started"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + // A message that arrived while Claude wrote its final answer runs as a turn of its own. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2"); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(2); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(2); + + await session.close(); +}); + +test("session state never opens a turn", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // "running" covers background subagents too, so it says nothing about the main session. + for (const state of ["idle", "running", "requires_action", "idle"] as const) { + await internal.routeSdkMessageFromPump(sessionState(state), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + // State frames are bookkeeping, never timeline content. + expect(events).toEqual([]); + + await session.close(); +}); + +test("an idle session state closes an autonomous turn that no result closed", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // Safety net: Claude says nothing at all is running any more. + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("session state never opens or closes a foreground turn", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + const { turnId } = await session.startTurn("hello"); + + // The send's own running, then an idle that trails the previous turn and lands late. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await internal.routeSdkMessageFromPump( + { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }, + queryMock, + ); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + await session.close(); +}); + +interface InterruptInternals extends SessionStateInternals { + pendingInterruptAbort: boolean; +} + +type InterruptibleQueryMock = QueryMock & { cancelAsyncMessage: ReturnType<typeof vi.fn> }; + +function createInterruptibleQueryMock(): InterruptibleQueryMock { + return Object.assign(createPendingQueryMock(), { + cancelAsyncMessage: vi.fn(async () => true), + }); +} + +/** The uuid Paseo stamped on the first message it pushed into the SDK input. */ +async function readFirstPromptUuid(): Promise<string | null> { + const input = sdkQueryFactory.mock.calls[0]?.[0] as { prompt: AsyncIterable<unknown> }; + return await createPromptUuidReader(input.prompt)(); +} + +function abortedResult() { + return { + type: "result", + subtype: "error_during_execution", + errors: [], + usage: buildUsage(), + total_cost_usd: 0, + }; +} + +test("an interrupt before Claude starts the message withdraws it instead of interrupting", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("hello"); + // Claude has not acknowledged the message yet: no init, no command_lifecycle "started". + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(queryMock.interrupt).not.toHaveBeenCalled(); + // No result follows an interrupt that never happened, so no flag may wait for one. + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_canceled")).toHaveLength(1); + + await session.close(); +}); + +test("an interrupt after Claude took the message but before it started stops it", async () => { + const queryMock = createInterruptibleQueryMock(); + // Claude has dequeued the message for its next turn, so withdrawing it is a no-op. + queryMock.cancelAsyncMessage.mockImplementation(async () => false); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("hello"); + // No command_lifecycle "started" or init has reached Paseo yet. + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(internal.pendingInterruptAbort).toBe(true); + + await session.close(); +}); + +test("a prompt sent while a stopped prompt is being withdrawn is not withdrawn too", async () => { + const queryMock = createInterruptibleQueryMock(); + let settleWithdrawal: (cancelled: boolean) => void = () => {}; + queryMock.cancelAsyncMessage.mockImplementationOnce( + () => new Promise<boolean>((resolve) => (settleWithdrawal = resolve)), + ); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + + await session.startTurn("first"); + const firstUuid = await readFirstPromptUuid(); + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + + await session.startTurn("second"); + settleWithdrawal(false); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(firstUuid); + + await session.close(); +}); + +test("an interrupt while only background subagents run never reaches Claude", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("delegate to a helper"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + // The main session is done; its helper keeps working. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(helperTaskProgress, queryMock); + + // Without perTaskStopAffordance, an interrupt here kills every running helper. + await session.interrupt(); + + expect(queryMock.interrupt).not.toHaveBeenCalled(); + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(false); + + await session.close(); +}); + +test("an interrupt during a main turn interrupts Claude once and drops the aborted result", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("long task"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + // Claude already started the message, so there is nothing to withdraw. + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(true); + + // A replacement starts before the aborted turn reports its result. + const { turnId } = await session.startTurn("replacement"); + await internal.routeSdkMessageFromPump(abortedResult(), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(0); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(queryMock.interrupt).toHaveBeenCalledTimes(1); + + await session.close(); +}); + +test("a new turn clears an interrupt flag no result will consume", async () => { + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // An interrupt that landed just after its turn had ended: Claude emits no result for it. + internal.pendingInterruptAbort = true; + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.pendingInterruptAbort).toBe(false); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // This turn's own failure is reported, not swallowed as the interrupted turn's leftover. + await internal.routeSdkMessageFromPump( + { ...abortedResult(), errors: ["API Error: 500 upstream failure"] }, + queryMock, + ); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(1); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + test("tracks run lifecycle transitions for success, error, and interrupt", async () => { const session = await createSession(); let streamCase: "success" | "error" | "interrupt" = "success"; diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index b6c8eea188..48ddab5973 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -634,6 +634,72 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(events[0]).toMatchObject({ id: TOOL_USE_ID }); }); + test.each(["Agent", "Task"] as const)( + "labels a background %s call's card with its type and task after a restart", + async (toolName) => { + writeSession({ + parentLines: [ + parentEntry([ + { + type: "tool_use", + id: TOOL_USE_ID, + name: toolName, + input: { + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + }, + ]), + JSON.stringify({ + type: "user", + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:48.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: TOOL_USE_ID, + content: [ + { + type: "text", + text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`, + }, + ], + }, + ], + }, + toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID }, + }), + ], + meta: JSON.stringify({ + agentType: "general-purpose", + description: "Count files here", + toolUseId: TOOL_USE_ID, + spawnDepth: 1, + requestShape: "background", + }), + }); + + const cards = (await replayEvents()).flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === TOOL_USE_ID + ? [event.item] + : [], + ); + expect(cards.at(-1)).toMatchObject({ + status: "completed", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "Count files here", + }, + }); + }, + ); + test("replays the subagent's own transcript onto its timeline", async () => { writeSession({ parentLines: [taskToolUse(), taskToolResult()], @@ -646,4 +712,56 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(timeline.length).toBeGreaterThan(0); expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID }); }); + + test("replays a SubagentHandback report as the subagent's final message", async () => { + const handbackId = "toolu_handback"; + const report = "## Verdict\n\n- **Coherent**"; + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: handbackId, + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: handbackId, + content: [ + { + type: "text", + text: '{"success":true,"message":"Report delivered to your caller."}', + }, + ], + }, + ], + }, + }), + ], + }); + + const items = (await replayDescriptors()).flatMap((event) => + event.event.type === "timeline" ? [event.event.item] : [], + ); + expect(items).toEqual([{ type: "assistant_message", text: report }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index efb452e9bf..84293afa6a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -587,4 +587,75 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { expect(latest.detail.log).toContain("[Read] file-6.md"); expect(latest.detail.log).toContain("[Read] file-205.md"); }); + + test("keeps a long main-thread tool call out of the subagents track", async () => { + // Claude Code sends a heartbeat every 30 s while a tool runs, with the tool's own id as + // parent_tool_use_id. Frames captured from Claude Code 2.1.291 for a 40 s MCP call. + queryFactory.mockImplementation(() => + buildQueryMock([ + { + type: "system", + subtype: "init", + session_id: "heartbeat-session", + permissionMode: "bypassPermissions", + model: "opus", + }, + { + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg-heartbeat-1", + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_slow", name: "mcp__slow__slow_check", input: {} }, + ], + }, + }, + { + type: "tool_progress", + tool_use_id: "toolu_slow-heartbeat-0", + tool_name: "mcp__slow__slow_check", + parent_tool_use_id: "toolu_slow", + elapsed_time_seconds: 30, + heartbeat: true, + }, + { + type: "user", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_slow", + content: [{ type: "text", text: "SLOW_CHECK_OK" }], + is_error: false, + }, + ], + }, + }, + { + type: "result", + subtype: "success", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + total_cost_usd: 0, + }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + + const events = await collectUntilTerminal(streamSession(session, "run the slow check")); + await session.close(); + + expect(events.filter((event) => event.type === "provider_subagent")).toEqual([]); + const toolCallNames = events.flatMap((event) => + event.type === "timeline" && event.item.type === "tool_call" ? [event.item.name] : [], + ); + expect(toolCallNames).toContain("mcp__slow__slow_check"); + expect(toolCallNames).not.toContain("Task"); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts new file mode 100644 index 0000000000..e5e8d117a4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import pino from "pino"; +import { expect, test } from "vitest"; + +import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +const ROOT_PROMPT = `Use Claude Code's native Agent tool exactly once, never Paseo tools, with this complete task: + +Do not use any tools. Reply with exactly this markdown report and nothing else: +## HANDBACK_HEADING +- **HANDBACK_BOLD** item + +Then reply exactly ROOT_DONE.`; + +function subagentItems(events: AgentStreamEvent[]): AgentTimelineItem[] { + return events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event.item] : [], + ); +} + +function expectHandbackAsReport(items: AgentTimelineItem[]): void { + expect(items.filter((item) => item.type === "tool_call")).toEqual([]); + expect( + items.flatMap((item) => (item.type === "assistant_message" ? [item.text] : [])).join(""), + ).toContain("## HANDBACK_HEADING\n- **HANDBACK_BOLD** item"); +} + +test("an auto-mode Claude subagent's handback report shows as its final message", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-subagent-handback-")); + const client = new ClaudeAgentClient({ logger: pino({ level: "warn" }) }); + const session = await client.createSession({ provider: "claude", cwd, modeId: "auto" }); + try { + const events = await collectSessionTurnEvents(session, ROOT_PROMPT); + expectHandbackAsReport(subagentItems(events)); + + const persistence = session.describePersistence(); + expect(persistence).not.toBeNull(); + await session.close(); + const restored = await client.resumeSession(persistence!, { cwd }); + try { + const replayed: AgentStreamEvent[] = []; + for await (const event of restored.streamHistory()) replayed.push(event); + expectHandbackAsReport(subagentItems(replayed)); + } finally { + await restored.close(); + } + } finally { + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 240_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts index d4fc0bcc0c..1223bbc541 100644 --- a/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts @@ -78,7 +78,7 @@ describe("subagent lifecycle across an interrupt", () => { queryFactory.mockReset(); }); - async function startInterruptibleTurn() { + async function startInterruptibleTurn(taskStarted: Record<string, unknown> = {}) { const channel = buildOpenQueryMock(); queryFactory.mockImplementation(() => channel.query); const session = await new ClaudeAgentClient({ @@ -107,6 +107,7 @@ describe("subagent lifecycle across an interrupt", () => { task_type: "local_agent", subagent_type: "general-purpose", description: "Reply with banana", + ...taskStarted, }); await vi.waitFor(() => expect(subagentEvents.length).toBeGreaterThan(0)); @@ -169,6 +170,31 @@ describe("subagent lifecycle across an interrupt", () => { await session.close(); }); + test("a child spawned in the background is not canceled with the turn that spawned it", async () => { + // Claude announces a background spawn on task_started itself and sends no task_updated patch. + const { channel, session, subagentEvents } = await startInterruptibleTurn({ + is_backgrounded: true, + }); + + await session.interrupt(); + channel.push({ + type: "system", + subtype: "task_notification", + task_id: TASK_ID, + tool_use_id: SUBAGENT_ID, + status: "completed", + }); + + await vi.waitFor(() => + expect(subagentEvents).toContainEqual( + expect.objectContaining({ id: SUBAGENT_ID, status: "completed" }), + ), + ); + expect(statuses(subagentEvents, SUBAGENT_ID)).toEqual(["running", "completed"]); + + await session.close(); + }); + test("a foreground child is canceled with the turn that spawned it", async () => { const { session, subagentEvents } = await startInterruptibleTurn(); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index d28ac910ac..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, }, }), @@ -3069,6 +3241,44 @@ describe("ClaudeAgentSession context window usage", () => { } }); + test("reports the plan mode Claude enters on its own with EnterPlanMode", async () => { + // Claude Code announces a mode it switched to itself as a status message + // carrying the new permissionMode, right after the EnterPlanMode tool call. + const client = new ClaudeAgentClient({ + logger, + queryFactory: createQueryFactoryForTurns([ + [ + { ...createInitMessage(), permissionMode: "acceptEdits" }, + { + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + session_id: "session-1", + }, + createSuccessResult(), + ], + ]), + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "acceptEdits", + }); + + try { + const events = await collectStreamEvents(session, "enter plan mode"); + + expect(events).toContainEqual( + expect.objectContaining({ type: "mode_changed", currentModeId: "plan" }), + ); + expect(await session.getCurrentMode()).toBe("plan"); + } finally { + await session.close(); + } + }); + test("a compaction abandoned mid-turn does not suppress the next compaction marker", async () => { // The first turn starts compacting and then ends without ever reaching a // compact_boundary, so the marker it opened is never resolved. @@ -3122,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 10048356b1..8ea12610ea 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -31,7 +31,7 @@ import { readTaskNotificationToolUseIdFromHistoryRecord, } from "./task-notification-tool-call.js"; import { - findClaudeModel, + getClaudeContextWindowMaxTokens, getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId, resolveConfiguredClaudeModel, @@ -44,6 +44,7 @@ import { } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; +import { readClaudeSubagentHandback } from "./subagent-handback.js"; import { ClaudeTaskState } from "./task-state.js"; import { ClaudeTaskProtocolSource, @@ -75,6 +76,7 @@ import { type ClaudeProviderOptions, } from "./options.js"; import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; +import { discoverClaudeModels } from "./model-discovery.js"; import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; import { realClaudeRewindSdk, @@ -83,7 +85,7 @@ import { type ClaudeRewindSdk, } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; -import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js"; +import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js"; import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; import { isProviderImageMarkdown, @@ -130,6 +132,7 @@ import { type ProviderCatalog, type ProviderRefreshContext, type ResolveAgentDefaultModeInput, + type ToolCallDetail, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; @@ -295,6 +298,13 @@ interface AsyncMessageInput<T> { interface ClaudeReplayOwnership { restoredIds: ReadonlySet<string>; toolOwners: ReadonlyMap<string, string>; + /** The parent's Task calls, keyed by tool-call id, so their cards replay labeled as they were live. */ + subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>; +} + +interface ClaudeSubagentCardFacts { + title?: string; + description?: string; } interface PersistedTimelineEntry { @@ -403,6 +413,7 @@ export interface ClaudeContentChunk { } interface ClaudeAgentClientOptions { + discoverModels?: typeof discoverClaudeModels; defaults?: { agents?: Record<string, AgentDefinition> }; logger: Logger; runtimeSettings?: ProviderRuntimeSettings; @@ -443,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, @@ -1496,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; @@ -1508,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; @@ -1590,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 { @@ -1806,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 { @@ -1898,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; @@ -1914,7 +1997,7 @@ class ClaudeContextUsageState { } beginTurn(): void { - this.streamRequestInputTokens = undefined; + this.streamRequestInputUsage = undefined; this.streamRequestOutputTokens = undefined; this.compactedContextWindowUsedTokens = undefined; } @@ -1938,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); @@ -1950,6 +2033,12 @@ class ClaudeContextUsageState { return null; } this.streamRequestOutputTokens = outputTokens; + if (this.streamRequestInputUsage) { + this.streamRequestInputUsage = mergeStreamDeltaInputUsage( + this.streamRequestInputUsage, + streamEvent, + ); + } } else { return null; } @@ -1996,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; } @@ -2021,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 = {}; @@ -2061,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; @@ -2076,6 +2168,7 @@ class ClaudeAgentSession implements AgentSession { private toolUseCache = new Map<string, ToolUseCacheEntry>(); private toolUseIndexToId = new Map<number, string>(); private toolUseInputBuffers = new Map<string, string>(); + private subagentHandbackIds = new Set<string>(); private pendingPermissions = new Map<string, PendingPermission>(); private activeForegroundTurnId: string | null = null; private autonomousTurn: AutonomousTurnState | null = null; @@ -2116,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; @@ -2141,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; @@ -2262,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 = () => { @@ -2272,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"); }); }; @@ -2288,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"); } @@ -2301,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"), ); @@ -2349,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); } @@ -2360,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; @@ -2431,8 +2542,15 @@ class ClaudeAgentSession implements AgentSession { const normalized = isPermissionMode(modeId) ? modeId : "default"; assertClaudeModeCanRun(normalized, this.harnessEnvironment); const previousMode = this.currentMode; - const activeQuery = await this.ensureQuery(); - await activeQuery.setPermissionMode(normalized); + const launchesQuery = !this.query || this.queryRestartNeeded; + const activeQuery = await this.ensureQuery(normalized); + try { + await activeQuery.setPermissionMode(normalized); + } catch (error) { + // The query was launched in the rejected mode; relaunch in the current mode next time. + if (launchesQuery) this.queryRestartNeeded = true; + throw error; + } if (normalized === "plan") { if (previousMode !== "plan") { this.planResumeMode = previousMode; @@ -2454,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; @@ -2780,9 +2898,11 @@ class ClaudeAgentSession implements AgentSession { this.startFreshConversationSession(); return; } + const currentSessionId = this.claudeSessionId; await revertClaudeConversation({ sdk: this.rewindSdk, - sessionId: this.claudeSessionId, + sessionId: currentSessionId, + transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null, messageId: target.messageId, resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId), setSessionId: (sessionId) => { @@ -3118,7 +3238,7 @@ class ClaudeAgentSession implements AgentSession { return { kind: "fresh-session" }; } - private async ensureQuery(): Promise<Query> { + private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> { if (this.query && !this.queryRestartNeeded) { return this.query; } @@ -3162,9 +3282,11 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; const input = createAsyncMessageInput<SDKUserMessage>(); - const options = await this.buildOptions(); + const options = await this.buildOptions(launchMode); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; + // A fresh Claude process has no turn of its own in flight. + this.mainTurnInFlight = false; this.query = claudeQuery( { prompt: input.iterable, options }, { @@ -3284,11 +3406,16 @@ class ClaudeAgentSession implements AgentSession { return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, - overlays: [this.launchEnv], + overlays: [ + this.launchEnv, + // Makes the CLI announce session_state_changed, whose "idle" is the safety net that closes + // an autonomous turn (see consumeSessionStateChange). + { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" }, + ], }); } - private async buildOptions(): Promise<ClaudeOptions> { + private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> { const { thinking, effort, ultracode } = this.resolveThinkingConfig(); const appendedSystemPrompt = this.buildAppendedSystemPrompt(); const providerOptions = applyClaudeToolPolicy( @@ -3297,7 +3424,7 @@ class ClaudeAgentSession implements AgentSession { ); const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode }); const sdkEnv = this.harnessEnvironment; - assertClaudeModeCanRun(this.currentMode, sdkEnv); + assertClaudeModeCanRun(permissionMode, sdkEnv); const claudeBinary = await this.resolveBinary(); this.logger.debug( @@ -3320,7 +3447,7 @@ class ClaudeAgentSession implements AgentSession { const base: ClaudeOptions = { cwd: this.config.cwd, includePartialMessages: true, - permissionMode: this.currentMode, + permissionMode, // Dynamic mode switching can recreate the underlying Claude query. Keep the // bypass launch capability available so later setPermissionMode("bypassPermissions") // calls do not fail after a model/thinking/rewind-driven restart. @@ -3351,6 +3478,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, @@ -3881,35 +4012,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") { @@ -4051,7 +4214,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( @@ -4065,8 +4234,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(), @@ -4078,14 +4255,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 = ( @@ -4093,14 +4271,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( @@ -4149,7 +4330,7 @@ class ClaudeAgentSession implements AgentSession { } } - this.forgetReadSteer(message); + this.forgetStartedMessage(message); switch (message.type) { case "system": @@ -4183,11 +4364,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); } @@ -4249,16 +4433,7 @@ class ClaudeAgentSession implements AgentSession { return { type: "timeline", provider: "claude", - item: { - ...toolCall, - detail: { - type: "sub_agent", - ...(declaration.title ? { subAgentType: declaration.title } : {}), - ...(declaration.description ? { description: declaration.description } : {}), - log: "", - actions: [], - }, - }, + item: { ...toolCall, detail: buildClaudeSubagentCardDetail(declaration) }, }; } @@ -4321,6 +4496,19 @@ class ClaudeAgentSession implements AgentSession { return; } if (message.subtype === "status") { + // Claude Code reports a mode it switched to on its own, such as entering + // plan mode with the EnterPlanMode tool, through the status message. + if ( + isPermissionMode(message.permissionMode) && + this.observePermissionMode(message.permissionMode) + ) { + events.push({ + type: "mode_changed", + provider: "claude", + currentModeId: this.currentMode, + availableModes: this.availableModes, + }); + } const status = toObjectRecord(message)?.status; if (status === "compacting") { this.compacting = true; @@ -4632,12 +4820,15 @@ class ClaudeAgentSession implements AgentSession { notice = this.createClaudeSessionChangedNotice(existingSessionId, newSessionId); } this.availableModes = DEFAULT_MODES; - this.currentMode = message.permissionMode; - if (this.currentMode !== "plan") { - this.planResumeMode = this.currentMode; - } + 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 }, @@ -4654,6 +4845,16 @@ class ClaudeAgentSession implements AgentSession { return { threadStartedSessionId, notice }; } + /** Records a mode Claude Code reports, returning whether it differs from the current one. */ + private observePermissionMode(mode: PermissionMode): boolean { + const changed = this.currentMode !== mode; + this.currentMode = mode; + if (mode !== "plan") { + this.planResumeMode = mode; + } + return changed; + } + private readMissingResumedConversationError(message: SDKMessage): string | null { if (message.type !== "result" || message.subtype !== "error_during_execution") { return null; @@ -4957,6 +5158,7 @@ class ClaudeAgentSession implements AgentSession { const sidechainEntries = [parentContent, ...sidechains.contents] .flatMap(parseClaudeHistoryRecords) .filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string"); + const parentFacts = readClaudeReplayParentFacts(parentEntries); // Replay produces the same observations the live task protocol produces, then folds them // with the same function, so identity and status are derived once for both paths. @@ -4967,7 +5169,7 @@ class ClaudeAgentSession implements AgentSession { entries, parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]), })), - parent: readClaudeReplayParentFacts(parentEntries), + parent: parentFacts, convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), }); const observations = [ @@ -4996,6 +5198,7 @@ class ClaudeAgentSession implements AgentSession { const replay = { restoredIds: restoredProviderSubagentIds, toolOwners: subagentReplay.toolOwners, + subagentToolCalls: parentFacts.toolCalls, }; if (observations.length === 0) return replay; @@ -5062,7 +5265,12 @@ class ClaudeAgentSession implements AgentSession { return; } const taskSnapshot = this.taskState.observe(entry); - const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)]; + const items = [ + ...(taskSnapshot ? [taskSnapshot] : []), + ...this.convertHistoryEntry(entry).map((item) => + labelReplayedSubagentCard(item, replay.subagentToolCalls), + ), + ]; const isVisibleUserEntry = entry.type === "user" && typeof entry.uuid === "string" && @@ -5089,26 +5297,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[] { @@ -5158,7 +5351,7 @@ class ClaudeAgentSession implements AgentSession { // User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message. const userTextParts: string[] = []; for (const block of content) { - if (!isClaudeContentChunk(block)) { + if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) { continue; } this.mapBlockToTimeline(block, { @@ -5246,6 +5439,24 @@ class ClaudeAgentSession implements AgentSession { } } + // A subagent's handback call is its final message, and the call's result is only an + // acknowledgement, so neither becomes a tool call. + private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean { + if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) { + this.subagentHandbackIds.delete(block.tool_use_id); + return true; + } + const handback = readClaudeSubagentHandback(block); + if (!handback) { + return false; + } + this.subagentHandbackIds.add(handback.callId); + if (handback.report) { + items.push({ type: "assistant_message", text: handback.report }); + } + return true; + } + private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { const entry = this.upsertToolUseEntry(block); if (!entry) { @@ -5812,6 +6023,33 @@ function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] { return entries; } +/** The parent's card for a subagent, labeled with its type and task. */ +function buildClaudeSubagentCardDetail( + facts: ClaudeSubagentCardFacts, +): Extract<ToolCallDetail, { type: "sub_agent" }> { + return { + type: "sub_agent", + ...(facts.title ? { subAgentType: facts.title } : {}), + ...(facts.description ? { description: facts.description } : {}), + log: "", + actions: [], + }; +} + +/** + * Live, a Task call's card is built from the task protocol's declaration. Replay has no task + * protocol, so the generic tool mapper leaves the card unlabeled; label it from the same facts. + */ +function labelReplayedSubagentCard( + item: AgentTimelineItem, + subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>, +): AgentTimelineItem { + if (item.type !== "tool_call") return item; + const facts = subagentToolCalls.get(item.callId); + if (!facts) return item; + return { ...item, detail: buildClaudeSubagentCardDetail(facts) }; +} + /** * Collect what the parent transcript knows about its Task calls, in the shape the shared replay * source consumes. The agentId scrape is kept only as a fallback for sessions recorded before @@ -6439,7 +6677,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. */ @@ -6448,7 +6687,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/rewind.test.ts b/packages/server/src/server/agent/providers/claude/rewind.test.ts index 99243bb9c4..2d6d3853c2 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.test.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, test } from "vitest"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { revertClaudeConversation, revertClaudeConversationAndFiles, revertClaudeFiles, + realClaudeRewindSdk, } from "./rewind.js"; import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js"; @@ -16,6 +21,7 @@ describe("Claude rewind", () => { await revertClaudeConversation({ sdk: claude, sessionId, + transcriptPath: "/claude/projects/repo/original-session.jsonl", messageId: "user-message-1", setSessionId: (nextSessionId) => { sessionId = nextSessionId; @@ -33,6 +39,7 @@ describe("Claude rewind", () => { await revertClaudeConversation({ sdk: claude, sessionId, + transcriptPath: "/claude/projects/repo/original-session.jsonl", messageId: "timeline-message-1", resolveMessageId: () => "claude-jsonl-message-1", setSessionId: (nextSessionId) => { @@ -76,6 +83,7 @@ describe("Claude rewind", () => { sdk: claude, query: claude.createQuery() as Query, sessionId, + transcriptPath: "/claude/projects/repo/original-session.jsonl", messageId: "user-message-1", setSessionId: (nextSessionId) => { sessionId = nextSessionId; @@ -87,3 +95,126 @@ describe("Claude rewind", () => { expect(sessionId).toBe("forked-before-rehydrate"); }); }); + +interface TranscriptLine { + type: "user" | "assistant"; + uuid: string; + parentUuid: string | null; + text: string; +} + +function writeTranscript( + transcriptPath: string, + sessionId: string, + lines: Array<TranscriptLine | string>, +): void { + const entries = lines.map((line) => { + if (typeof line === "string") return line; + const { type, uuid, parentUuid, text } = line; + return JSON.stringify({ + type, + uuid, + parentUuid, + sessionId, + cwd: "/repo", + timestamp: new Date().toISOString(), + message: { role: type, content: text }, + }); + }); + writeFileSync(transcriptPath, `${entries.join("\n")}\n`, "utf8"); +} + +function readMessageTexts(transcriptPath: string): string[] { + const entries = readFileSync(transcriptPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { message?: { content: string } }); + return entries.flatMap((entry) => (entry.message ? [entry.message.content] : [])); +} + +describe("Claude rewind against the Claude SDK", () => { + let tempRoot: string | null = null; + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + + afterEach(() => { + if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = null; + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + }); + + test("forks a session that lives in a provider's own CLAUDE_CONFIG_DIR", async () => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-")); + // The provider sets CLAUDE_CONFIG_DIR for Claude Code; the daemon's own environment does not. + delete process.env.CLAUDE_CONFIG_DIR; + const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo"); + mkdirSync(projectDir, { recursive: true }); + const sessionId = randomUUID(); + const firstUser = randomUUID(); + const firstReply = randomUUID(); + const secondUser = randomUUID(); + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`); + writeTranscript(transcriptPath, sessionId, [ + { type: "user", uuid: firstUser, parentUuid: null, text: "first" }, + { type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" }, + { type: "user", uuid: secondUser, parentUuid: firstReply, text: "second" }, + { type: "assistant", uuid: randomUUID(), parentUuid: secondUser, text: "second reply" }, + ]); + let currentSessionId = sessionId; + + await revertClaudeConversation({ + sdk: realClaudeRewindSdk, + sessionId, + transcriptPath, + messageId: firstReply, + setSessionId: (nextSessionId) => { + currentSessionId = nextSessionId; + }, + }); + + expect(currentSessionId).not.toBe(sessionId); + expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([ + "first", + "first reply", + ]); + }); + + test("skips transcript lines that are not entries when forking", async () => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-")); + delete process.env.CLAUDE_CONFIG_DIR; + const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo"); + mkdirSync(projectDir, { recursive: true }); + const sessionId = randomUUID(); + const firstUser = randomUUID(); + const firstReply = randomUUID(); + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`); + writeTranscript(transcriptPath, sessionId, [ + "null", + { type: "user", uuid: firstUser, parentUuid: null, text: "first" }, + "[1, 2]", + '"text"', + "{}", + { type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" }, + { type: "user", uuid: randomUUID(), parentUuid: firstReply, text: "second" }, + ]); + let currentSessionId = sessionId; + + await revertClaudeConversation({ + sdk: realClaudeRewindSdk, + sessionId, + transcriptPath, + messageId: firstReply, + setSessionId: (nextSessionId) => { + currentSessionId = nextSessionId; + }, + }); + + expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([ + "first", + "first reply", + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/rewind.ts b/packages/server/src/server/agent/providers/claude/rewind.ts index 9c3e3d708d..3b228aacc6 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -1,29 +1,90 @@ -import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk"; +import { appendFile, readFile } from "node:fs/promises"; +import path from "node:path"; +import { + forkSession as claudeForkSession, + type Query, + type SessionStore, + type SessionStoreEntry, +} from "@anthropic-ai/claude-agent-sdk"; export interface ClaudeRewindSdk { forkSession( sessionId: string, - options: { upToMessageId: string }, + options: { upToMessageId: string; transcriptPath: string }, ): Promise<{ sessionId: string }>; } +// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a +// provider can point Claude Code at another one. Forking through a store rooted at the +// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it. export const realClaudeRewindSdk: ClaudeRewindSdk = { - forkSession: claudeForkSession, + forkSession: (sessionId, options) => + claudeForkSession(sessionId, { + upToMessageId: options.upToMessageId, + sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)), + }), }; +function projectFolderSessionStore(projectFolder: string): SessionStore { + const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`); + return { + async load({ sessionId }) { + let text: string; + try { + text = await readFile(transcriptPath(sessionId), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + return parseTranscriptEntries(text); + }, + async append({ sessionId }, entries) { + const lines = entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""); + await appendFile(transcriptPath(sessionId), lines, "utf8"); + }, + }; +} + +function parseTranscriptEntries(text: string): SessionStoreEntry[] { + const entries: SessionStoreEntry[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // Skip a line that is not JSON, as the SDK's own transcript reader does. + continue; + } + if (isSessionStoreEntry(parsed)) entries.push(parsed); + } + return entries; +} + +function isSessionStoreEntry(value: unknown): value is SessionStoreEntry { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof (value as { type?: unknown }).type === "string" + ); +} + export async function revertClaudeConversation(input: { sdk: ClaudeRewindSdk; sessionId: string | null; + transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise<string>; setSessionId: (sessionId: string) => void; }): Promise<void> { - if (!input.sessionId) { + if (!input.sessionId || !input.transcriptPath) { throw new Error("Claude session is not ready for rewind"); } const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId; const fork = await input.sdk.forkSession(input.sessionId, { upToMessageId: messageId, + transcriptPath: input.transcriptPath, }); input.setSessionId(fork.sessionId); } @@ -44,6 +105,7 @@ export async function revertClaudeConversationAndFiles(input: { sdk: ClaudeRewindSdk; query: Query; sessionId: string | null; + transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise<string>; setSessionId: (sessionId: string) => void; diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index 615ca81f3a..f762557879 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => { }, }); }); + + it("shows a SubagentHandback report as the subagent's final message", () => { + const tracker = new ClaudeSidechainTracker({ getToolInput: () => null }); + const report = " indented code\n\n## Verdict\n\n- **Coherent**"; + const frames = [ + { + type: "stream_event", + parent_tool_use_id: "task-1", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: {}, + }, + }, + }, + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { + id: "msg-1", + content: [ + { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "task-1", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "handback-1", + tool_name: "SubagentHandback", + content: [{ type: "text", text: '{"success":true}' }], + }, + ], + }, + }, + ]; + + const childItems = frames + .flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1")) + .flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" + ? [event.event.item] + : [], + ); + + expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 6973d1db5d..195e21f857 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -6,6 +6,10 @@ import { mapClaudeRunningToolCall, } from "./tool-call-mapper.js"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; +import { + isClaudeSubagentHandbackToolName, + readClaudeSubagentHandback, +} from "./subagent-handback.js"; import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; @@ -91,7 +95,7 @@ export class ClaudeSidechainTracker { const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); const actionCandidates = this.extractSubAgentActionCandidates(message); const childTimelineItems = [ - ...this.extractSubAgentTimelineItems(message), + ...this.extractSubAgentTimelineItems(message, state), ...this.extractSubAgentToolResults(message, state), ]; let actionUpdated = false; @@ -233,7 +237,10 @@ export class ClaudeSidechainTracker { this.activeSidechains.clear(); } - private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] { + private extractSubAgentTimelineItems( + message: SDKMessage, + state: SubAgentActivityState, + ): AgentTimelineItem[] { if (message.type !== "assistant" || !Array.isArray(message.message?.content)) { return []; } @@ -253,6 +260,18 @@ export class ClaudeSidechainTracker { } else if (block.type === "thinking") { const text = readTrimmedString(block.thinking); if (text) items.push({ type: "reasoning", text }); + } else { + const handback = readClaudeSubagentHandback(block); + if (!handback) continue; + // The handback's result is only an acknowledgement, never a tool card. + state.completedActionKeys.add(handback.callId); + if (handback.report) { + items.push({ + type: "assistant_message", + text: handback.report, + ...(messageId ? { messageId } : {}), + }); + } } } return items; @@ -391,6 +410,12 @@ export class ClaudeSidechainTracker { } private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { + return this.extractToolActionCandidates(message).filter( + (action) => !isClaudeSubagentHandbackToolName(action.toolName), + ); + } + + private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { if (message.type === "assistant") { return this.extractAssistantMessageActions(message); } diff --git a/packages/server/src/server/agent/providers/claude/subagent-handback.ts b/packages/server/src/server/agent/providers/claude/subagent-handback.ts new file mode 100644 index 0000000000..2a8613ed31 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagent-handback.ts @@ -0,0 +1,27 @@ +/** + * Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a + * `SubagentHandback` tool call instead of its final text. The report is the subagent's final + * message, so Paseo shows it as one and hides the call and its acknowledgement. + */ +const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback"; + +export interface ClaudeSubagentHandback { + callId: string; + /** Null while the call is still streaming its input. */ + report: string | null; +} + +export function isClaudeSubagentHandbackToolName(name: unknown): boolean { + return name === SUBAGENT_HANDBACK_TOOL_NAME; +} + +export function readClaudeSubagentHandback(block: { + type: string; + [key: string]: unknown; +}): ClaudeSubagentHandback | null { + if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null; + if (typeof block.id !== "string" || block.id.length === 0) return null; + const input = block.input as { message?: unknown } | null | undefined; + const report = typeof input?.message === "string" ? input.message : ""; + return { callId: block.id, report: report.trim().length > 0 ? report : null }; +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts index 1eca4cedc2..b0bfc3d755 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts @@ -511,6 +511,33 @@ describe("ClaudeTaskProtocolSource", () => { expect(source.cancelRunningForegroundTasks()).toEqual([]); }); + it("does not cancel a subagent announced as backgrounded at spawn", () => { + // Claude sets is_backgrounded on task_started for a child spawned in the background, and + // sends no task_updated patch for it. + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("does not cancel a resumed subagent, which Claude always runs in the background", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ tool_use_id: "toolu_original", is_backgrounded: false })); + source.observe(taskUpdated("completed")); + source.observe(taskStarted({ tool_use_id: "toolu_resumed", is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("still cancels a subagent announced in the foreground", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: false })); + + expect(source.cancelRunningForegroundTasks()).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "canceled" }, + ]); + }); + it("still routes a backgrounded subagent that settles after the interrupt", () => { // The headline case: interrupt, continue, and the child that was told to outlive the turn // reports completion later. Wiping the routing table on cancel drops this on the floor and diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index 1a44520366..2149fae139 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -16,7 +16,8 @@ import { * * Verified on the wire (Claude Code 2.1.220, Paseo's own query options): * - * task_started task_id, tool_use_id, description, subagent_type, task_type + * task_started task_id, tool_use_id, description, subagent_type, task_type, + * is_backgrounded * task_updated task_id, patch.status, patch.is_backgrounded * task_notification task_id, tool_use_id, status * @@ -39,6 +40,11 @@ interface TaskStartedMessage { task_type?: string; prompt?: string; skip_transcript?: boolean; + /** + * Set at spawn: true for a task registered in the background. A resumed subagent is always + * registered in the background. + */ + is_backgrounded?: boolean; } /** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */ @@ -71,8 +77,8 @@ interface TaskUpdatedMessage { task_id: string; /** * `is_backgrounded` is declared on `SDKTaskUpdatedMessage["patch"]`: it flips when a foreground - * task is backgrounded, which is the only signal that separates a child that dies with its turn - * from one that was explicitly told to outlive it. + * task is moved to the background after `task_started`. Together with `task_started`'s own + * flag it separates a child that dies with its turn from one told to outlive it. */ patch?: { status?: string; is_backgrounded?: boolean }; } @@ -171,8 +177,8 @@ export class ClaudeTaskProtocolSource { /** Workflow invocations already own a real Workflow card in the parent timeline. */ private readonly idsWithExistingParentToolCard = new Set<string>(); /** - * Declared subagents that were moved to the background. They outlive the turn that spawned - * them, so a turn ending is not evidence that they stopped. + * Declared subagents running in the background, whether spawned there or moved there later. + * They outlive the turn that spawned them, so a turn ending is not evidence that they stopped. */ private readonly backgroundedIds = new Set<string>(); /** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */ @@ -331,6 +337,7 @@ export class ClaudeTaskProtocolSource { this.sawTaskStarted = true; const existingId = this.subagentIdByTaskId.get(message.task_id); + this.recordBackgrounded(existingId ?? id, message.is_backgrounded); if (existingId) { return this.observeExistingTaskStart(message, id, existingId); } @@ -414,14 +421,15 @@ export class ClaudeTaskProtocolSource { private observeTaskUpdated(message: TaskUpdatedMessage): SubagentObservation[] { const id = this.subagentIdByTaskId.get(message.task_id); - const backgrounded = message.patch?.is_backgrounded; - if (id && typeof backgrounded === "boolean") { - if (backgrounded) this.backgroundedIds.add(id); - else this.backgroundedIds.delete(id); - } + if (id) this.recordBackgrounded(id, message.patch?.is_backgrounded); return this.observeStatus(message.task_id, message.patch?.status); } + private recordBackgrounded(id: string, backgrounded: boolean | undefined): void { + if (backgrounded === true) this.backgroundedIds.add(id); + else if (backgrounded === false) this.backgroundedIds.delete(id); + } + private observeTaskNotification(message: TaskNotificationMessage): SubagentObservation[] { const observations = this.observeWorkflowResult(message); observations.push(...this.observeUsage(message.task_id, message.usage)); diff --git a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts index 496d3bc209..d607b5c063 100644 --- a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts +++ b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts @@ -12,7 +12,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk { async forkSession( _sessionId: string, - options: { upToMessageId: string }, + options: { upToMessageId: string; transcriptPath: string }, ): Promise<{ sessionId: string }> { this.recordedForks.push({ upToMessageId: options.upToMessageId }); return { sessionId: this.nextSessionId }; diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index 5c9dbe956c..cc32a3c013 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -22,6 +22,44 @@ describe("Codex app-server transport", () => { child.stdin.end(); }); + test("keeps a JSON message intact when it contains raw U+2028/U+2029", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + + const request = client.request("thread/resume", {}); + const text = "a\u2028b\u2029c\u0085d"; + // Codex does not escape U+2028/U+2029 in JSON output, so write them raw. + const message = `{"id":1,"result":{"text":"${text}"}}\n`; + const bytes = Buffer.from(message, "utf8"); + // Split mid-way (inside a multi-byte character) to exercise chunk boundaries. + child.stdout.write(bytes.subarray(0, 28)); + child.stdout.write(bytes.subarray(28)); + + await expect(request).resolves.toEqual({ text }); + child.stdout.end(); + child.stderr.end(); + child.stdin.end(); + }); + + test("reads a multi-megabyte JSON message arriving in 64 KB chunks", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + + const request = client.request("thread/resume", {}); + // A resumed long thread arrives as one large line, split into 64 KB stdout chunks. + const text = "x".repeat(32 * 1024 * 1024); + const bytes = Buffer.from(`{"id":1,"result":{"text":"${text}"}}\n`, "utf8"); + const chunkSize = 64 * 1024; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + child.stdout.write(bytes.subarray(offset, offset + chunkSize)); + } + + await expect(request).resolves.toEqual({ text }); + child.stdout.end(); + child.stderr.end(); + child.stdin.end(); + }); + test("dispose rejects pending requests instead of leaving them hanging", async () => { const child = createCodexAppServerChildProcess(); const client = new CodexAppServerClient(child, createTestLogger()); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index 1720118c63..acb5e76341 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -1,5 +1,5 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import readline from "node:readline"; +import { StringDecoder } from "node:string_decoder"; import type { Logger } from "pino"; import { z } from "zod"; @@ -167,8 +167,41 @@ function readProviderTurnId(params: unknown): string | undefined { return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined; } +/** + * Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029 + * as line terminators, but Codex emits those characters raw inside JSON strings, so readline + * would cut a single JSON-RPC message into pieces that fail to parse. + */ +function createNewlineLineReader( + input: NodeJS.ReadableStream, + onLine: (line: string) => void, +): { close(): void } { + const decoder = new StringDecoder("utf8"); + // Text after the last "\n", kept as parts and joined once its line ends, so a large line + // arriving in many chunks is scanned and copied once instead of once per chunk. + let unterminated: string[] = []; + const onData = (chunk: Buffer | string): void => { + const text = typeof chunk === "string" ? chunk : decoder.write(chunk); + let lineStart = 0; + let index = text.indexOf("\n"); + while (index !== -1) { + unterminated.push(text.slice(lineStart, index)); + const line = unterminated.join(""); + unterminated = []; + onLine(line.endsWith("\r") ? line.slice(0, -1) : line); + lineStart = index + 1; + index = text.indexOf("\n", lineStart); + } + if (lineStart < text.length) { + unterminated.push(text.slice(lineStart)); + } + }; + input.on("data", onData); + return { close: () => void input.off("data", onData) }; +} + export class CodexAppServerClient { - private readonly rl: readline.Interface; + private readonly stdoutLineReader: { close(): void }; private readonly pending = new Map<number, PendingRequest>(); private readonly requestHandlers = new Map<string, RequestHandler>(); private notificationHandler: NotificationHandler | null = null; @@ -182,8 +215,7 @@ export class CodexAppServerClient { private readonly logger: Logger, private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}), ) { - this.rl = readline.createInterface({ input: child.stdout }); - this.rl.on("line", (line) => { + this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => { void this.handleLine(line).catch((error) => { this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line"); }); @@ -259,7 +291,7 @@ export class CodexAppServerClient { async dispose(): Promise<void> { this.disposed = true; this.unexpectedTerminationHandler = null; - this.rl.close(); + this.stdoutLineReader.close(); this.rejectPending(new Error("Codex app-server client is closed")); try { this.child.stdin.end(); @@ -286,7 +318,7 @@ export class CodexAppServerClient { return; } this.disposed = true; - this.rl.close(); + this.stdoutLineReader.close(); this.rejectPending(error); const handler = this.unexpectedTerminationHandler; this.unexpectedTerminationHandler = null; diff --git a/packages/server/src/server/agent/providers/custom-message.ts b/packages/server/src/server/agent/providers/custom-message.ts new file mode 100644 index 0000000000..3dd15b2d9c --- /dev/null +++ b/packages/server/src/server/agent/providers/custom-message.ts @@ -0,0 +1,26 @@ +import type { AgentTimelineItem } from "../agent-sdk-types.js"; + +/** Preserve extension context as an expandable tool row instead of assistant speech. */ +export function mapCustomMessageToToolCall( + message: { role: "custom"; customType?: unknown; details?: unknown }, + text: string, + callId: string, +): Extract<AgentTimelineItem, { type: "tool_call" }> { + const customType = + typeof message.customType === "string" && message.customType.trim() + ? message.customType + : "custom-message"; + return { + type: "tool_call", + callId, + name: customType, + status: "completed", + detail: { type: "plain_text", text }, + metadata: { + synthetic: true, + customType, + ...(message.details === undefined ? {} : { details: message.details }), + }, + error: null, + }; +} diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts new file mode 100644 index 0000000000..fdddb4941c --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts @@ -0,0 +1,213 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient archived history", () => { + test("replays an archived session whose working directory was removed", async () => { + await withFakeACPAgent(async ({ command, removedCwd, loadRequestsPath }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + const loadRequests = JSON.parse(await readFile(loadRequestsPath, "utf8")); + expect(loadRequests).toEqual([{ sessionId: "archived-session", cwd: removedCwd }]); + } finally { + await session.close(); + } + }); + }); + + test("replays an archived session whose working directory path is now a file", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + await writeFile(removedCwd, "not a directory", "utf8"); + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + } finally { + await session.close(); + } + }); + }); + + test("still requires the working directory to resume a session that will run", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + await expect( + client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "interactive" }, + ), + ).rejects.toThrow("ENOENT"); + }); + }); + + test("releases the provider process once an archived session's history is replayed", async () => { + await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + const pid = Number(await readFile(pidPath, "utf8")); + expect(isProcessAlive(pid)).toBe(false); + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + } finally { + await session.close(); + } + }); + }); + + test("keeps the provider process for an interactive resume", async () => { + await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => { + await mkdir(removedCwd, { recursive: true }); + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "interactive" }, + ); + try { + const pid = Number(await readFile(pidPath, "utf8")); + expect(isProcessAlive(pid)).toBe(true); + } finally { + await session.close(); + } + }); + }); +}); + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> { + const text: string[] = []; + for await (const event of events) { + if (event.type !== "timeline") { + continue; + } + if (event.item.type === "user_message" || event.item.type === "assistant_message") { + text.push(`${event.item.type}:${event.item.text}`); + } + } + return text; +} + +async function withFakeACPAgent( + run: (input: { + command: [string, ...string[]]; + removedCwd: string; + loadRequestsPath: string; + pidPath: string; + }) => Promise<void>, +): Promise<void> { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + const loadRequestsPath = path.join(testDir, "load-requests.json"); + const pidPath = path.join(testDir, "agent.pid"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run({ + command: [process.execPath, scriptPath, loadRequestsPath, pidPath], + removedCwd: path.join(testDir, "removed-worktree"), + loadRequestsPath, + pidPath, + }); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +const fakeACPAgentScript = ` +const fs = require("node:fs"); +const readline = require("node:readline"); + +const loadRequestsPath = process.argv[2]; +const pidPath = process.argv[3]; +if (pidPath) { + fs.writeFileSync(pidPath, String(process.pid)); +} +const loadRequests = []; +const rl = readline.createInterface({ input: process.stdin }); + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +function replay(sessionId, sessionUpdate, text) { + write({ + method: "session/update", + params: { sessionId, update: { sessionUpdate, content: { type: "text", text } } }, + }); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + write({ + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? 1, + agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } }, + }, + }); + return; + } + + if (message.method === "session/load") { + const { sessionId, cwd } = message.params; + loadRequests.push({ sessionId, cwd }); + fs.writeFileSync(loadRequestsPath, JSON.stringify(loadRequests)); + replay(sessionId, "user_message_chunk", "What is in the README?"); + replay(sessionId, "agent_message_chunk", "It describes the project."); + write({ id: message.id, result: {} }); + return; + } + + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.permission-turn-failure.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.permission-turn-failure.test.ts new file mode 100644 index 0000000000..aaa6a5110e --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.permission-turn-failure.test.ts @@ -0,0 +1,165 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient permission requests when a turn ends early", () => { + test.each([ + { ending: "fail", terminalEvent: "turn_failed" }, + { ending: "cancel", terminalEvent: "turn_canceled" }, + ] as const)( + "answers a permission request left open by a $terminalEvent turn with cancelled", + async ({ ending, terminalEvent }) => { + await withFakeACPAgent(ending, async (command, cwd) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + const session = await client.createSession({ provider: "acp", cwd }); + try { + const events = collectEvents(session); + + await session.startTurn("turn one"); + await waitForEvent(events, terminalEvent); + expect(hasEvent(events, "permission_requested")).toBe(true); + expect(session.getPendingPermissions()).toEqual([]); + + const replyStart = events.length; + await session.startTurn("turn two"); + await waitForEvent(events, "turn_completed", replyStart); + + expect(assistantText(events.slice(replyStart))).toBe( + 'perm-1 answered: {"outcome":{"outcome":"cancelled"}}', + ); + } finally { + await session.close(); + } + }); + }, + ); +}); + +function collectEvents(session: AgentSession): AgentStreamEvent[] { + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + return events; +} + +function hasEvent( + events: AgentStreamEvent[], + type: AgentStreamEvent["type"], + fromIndex = 0, +): boolean { + return events.slice(fromIndex).some((event) => event.type === type); +} + +async function waitForEvent( + events: AgentStreamEvent[], + type: AgentStreamEvent["type"], + fromIndex = 0, +): Promise<void> { + const deadline = Date.now() + 5_000; + while (!hasEvent(events, type, fromIndex)) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${type}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function assistantText(events: AgentStreamEvent[]): string { + return events + .flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], + ) + .join(""); +} + +async function withFakeACPAgent( + ending: "fail" | "cancel", + run: (command: [string, ...string[]], cwd: string) => Promise<void>, +): Promise<void> { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run([process.execPath, scriptPath, ending], testDir); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +// Turn one asks for permission, then fails or is cancelled while the request is +// still open. +// Later turns report whether that request was ever answered. +const fakeACPAgentScript = ` +const readline = require("node:readline"); + +const ending = process.argv[2]; +const rl = readline.createInterface({ input: process.stdin }); +let promptCount = 0; +let permissionAnswer = null; + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === undefined && message.id === "perm-1") { + permissionAnswer = message.result ?? { error: message.error }; + return; + } + if (message.method === "initialize") { + write({ id: message.id, result: { protocolVersion: 1, agentCapabilities: {} } }); + return; + } + if (message.method === "session/new") { + write({ id: message.id, result: { sessionId: "session-1" } }); + return; + } + if (message.method === "session/prompt") { + const sessionId = message.params.sessionId; + promptCount += 1; + if (promptCount === 1) { + const toolCall = { toolCallId: "tool-1", title: "echo hello", kind: "execute", status: "pending" }; + write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } }); + write({ + id: "perm-1", + method: "session/request_permission", + params: { + sessionId, + toolCall, + options: [ + { optionId: "allow", name: "Allow once", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }, + }); + setTimeout(() => { + if (ending === "cancel") { + write({ id: message.id, result: { stopReason: "cancelled" } }); + } else { + write({ id: message.id, error: { code: -32603, message: "turn failed with a permission open" } }); + } + }, 50); + return; + } + const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer); + write({ + method: "session/update", + params: { + sessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } }, + }, + }); + write({ id: message.id, result: { stopReason: "end_turn" } }); + return; + } + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts index 62b6c9ead9..7dfbb6abb8 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts @@ -154,6 +154,28 @@ describe("JsonlRpcProcess", () => { } }); + test("publishes unmatched responses without publishing the response that settles a request", async () => { + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); + const messages: Record<string, unknown>[] = []; + transport.onMessage((message) => messages.push(message)); + + try { + const { id, promise } = transport.startRequest({ type: "prompt" }); + const response = { id, type: "response", command: "prompt" }; + const rejection = { ...response, success: false, error: "rejected after acknowledgement" }; + const unknownResponse = { ...response, id: "unknown-request", success: true }; + child.stdout.write(`${JSON.stringify({ ...response, success: true })}\n`); + child.stdout.write(`${JSON.stringify(rejection)}\n`); + child.stdout.write(`${JSON.stringify(unknownResponse)}\n`); + + await expect(promise).resolves.toBeUndefined(); + expect(messages).toEqual([rejection, unknownResponse]); + } finally { + await transport.close(); + } + }); + test("rejects unsuccessful responses", async () => { const transport = startProcess(); diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index d58e2235ca..9d8b9af623 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -258,25 +258,27 @@ export class JsonlRpcProcess { this.frameDecoder.write(chunk); } - /** Route a complete logical frame to its consumer (response or subscriber). */ + /** Route a complete logical frame to its consumer (pending request or subscriber). */ private dispatchFrame(message: Record<string, unknown>): void { - if (message.type === "response") { - this.handleResponse(message as unknown as JsonlRpcResponse); + const pending = + message.type === "response" && typeof message.id === "string" + ? this.pending.get(message.id) + : undefined; + if (pending) { + this.settleRequest(pending, message as unknown as JsonlRpcResponse & { id: string }); return; } + // A response nothing is waiting for is published like any other frame, so a provider + // can read it as an event, such as OMP 18.3 rejecting a prompt after acknowledging it. for (const subscriber of this.messageSubscribers) { subscriber(message); } } - private handleResponse(response: JsonlRpcResponse): void { - if (!response.id) { - return; - } - const pending = this.pending.get(response.id); - if (!pending) { - return; - } + private settleRequest( + pending: PendingRequest, + response: JsonlRpcResponse & { id: string }, + ): void { if (pending.timer) { clearTimeout(pending.timer); } diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index fccc89224b..ce9714c5f2 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -232,6 +232,7 @@ type SteeringReplayShape = "claude" | "codex"; interface MockQuestionOption { label: string; description?: string; + preview?: string; } interface MockQuestionPromptQuestion { @@ -271,6 +272,16 @@ function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string | return match?.[1] ?? null; } +const ROLLOUT_FLAG_PREVIEW = [ + "Ship it dark behind the new-surface flag, then turn it on for internal users before everyone else.", + "", + 'if (flags.enabled("new-surface")) {', + " render(<NewSurface />);", + "}", + ...Array.from({ length: 40 }, (_, i) => `// rollout step ${i + 1}`), + "// LAST PREVIEW LINE", +].join("\n"); + function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null { const text = promptToText(prompt); if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) { @@ -309,7 +320,10 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe { question: "Which rollout should we use?", header: "rollout", - options: [{ label: "Immediately" }, { label: "Behind feature flag" }], + options: [ + { label: "Immediately" }, + { label: "Behind feature flag", preview: ROLLOUT_FLAG_PREVIEW }, + ], multiSelect: false, }, { @@ -737,6 +751,8 @@ export class MockLoadTestAgentSession implements AgentSession { readonly capabilities = CAPABILITIES; readonly features: AgentFeature[] = []; readonly id: string; + private readonly usageSessionKey = randomUUID(); + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); private readonly history: AgentStreamEvent[] = []; private readonly logger?: Logger; @@ -792,6 +808,15 @@ export class MockLoadTestAgentSession implements AgentSession { ); } + usageSession() { + return { + provider: this.provider, + model: this.modelId ?? undefined, + env: {}, + sessionKey: this.usageSessionKey, + }; + } + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> { const { turnId } = await this.startTurn(prompt, options); const turn = this.activeTurn; diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 640ead5d30..a1dc9130a8 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -215,6 +215,55 @@ function createToolCatalog(): PaseoToolCatalog { } describe("OMP agent client and session", () => { + test("failed manual compact closes its progress row and shows the error", async () => { + const omp = new OmpHarness(); + await omp.start(); + + const events = await omp.compact({}, new Error("compaction refused")); + + expect(events).toEqual([ + expect.objectContaining({ + item: { type: "compaction", status: "loading", trigger: "manual" }, + }), + expect.objectContaining({ + item: { type: "compaction", status: "completed", trigger: "manual" }, + }), + expect.objectContaining({ + item: { + type: "assistant_message", + text: "[Error] Failed to compact context: compaction refused", + }, + }), + ]); + }); + + test("manual compact shows its progress and publishes the compacted context usage", async () => { + const omp = new OmpHarness(); + await omp.start(); + + const events = await omp.compact({ + contextUsage: { tokens: 13_447, contextWindow: 272_000 }, + }); + + expect(events).toEqual([ + expect.objectContaining({ + type: "timeline", + item: { type: "compaction", status: "loading", trigger: "manual" }, + }), + expect.objectContaining({ + type: "timeline", + item: { type: "compaction", status: "completed", trigger: "manual" }, + }), + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ + contextWindowUsedTokens: 13_447, + contextWindowMaxTokens: 272_000, + }), + }), + ]); + }); + test("owns launch configuration and registers native host tools", async () => { const omp = new OmpHarness(); await omp.start({ modeId: "ask" }, createToolCatalog()); @@ -459,7 +508,9 @@ describe("OMP agent client and session", () => { test("fails a turn when the provider idle gate passes its deadline", async () => { const scheduler = new ManualIdleScheduler(); - const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 }); + // Long enough that the gate's first check, made right after the turn ends, cannot already + // be past it on a slow runner. + const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 50 }); await omp.start(); const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", { isStreaming: true, @@ -473,7 +524,7 @@ describe("OMP agent client and session", () => { args: { command: "sleep 30" }, }); expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]); - await new Promise((resolve) => setTimeout(resolve, 2)); + await new Promise((resolve) => setTimeout(resolve, 60)); scheduler.retry(); await expect(completion).rejects.toThrow(/provider idle/i); expect(omp.runningToolCallIds()).toEqual([]); @@ -662,6 +713,46 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test("keeps custom context in separate tools while a turn continues", async () => { + const omp = new OmpHarness(); + await omp.start(); + await omp.requireStartTurn("Explain the project"); + omp.runtime().beginTurn(); + for (const display of [true, false, true]) { + omp.emit({ + type: "message_end", + message: { + role: "custom", + customType: "project-context", + content: [{ type: "text", text: "Project instructions" }], + details: { project: "example" }, + display, + }, + }); + } + const items = omp.timeline(); + expect(items).toEqual( + [1, 2].map(() => ({ + type: "tool_call", + callId: expect.stringMatching(/^omp-custom-/), + name: "project-context", + status: "completed", + detail: { type: "plain_text", text: "Project instructions" }, + metadata: { + synthetic: true, + customType: "project-context", + details: { project: "example" }, + }, + error: null, + })), + ); + expect(new Set(items.map((item) => item.type === "tool_call" && item.callId)).size).toBe(2); + expect(omp.completedTurnCount()).toBe(0); + omp.runtime().finishTurn(); + await waitForImmediate(); + expect(omp.completedTurnCount()).toBe(1); + }); + test("omits live custom messages when display is false", async () => { const omp = new OmpHarness(); await omp.start(); @@ -705,11 +796,19 @@ describe("OMP agent client and session", () => { message: "Background job DocsSmokeTwo completed", }, ]); - // Non-notice custom messages still fall through as assistant messages with - // their own id so the stream coalescer never glues them onto the open reply. - expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([ + expect( + omp.timeline().filter((item) => item.type !== "notification" && item.type !== "user_message"), + ).toEqual([ { type: "assistant_message", text: "done", messageId: "omp-assistant-1" }, - { type: "assistant_message", text: "plain custom status text", messageId: "omp-custom-1" }, + { + type: "tool_call", + callId: expect.stringMatching(/^omp-custom-/), + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "plain custom status text" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, ]); }); @@ -879,6 +978,24 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test.each(["result after ack", "result before ack"] as const)( + "fails a prompt OMP rejects before its agent runs (%s)", + async (order) => { + const omp = new OmpHarness(); + await omp.start(); + + await expect( + omp.runPromptRejectedBeforeAgentRuns( + "Reply with ok.", + "No API key found for anthropic.", + order, + ), + ).rejects.toThrow("No API key found for anthropic."); + expect(omp.turnFailures()).toEqual(["No API key found for anthropic."]); + expect(omp.completedTurnCount()).toBe(0); + }, + ); + test("completes a no-turn notify with one notification and no assistant text", async () => { const scheduler = new ManualNoTurnScheduler(); const omp = new OmpHarness({ noTurnScheduler: scheduler }); @@ -1170,6 +1287,25 @@ describe("OMP agent client and session", () => { expect(omp.runtime().prompts).toEqual([{ message: "continue", imageCount: 0 }]); }); + test("ignores a prompt rejection a previous OMP process left for a reused request id", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.emit({ + type: "prompt_result", + id: "req_1", + agentInvoked: false, + status: "error", + error: { message: "No API key found for anthropic." }, + }); + omp.processExit("OMP RPC process exited with code 1 and signal null"); + + await omp.runPromptWithoutTurnOnNextRuntime("/session", "req_1"); + + expect(omp.runtimeLaunches()).toHaveLength(2); + expect(omp.turnFailures()).toEqual(["OMP RPC process exited with code 1 and signal null"]); + expect(omp.completedTurnCount()).toBe(1); + }); + test("reports an immediate relaunch failure without retrying in a loop", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index a04b4c8fb0..5413364cdb 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -1,3 +1,4 @@ +import { mapCustomMessageToToolCall } from "../custom-message.js"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; @@ -69,11 +70,7 @@ import { } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; -import { - ompCustomMessageId, - ompSkillPromptUserText, - shouldDisplayOmpCustomMessage, -} from "./custom-message.js"; +import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; import { mapOmpSystemNoticeToNotification } from "./system-notice.js"; import { materializeProviderImage } from "../provider-image-output.js"; @@ -175,6 +172,21 @@ interface OmpPersistenceMetadata { bridgedTools: Map<string, OmpBridgedToolIdentity>; } +/** OMP's outcome for one prompt, correlated by the prompt's request id. */ +interface OmpPromptResult { + agentInvoked: boolean; + /** Why OMP rejected the prompt before its agent ran, such as a missing API key. */ + error: string | null; +} + +function promptResultError(result: { + status?: string; + error?: { message: string }; +}): string | null { + if (result.status !== "error") return null; + return result.error?.message || "OMP rejected the prompt"; +} + interface StartTurnResult { turnId: string; } @@ -698,12 +710,11 @@ export class OmpAgentSession implements AgentSession { private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = []; private activePromptRequestId: string | null = null; private activePromptAgentInvoked: boolean | null = null; - private readonly pendingPromptResults = new Map<string, boolean>(); + private activePromptError: string | null = null; + private readonly pendingPromptResults = new Map<string, OmpPromptResult>(); private pendingNoTurnCompletionAbort: AbortController | null = null; private lastKnownThinkingOptionId: string | null; - private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; - private outOfBandCompactionStarted = false; - private outOfBandCompactionCompleted = false; + private manualCompactionRunning = false; private commandCache: AgentSlashCommand[] | null = null; private readonly subagentIndex = new OmpSubagentIndex(); private readonly subagentCardTracker: OmpSubagentCardTracker; @@ -720,7 +731,6 @@ export class OmpAgentSession implements AgentSession { private closed = false; private live: boolean; private readonly emittedUserMessageIds = new Set<string>(); - private customMessageIndex = 0; private readonly usageSessionKey = randomUUID(); @@ -869,12 +879,13 @@ export class OmpAgentSession implements AgentSession { if (ack.requestId) { this.pendingPromptResults.delete(ack.requestId); } - this.activePromptAgentInvoked = correlatedResult ?? ack.agentInvoked ?? null; - if (correlatedResult === false) { + this.activePromptAgentInvoked = correlatedResult?.agentInvoked ?? ack.agentInvoked ?? null; + this.activePromptError = correlatedResult?.error ?? null; + if (correlatedResult?.agentInvoked === false) { this.scheduleNoTurnPromptCompletion(turnId); return; } - if (correlatedResult !== true && ack.agentInvoked === false) { + if (correlatedResult?.agentInvoked !== true && ack.agentInvoked === false) { await this.completeNoTurnPrompt(turnId); return; } @@ -1032,6 +1043,9 @@ export class OmpAgentSession implements AgentSession { const state = await next.getState(); if (this.closed) throw new Error("OMP session is closed"); this.unsubscribeRuntime?.(); + // Request ids restart with each OMP process, so results held for the old one cannot + // correlate with prompts sent to the new one. + this.pendingPromptResults.clear(); this.runtimeSession = next; this.hostTools = restarted.hostTools; this.state = state; @@ -1313,7 +1327,14 @@ export class OmpAgentSession implements AgentSession { ) { return; } + const error = this.activePromptError; this.emitBufferedNoTurnOutputs(turnId); + if (error) { + this.usagePoller.stopTurn(); + this.resetActiveTurn({ terminalizeWork: true }); + this.emit({ type: "turn_failed", provider: this.provider, turnId, error }); + return; + } this.completeTurn(turnId, []); } @@ -1322,6 +1343,7 @@ export class OmpAgentSession implements AgentSession { this.activeNoTurnPromptText = null; this.activePromptRequestId = null; this.activePromptAgentInvoked = null; + this.activePromptError = null; this.pendingNoTurnOutputs.splice(0, this.pendingNoTurnOutputs.length); } @@ -1383,30 +1405,24 @@ export class OmpAgentSession implements AgentSession { customInstructions: string | undefined, emit: (event: AgentStreamEvent) => void, ): Promise<void> { - if (this.outOfBandCompactionEmit) { + if (this.manualCompactionRunning) { throw new Error("An OMP compact command is already running"); } - this.outOfBandCompactionEmit = emit; - this.outOfBandCompactionStarted = false; - this.outOfBandCompactionCompleted = false; + // OMP answers a manual compact with its RPC response alone, without + // compaction_start or compaction_end, so the request owns both rows. + this.manualCompactionRunning = true; + const emitCompaction = (status: "loading" | "completed") => + emit({ + type: "timeline", + provider: this.provider, + item: { type: "compaction", status, trigger: "manual" }, + }); + emitCompaction("loading"); try { await this.runtimeSession.compact(customInstructions); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if ( - this.outOfBandCompactionEmit === emit && - this.outOfBandCompactionStarted && - !this.outOfBandCompactionCompleted - ) { - this.emitCompactionTimeline({ - turnId: undefined, - item: { - type: "compaction", - status: "completed", - trigger: "manual", - }, - }); - } + emitCompaction("completed"); emit({ type: "timeline", provider: this.provider, @@ -1415,13 +1431,12 @@ export class OmpAgentSession implements AgentSession { text: `[Error] Failed to compact context: ${message}`, }, }); + return; } finally { - if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) { - this.outOfBandCompactionEmit = null; - this.outOfBandCompactionStarted = false; - this.outOfBandCompactionCompleted = false; - } + this.manualCompactionRunning = false; } + emitCompaction("completed"); + await this.usagePoller.refresh(); } private async executeAutoCompactCommand( @@ -1719,15 +1734,17 @@ export class OmpAgentSession implements AgentSession { ? event.agentInvoked : undefined; if (requestId && agentInvoked !== undefined) { + const result = { agentInvoked, error: promptResultError(event) }; if (requestId === this.activePromptRequestId && this.activeTurnId) { this.activePromptAgentInvoked = agentInvoked; + this.activePromptError = result.error; if (agentInvoked === false) { this.scheduleNoTurnPromptCompletion(this.activeTurnId); } else { this.cancelNoTurnPromptCompletion(); } } else if (this.activePromptRequestId === null) { - this.pendingPromptResults.set(requestId, agentInvoked); + this.pendingPromptResults.set(requestId, result); } } return; @@ -1827,24 +1844,8 @@ export class OmpAgentSession implements AgentSession { return; } case "compaction_start": - this.emitCompactionTimeline({ - turnId, - item: { - type: "compaction", - status: "loading", - trigger: event.reason === "manual" ? "manual" : "auto", - }, - }); - return; case "compaction_end": - this.emitCompactionTimeline({ - turnId, - item: { - type: "compaction", - status: "completed", - trigger: event.reason === "manual" ? "manual" : "auto", - }, - }); + this.handleCompactionEvent(event, turnId); return; case "agent_end": { const messages = event.messages ?? []; @@ -1898,35 +1899,24 @@ export class OmpAgentSession implements AgentSession { } } - private emitCompactionTimeline(input: { - turnId: string | undefined; - item: Extract<AgentStreamEvent, { type: "timeline" }>["item"]; - }): void { - const emitOutOfBand = this.outOfBandCompactionEmit; - if (emitOutOfBand && input.item.type === "compaction") { - if (input.item.status === "loading") { - this.outOfBandCompactionStarted = true; - } - if (input.item.status === "completed") { - this.outOfBandCompactionCompleted = true; - } - } - const event: AgentStreamEvent = { - type: "timeline", - provider: this.provider, - ...(emitOutOfBand ? {} : { turnId: input.turnId }), - item: input.item, - }; - if (emitOutOfBand) { - emitOutOfBand(event); - if (input.item.type === "compaction" && input.item.status === "completed") { - this.outOfBandCompactionEmit = null; - this.outOfBandCompactionStarted = false; - this.outOfBandCompactionCompleted = false; - } + private handleCompactionEvent( + event: Extract<OmpAgentSessionEvent, { type: "compaction_start" | "compaction_end" }>, + turnId: string | undefined, + ): void { + // A manual compact emits its own rows around the request. + if (this.manualCompactionRunning) { return; } - this.emit(event); + this.emit({ + type: "timeline", + provider: this.provider, + turnId, + item: { + type: "compaction", + status: event.type === "compaction_start" ? "loading" : "completed", + trigger: event.reason === "manual" ? "manual" : "auto", + }, + }); } private handleMessageUpdate( @@ -2000,14 +1990,8 @@ export class OmpAgentSession implements AgentSession { type: "timeline", provider: this.provider, turnId, - item: item ?? { - type: "assistant_message", - text, - messageId: ompCustomMessageId(event.message, () => { - this.customMessageIndex += 1; - return this.customMessageIndex; - }), - }, + item: + item ?? mapCustomMessageToToolCall(event.message, text, `omp-custom-${randomUUID()}`), }); } } diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index ebe99ead03..2deae45850 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -227,6 +227,95 @@ describe("OMP CLI runtime", () => { expect(eventTypes).toEqual(["notice"]); }); + test("emits agent_end for a run that contains an OMP developer message", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const eventTypes: string[] = []; + session.onEvent((event) => eventTypes.push(event.type)); + + // OMP 18.5 injects a developer reminder mid-run when a tool call matches a rule. + const reminder = { + role: "developer", + content: [ + { type: "text", text: '<system-reminder reason="rule_violation">…</system-reminder>' }, + ], + attribution: "agent", + timestamp: 2, + }; + child.stdout.write(`${JSON.stringify({ type: "message_end", message: reminder })}\n`); + child.stdout.write( + `${JSON.stringify({ + type: "agent_end", + messages: [ + { role: "user", content: [{ type: "text", text: "Write tiny.ts" }], timestamp: 1 }, + reminder, + { + role: "assistant", + content: [{ type: "text", text: "Done" }], + stopReason: "stop", + timestamp: 3, + }, + ], + })}\n`, + ); + + expect(eventTypes).toEqual(["message_end", "agent_end"]); + }); + + test("emits agent_end for a run that contains every OMP message role Paseo does not render", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const eventTypes: string[] = []; + session.onEvent((event) => eventTypes.push(event.type)); + + // A prompt that @-mentions a file makes OMP add a fileMention message to the run. + const fileMention = { + role: "fileMention", + files: [{ path: "notes.md", content: "# Notes\n", lineCount: 1 }], + timestamp: 2, + }; + child.stdout.write(`${JSON.stringify({ type: "message_end", message: fileMention })}\n`); + child.stdout.write( + `${JSON.stringify({ + type: "agent_end", + messages: [ + { role: "user", content: [{ type: "text", text: "Read @notes.md" }], timestamp: 1 }, + fileMention, + { + role: "pythonExecution", + code: "print(1)", + output: "1\n", + cancelled: false, + truncated: false, + timestamp: 3, + }, + { + role: "hookMessage", + customType: "hook", + content: "hook output", + display: false, + timestamp: 4, + }, + { role: "branchSummary", summary: "Earlier branch", fromId: "entry-1", timestamp: 5 }, + { + role: "compactionSummary", + summary: "Earlier turns", + tokensBefore: 1000, + timestamp: 6, + }, + { + role: "assistant", + content: [{ type: "text", text: "Done" }], + stopReason: "stop", + timestamp: 7, + }, + ], + })}\n`, + ); + + expect(eventTypes).toEqual(["message_end", "agent_end"]); + }); + test("lists commands through get_available_commands", async () => { const child = createOmpChild(); const commandTypes: string[] = []; @@ -352,6 +441,31 @@ describe("OMP CLI runtime", () => { await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" }); }); + test("reports OMP 18.3's late prompt rejection as a failed prompt result", async () => { + const child = createOmpChild(); + onOmpCommand(child, (command) => { + if (command.type !== "prompt") return; + const response = { id: command.id, type: "response", command: "prompt" }; + const rejection = { ...response, success: false, error: "No API key found for anthropic." }; + child.stdout.write( + `${JSON.stringify({ ...response, success: true })}\n${JSON.stringify(rejection)}\n`, + ); + }); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const events: unknown[] = []; + session.onEvent((event) => events.push(event)); + + await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" }); + + expect(events).toContainEqual({ + type: "prompt_result", + id: "req_1", + agentInvoked: false, + status: "error", + error: { message: "No API key found for anthropic." }, + }); + }); + test("negotiates RPC protocol v2 when OMP advertises it", async () => { const child = createOmpChild({ supportedProtocolVersions: [1, 2] }); const commands: Record<string, unknown>[] = []; diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index 51d340ab2c..75bd79f5f0 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -27,7 +27,7 @@ import { OmpModelsResultSchema, OmpPromptAckSchema, OmpRpcCommandSchema, - OmpRuntimeEventSchema, + parseOmpRuntimeEvent, OmpSessionStateSchema, OmpSessionStatsSchema, type OmpThinkingLevel, @@ -121,9 +121,9 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { private readonly launchEnvironment: Record<string, string>, ) { process.onMessage((message) => { - const event = OmpRuntimeEventSchema.safeParse(message); - if (event.success) { - this.emit(event.data); + const event = parseOmpRuntimeEvent(message); + if (event) { + this.emit(event); } }); process.onExit(({ error }) => { diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 02edb43af6..e64a93fd64 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -27,6 +27,40 @@ async function collectHistory( } describe("OMP history mapper", () => { + test("renders visible custom messages as completed tools with their type and content", async () => { + const events = await collectHistory([ + { + role: "custom", + customType: "project-context", + content: "Project instructions", + display: true, + }, + { + role: "custom", + customType: "private-context", + content: "Hidden instructions", + display: false, + }, + { + role: "custom", + customType: "project-context", + content: "Project instructions", + display: true, + }, + ]); + expect(events.map((event) => event.item)).toEqual( + [1, 2].map((index) => ({ + type: "tool_call", + callId: `omp-custom-${index}`, + name: "project-context", + status: "completed", + detail: { type: "plain_text", text: "Project instructions" }, + metadata: { synthetic: true, customType: "project-context" }, + error: null, + })), + ); + }); + test("replays a web search details error as failed when OMP sets isError false", async () => { const events = await collectHistory([ { @@ -203,10 +237,101 @@ describe("OMP history mapper", () => { for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) events.push(event); expect(events.map((event) => event.item)).toEqual([ + { + type: "tool_call", + callId: "omp-custom-1", + name: "custom-message", + status: "completed", + detail: { + type: "plain_text", + text: "[developer] External instruction", + }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, + ]); + }); + + test("replays a rule-violation run with the same rows the live timeline showed", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-rule-reminder-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root" }, + { + type: "message", + id: "prompt", + parentId: "root", + message: { + role: "user", + content: "Read @notes.md, then write tiny.ts", + }, + }, + { + type: "message", + id: "mention", + parentId: "prompt", + message: { + role: "fileMention", + files: [ + { + path: "notes.md", + content: "[notes.md#468D]\n1:# notes", + lineCount: 1, + }, + ], + }, + }, + { + type: "ttsr_injection", + id: "injection", + parentId: "mention", + injectedRules: ["ts-no-tiny-functions"], + }, + { + type: "message", + id: "rule-reminder", + parentId: "injection", + message: { + role: "developer", + content: [ + { + type: "text", + text: '<system-reminder reason="rule_violation" rule="ts-no-tiny-functions" path="builtin-defaults:ts-no-tiny-functions">\nAvoid tiny functions.\n</system-reminder>', + }, + ], + }, + }, + { + type: "message", + id: "answer", + parentId: "rule-reminder", + message: { + role: "assistant", + content: [{ type: "text", text: "FINAL_ANSWER" }], + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ + sessionFile, + provider: "omp", + })) + events.push(event); + expect(events.map((event) => event.item)).toEqual([ + { + type: "user_message", + text: "Read @notes.md, then write tiny.ts", + messageId: "prompt", + }, { type: "assistant_message", - text: "[developer] External instruction", - messageId: "omp-custom-1", + text: "FINAL_ANSWER", + messageId: "omp-history-assistant-1", }, ]); }); @@ -408,18 +533,26 @@ describe("OMP history mapper", () => { type: "timeline", provider: "omp", item: { - type: "assistant_message", - text: "visible explicit custom", - messageId: "omp-custom-1", + type: "tool_call", + callId: "omp-custom-1", + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "visible explicit custom" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, }, }, { type: "timeline", provider: "omp", item: { - type: "assistant_message", - text: "visible legacy custom", - messageId: "omp-custom-2", + type: "tool_call", + callId: "omp-custom-2", + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "visible legacy custom" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, }, }, { @@ -651,11 +784,26 @@ describe("OMP history mapper", () => { expect(events.map((event) => event.item)).toEqual([ { type: "user_message", text: "active branch", messageId: "user-active" }, { - type: "assistant_message", - text: "[future_control] Unsupported history record", - messageId: "omp-custom-1", + type: "tool_call", + callId: "omp-custom-1", + name: "custom-message", + status: "completed", + detail: { + type: "plain_text", + text: "[future_control] Unsupported history record", + }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, + { + type: "tool_call", + callId: "omp-custom-2", + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "[developer] developer note" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, }, - { type: "assistant_message", text: "[developer] developer note", messageId: "omp-custom-2" }, ]); const omp = new FakeOmp(); @@ -681,6 +829,64 @@ describe("OMP history mapper", () => { ]); }); + test("replays a compaction entry as a completed compaction row", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-compaction-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root", parentId: null }, + { + type: "message", + id: "answer-1", + parentId: "root", + message: { + role: "assistant", + content: [{ type: "text", text: "Before." }], + responseId: "resp-1", + }, + }, + { + type: "compaction", + id: "compaction-1", + parentId: "answer-1", + timestamp: "2026-10-05T04:31:19.537Z", + summary: "## Goal\nSummary", + shortSummary: "Summary", + firstKeptEntryId: "answer-1", + tokensBefore: 77611, + tokensAfter: 37526, + method: "snapcompact", + details: { readFiles: [], modifiedFiles: [] }, + fromExtension: false, + }, + { + type: "message", + id: "answer-2", + parentId: "compaction-1", + message: { + role: "assistant", + content: [{ type: "text", text: "After." }], + responseId: "resp-2", + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) { + events.push(event); + } + expect(events.map((event) => event.item)).toEqual([ + { type: "assistant_message", text: "Before.", messageId: "resp-1" }, + { type: "compaction", status: "completed", preTokens: 77611 }, + { type: "assistant_message", text: "After.", messageId: "resp-2" }, + ]); + expect(events[1]).toMatchObject({ type: "timeline", timestamp: "2026-10-05T04:31:19.537Z" }); + }); + test("maps omp 18.1 custom_message entries like live custom messages", async () => { const dir = mkdtempSync(join(tmpdir(), "omp-custom-message-history-")); const sessionFile = join(dir, "session.jsonl"); @@ -758,11 +964,30 @@ describe("OMP history mapper", () => { expect(events.map((event) => event.item)).toEqual([ { type: "assistant_message", text: "Done.", messageId: "resp-1" }, { type: "user_message", text: "/skill:commit", messageId: "omp-custom-skill-1-user" }, - { type: "assistant_message", text: ircMessage, messageId: "omp-custom-irc-1" }, { - type: "assistant_message", - text: "visible without display flag", - messageId: "omp-custom-legacy-1", + type: "tool_call", + callId: "omp-custom-irc-1", + name: "irc:incoming", + status: "completed", + detail: { type: "plain_text", text: ircMessage }, + metadata: { + synthetic: true, + customType: "irc:incoming", + details: { from: "worker-1", message: "ready for review" }, + }, + error: null, + }, + { + type: "tool_call", + callId: "omp-custom-legacy-1", + name: "legacy-no-display", + status: "completed", + detail: { + type: "plain_text", + text: "visible without display flag", + }, + metadata: { synthetic: true, customType: "legacy-no-display" }, + error: null, }, ]); }); @@ -821,18 +1046,44 @@ describe("OMP history mapper", () => { type: "timeline", provider: "omp", item: { - type: "assistant_message", - text: '[IMPORTANT: Agent invoked the "improve" skill.]', - messageId: "omp-custom-skill-agent", + type: "tool_call", + callId: "omp-custom-skill-agent", + name: "skill-prompt", + status: "completed", + detail: { + type: "plain_text", + text: '[IMPORTANT: Agent invoked the "improve" skill.]', + }, + metadata: { + synthetic: true, + customType: "skill-prompt", + details: { + name: "improve", + path: "/home/me/.agents/skills/improve/SKILL.md", + lineCount: 9, + }, + }, + error: null, }, }, { type: "timeline", provider: "omp", item: { - type: "assistant_message", - text: "<irc>\n<from>worker-1</from>\n<message>hi</message>\n</irc>", - messageId: "omp-custom-irc-user", + type: "tool_call", + callId: "omp-custom-irc-user", + name: "irc:incoming", + status: "completed", + detail: { + type: "plain_text", + text: "<irc>\n<from>worker-1</from>\n<message>hi</message>\n</irc>", + }, + metadata: { + synthetic: true, + customType: "irc:incoming", + details: { from: "worker-1", message: "hi" }, + }, + error: null, }, }, ]); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index adca06a45d..2c818dfc5a 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -20,6 +20,10 @@ interface OmpSessionEntry { [key: string]: unknown; } +type OmpReplayEntry = + | { entry: OmpSessionEntry; message: OmpAgentMessage } + | { entry: OmpSessionEntry; item: AgentTimelineItem }; + export async function readOmpHistoryTodoState( sessionFile: string, ): Promise<AgentTimelineItem | null> { @@ -90,13 +94,17 @@ export async function* streamOmpHistory(input: { throw error; } const messages: OmpAgentMessage[] = []; - const messageEntries: OmpSessionEntry[] = []; + const replayEntries: OmpReplayEntry[] = []; const userEntries: OmpCapturedUserMessageEntry[] = []; for (const entry of entries) { + if (entry.type === "compaction") { + replayEntries.push({ entry, item: mapCompactionEntry(entry) }); + continue; + } const mapped = mapEntryMessage(entry); if (!mapped) continue; messages.push(mapped); - messageEntries.push(entry); + replayEntries.push({ entry, message: mapped }); if (mapped.role === "user" && entry.id) { userEntries.push({ id: entry.id, text: textOf(mapped.content) }); } @@ -107,9 +115,13 @@ export async function* streamOmpHistory(input: { OMP_HISTORY_MAPPER_HOOKS, input.bridgedTools, ); - for (let index = 0; index < messages.length; index += 1) { - const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp); - for (const event of mapper.mapMessages([messages[index]!])) { + for (const replayEntry of replayEntries) { + const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp); + const events: AgentStreamEvent[] = + "item" in replayEntry + ? [{ type: "timeline", provider: input.provider, item: replayEntry.item }] + : mapper.mapMessages([replayEntry.message]); + for (const event of events) { yield timestamp && event.type === "timeline" ? { ...event, timestamp } : event; } } @@ -360,8 +372,7 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { content.length === 1 && content[0]?.type === "text" && typeof content[0].text === "string" && - content[0].text.trimStart().startsWith("<system-reminder>") && - content[0].text.trimEnd().endsWith("</system-reminder>") + isSystemReminder(content[0].text) ) { return null; } @@ -370,6 +381,19 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { if (["user", "assistant", "toolResult", "custom", "bashExecution"].includes(message.role)) { return message as unknown as OmpAgentMessage; } + // OMP roles the live timeline does not render either, such as the fileMention that carries + // an @-mentioned file's content. + if ( + [ + "pythonExecution", + "hookMessage", + "branchSummary", + "compactionSummary", + "fileMention", + ].includes(message.role) + ) { + return null; + } return visibleFallback(message.role, message); } if (entry.type === "custom_message") { @@ -381,6 +405,16 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { return visibleFallback(entry.type, entry); } +// OMP persists a compaction as a top-level entry; the live timeline shows it as a compaction row. +// The entry does not record whether a manual /compact or auto-compaction produced it. +function mapCompactionEntry(entry: OmpSessionEntry): AgentTimelineItem { + return { + type: "compaction", + status: "completed", + ...(typeof entry.tokensBefore === "number" ? { preTokens: entry.tokensBefore } : {}), + }; +} + // omp 18.1+ persists injected rows (skill prompts, hub messages, job notices) as top-level // custom_message entries without a message object; replay them as live custom messages function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null { @@ -399,6 +433,13 @@ function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null { } as OmpAgentMessage; } +// OMP's rule-violation reminder carries attributes: <system-reminder reason="rule_violation" ...> +function isSystemReminder(text: string): boolean { + return ( + /^<system-reminder[\s>]/.test(text.trimStart()) && text.trimEnd().endsWith("</system-reminder>") + ); +} + function isControlEntryType(type: string): boolean { return ( type === "session" || @@ -410,6 +451,7 @@ function isControlEntryType(type: string): boolean { type === "system_prompt" || type === "model_change" || type === "thinking_level_change" || + type === "ttsr_injection" || type === "tool_execution" || type.startsWith("tool_execution_") ); diff --git a/packages/server/src/server/agent/providers/omp/message-history.ts b/packages/server/src/server/agent/providers/omp/message-history.ts index 112a942b64..1950502c89 100644 --- a/packages/server/src/server/agent/providers/omp/message-history.ts +++ b/packages/server/src/server/agent/providers/omp/message-history.ts @@ -1,3 +1,4 @@ +import { mapCustomMessageToToolCall } from "../custom-message.js"; import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js"; import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js"; import type { OmpBridgedToolIdentity } from "./mcp-bridge.js"; @@ -155,7 +156,7 @@ export class OmpHistoryMapper { { type: "timeline", provider: this.provider, - item: { type: "assistant_message", text, messageId }, + item: mapCustomMessageToToolCall(message, text, messageId), }, ]; } diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 4c69f2fbeb..b0514c7863 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -41,6 +41,13 @@ const OmpUserMessageSchema = z content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]), }) .passthrough(); +// OMP injects hidden developer messages, such as rule-violation reminders, into a run. +const OmpDeveloperMessageSchema = z + .object({ + role: z.literal("developer"), + content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]), + }) + .passthrough(); const OmpCustomMessageSchema = z .object({ role: z.literal("custom"), @@ -79,13 +86,30 @@ const OmpBashExecutionMessageSchema = z timestamp: z.number(), }) .passthrough(); +// Roles Paseo does not render. They are listed so that a frame carrying one, such as +// agent_end, still parses; every role in OMP's RPC AgentMessage must appear in the union. +const OmpPythonExecutionMessageSchema = z + .object({ role: z.literal("pythonExecution") }) + .passthrough(); +const OmpHookMessageSchema = z.object({ role: z.literal("hookMessage") }).passthrough(); +const OmpBranchSummaryMessageSchema = z.object({ role: z.literal("branchSummary") }).passthrough(); +const OmpCompactionSummaryMessageSchema = z + .object({ role: z.literal("compactionSummary") }) + .passthrough(); +const OmpFileMentionMessageSchema = z.object({ role: z.literal("fileMention") }).passthrough(); export const OmpAgentMessageSchema = z.discriminatedUnion("role", [ OmpUserMessageSchema, + OmpDeveloperMessageSchema, OmpCustomMessageSchema, OmpAssistantMessageSchema, OmpToolResultMessageSchema, OmpBashExecutionMessageSchema, + OmpPythonExecutionMessageSchema, + OmpHookMessageSchema, + OmpBranchSummaryMessageSchema, + OmpCompactionSummaryMessageSchema, + OmpFileMentionMessageSchema, ]); export const OmpModelThinkingSchema = z @@ -480,6 +504,8 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [ type: z.literal("prompt_result"), id: z.string().optional(), agentInvoked: z.boolean().optional(), + status: z.string().optional(), + error: z.object({ message: z.string() }).passthrough().optional(), }) .passthrough(), z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(), @@ -609,6 +635,36 @@ export type OmpSubagentEventPayload = z.infer<typeof OmpSubagentEventPayloadSche export type OmpAssistantMessageEvent = z.infer<typeof OmpAssistantMessageEventSchema>; export type OmpAgentSessionEvent = z.infer<typeof OmpAgentSessionEventSchema>; export type OmpRuntimeEvent = z.infer<typeof OmpRuntimeEventSchema>; + +/** + * COMPAT(ompLatePromptRejection): added in v0.11.0, remove after 2027-04-05 once the + * minimum supported OMP version emits prompt_result errors. + * OMP 18.3 reports a prompt it rejects after acknowledging it, such as one whose model has + * no API key, only as a second failed `response` frame for the prompt id. Later releases + * report the same outcome as a `prompt_result` with `status: "error"`. + */ +const OmpLatePromptRejectionSchema = z.object({ + type: z.literal("response"), + command: z.literal("prompt"), + id: z.string(), + success: z.literal(false), + error: z.string(), +}); + +export function parseOmpRuntimeEvent(message: unknown): OmpRuntimeEvent | null { + const rejection = OmpLatePromptRejectionSchema.safeParse(message); + if (rejection.success) { + return { + type: "prompt_result", + id: rejection.data.id, + agentInvoked: false, + status: "error", + error: { message: rejection.data.error }, + }; + } + const event = OmpRuntimeEventSchema.safeParse(message); + return event.success ? event.data : null; +} export type OmpTodoItem = z.infer<typeof OmpTodoItemSchema>; export type OmpTodoPhase = z.infer<typeof OmpTodoPhaseSchema>; export type OmpTodoReminderEvent = z.infer<typeof OmpTodoReminderEventSchema>; diff --git a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts index 403a389647..58688c245d 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts @@ -168,7 +168,6 @@ export class FakeOmpSession implements OmpRuntimeSession { subagents: FakeOmpSubagentSnapshot[] = []; readonly subagentSubscriptionErrors = new Map<FakeOmpSubagentSubscriptionLevel, Error>(); compactError: Error | null = null; - emitCompactEnd = true; getStateError: Error | null = null; promptAck: OmpPromptAck = {}; branchResponse: { text?: string; cancelled?: boolean } = { text: "" }; @@ -266,11 +265,8 @@ export class FakeOmpSession implements OmpRuntimeSession { } async compact(customInstructions?: string): Promise<void> { + // Like OMP, answer a manual compact with the response alone, no compaction events. this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); - this.emit({ type: "compaction_start", reason: "manual" }); - if (this.emitCompactEnd) { - this.emit({ type: "compaction_end", reason: "manual" }); - } if (this.compactError) { throw this.compactError; } diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index f7f0039257..8be53f1742 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -19,7 +19,12 @@ import { type OmpProviderIdleScheduler, } from "../agent.js"; import type { OmpUsagePollScheduler } from "../usage-poller.js"; -import type { OmpAgentMessage, OmpRpcSlashCommand, OmpRuntimeEvent } from "../rpc-types.js"; +import type { + OmpAgentMessage, + OmpRpcSlashCommand, + OmpRuntimeEvent, + OmpSessionStats, +} from "../rpc-types.js"; import { FakeOmp } from "./fake-omp.js"; const CWD = "/tmp/paseo-omp-agent-test"; @@ -169,6 +174,18 @@ export class OmpHarness { return events; } + async compact(statsAfter: OmpSessionStats, error?: Error): Promise<AgentStreamEvent[]> { + const handler = this.requireSession().tryHandleOutOfBand("/compact"); + if (!handler) throw new Error("OMP session did not handle /compact out-of-band"); + const runtime = this.omp.latestSession(); + const sessionEventCount = this.events.length; + const emitted: AgentStreamEvent[] = []; + runtime.stats = statsAfter; + runtime.compactError = error ?? null; + await handler.run({ emit: (event) => emitted.push(event) }); + return [...emitted, ...this.events.slice(sessionEventCount)]; + } + async runPrompt( input: string, output: string, @@ -294,6 +311,14 @@ export class OmpHarness { return await run; } + async runPromptWithoutTurnOnNextRuntime(input: string, requestId: string): Promise<unknown> { + const session = this.requireSession(); + this.omp.queueSessionSetup((runtime) => { + runtime.promptAck = { requestId, agentInvoked: false }; + }); + return await session.run(input); + } + async runPromptWithoutTurn(input: string): Promise<unknown> { const session = this.requireSession(); this.omp.latestSession().promptAck = { agentInvoked: false }; @@ -329,6 +354,31 @@ export class OmpHarness { return { completed: () => isCompleted, completion }; } + async runPromptRejectedBeforeAgentRuns( + input: string, + error: string, + order: "result after ack" | "result before ack", + ): Promise<unknown> { + const session = this.requireSession(); + const runtime = this.omp.latestSession(); + runtime.promptAck = { requestId: "prompt-rejected" }; + const rejection = { + type: "prompt_result", + id: "prompt-rejected", + agentInvoked: false, + status: "error", + sessionSettled: true, + error: { message: error, retryable: false }, + } as const; + const promptStarted = runtime.nextPrompt(); + const run = session.run(input); + if (order === "result before ack") runtime.emit(rejection); + await promptStarted; + await waitForImmediate(); + if (order === "result after ack") runtime.emit(rejection); + return await run; + } + async runPromptAfterCorrelatedTrueResult( input: string, output: string, diff --git a/packages/server/src/server/agent/providers/omp/usage-poller.test.ts b/packages/server/src/server/agent/providers/omp/usage-poller.test.ts index 9750ec59ef..56b9dc667a 100644 --- a/packages/server/src/server/agent/providers/omp/usage-poller.test.ts +++ b/packages/server/src/server/agent/providers/omp/usage-poller.test.ts @@ -36,6 +36,27 @@ function deferred<T>() { } describe("OMP usage poller", () => { + test("refresh publishes usage without ending an active turn's polling", async () => { + const scheduler = new ManualPollScheduler(); + const used: Array<number | undefined> = []; + let stats: OmpSessionStats = { contextUsage: { contextWindow: 200_000, tokens: 9_000 } }; + const poller = new OmpUsagePoller({ + scheduler, + readStats: async () => stats, + onUsage: (update) => used.push(update.contextWindowUsedTokens), + onPollError: (error) => { + throw error; + }, + }); + + poller.startTurn(); + stats = { contextUsage: { contextWindow: 200_000, tokens: 2_000 } }; + await poller.refresh(); + + expect(used).toEqual([2_000]); + expect(scheduler.activePollCount()).toBe(1); + }); + test("emits only changed usage while active", async () => { const scheduler = new ManualPollScheduler(); const updates: AgentUsage[] = []; diff --git a/packages/server/src/server/agent/providers/omp/usage-poller.ts b/packages/server/src/server/agent/providers/omp/usage-poller.ts index 4e41708b5a..bc3bc1c968 100644 --- a/packages/server/src/server/agent/providers/omp/usage-poller.ts +++ b/packages/server/src/server/agent/providers/omp/usage-poller.ts @@ -105,6 +105,17 @@ export class OmpUsagePoller { this.publishUsage(usage, turnId); } + async refresh(): Promise<void> { + let usage: AgentUsage | undefined; + try { + usage = toAgentUsage(await this.options.readStats()); + } catch (error) { + if (!this.closed) this.options.onPollError(error); + return; + } + if (!this.closed) this.publishUsage(usage); + } + close(): void { this.closed = true; this.active = false; diff --git a/packages/server/src/server/agent/providers/opencode-agent.recovery.test.ts b/packages/server/src/server/agent/providers/opencode-agent.recovery.test.ts index d0e8e663fc..8a4f43426e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.recovery.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.recovery.test.ts @@ -15,29 +15,63 @@ import { OpenCodeServerManager } from "./opencode/server-manager.js"; test("dispatches ascending OpenCode message identifiers through the public provider path", async () => { const upstream = await createRecoveryUpstream(); - const fixture = await createPublicRecoverySession(upstream, new RecoveryTiming()); - const observed: AgentStreamEvent[] = []; - fixture.session.subscribe((event) => observed.push(event)); - await upstream.connected(1); - upstream.send(0, connectedRecord()); - - await fixture.session.startTurn("first"); - await upstream.dispatched(1); - upstream.send(0, idleRecord()); - await eventually(() => - expect(observed.filter((event) => event.type === "turn_completed")).toHaveLength(1), + const client = new OpenCodeAgentClient( + createTestLogger(), + { + command: { + mode: "replace", + argv: [ + process.execPath, + "-e", + `setInterval(() => {}, 1000); fetch(${JSON.stringify( + `${upstream.url}/test/listening`, + )}).then(() => console.log("listening on test server"));`, + "--", + ], + }, + }, + { + resolveHomeDir: () => process.cwd(), + createClient: ({ directory }) => createOpencodeClient({ baseUrl: upstream.url, directory }), + }, ); - await fixture.session.startTurn("second"); - const ids = await upstream.dispatched(2); + try { + const creating = client.createSession({ + provider: "opencode", + cwd: "/workspace", + }); + void creating.catch(() => undefined); + await upstream.awaitingListening(); + // The process is alive but has not reported listening, so even a reachable endpoint must stay untouched. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(upstream.connectionCount()).toBe(0); + upstream.reportListening(); + const session = await creating; + const observed: AgentStreamEvent[] = []; + session.subscribe((event) => observed.push(event)); + await upstream.connected(1); + upstream.send(0, connectedRecord()); - expect(ids).toHaveLength(2); - expect(ids[0]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); - expect(ids[1]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); - expect((ids[0] as string) < (ids[1] as string)).toBe(true); + await session.startTurn("first"); + await upstream.dispatched(1); + upstream.send(0, idleRecord()); + await eventually(() => + expect(observed.filter((event) => event.type === "turn_completed")).toHaveLength(1), + ); + await session.startTurn("second"); + const ids = await upstream.dispatched(2); - await fixture.session.close(); - await fixture.manager.shutdown(); - await upstream.close(); + expect(ids).toHaveLength(2); + expect(ids[0]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + expect(ids[1]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + expect((ids[0] as string) < (ids[1] as string)).toBe(true); + + await session.close(); + } finally { + upstream.reportListening(); + await client.shutdown(); + await upstream.close(); + } }); test("recovers missed output after EOF without failing the active turn", async () => { @@ -306,7 +340,10 @@ async function createPublicRecoverySession( serverProcess?.exit(); return "terminated"; }, - createEventSource: (options) => new OpenCodeEventConsumer({ ...options, timing }), + createEventSource: (options) => { + expect(options.listening).toBeInstanceOf(Promise); + return new OpenCodeEventConsumer({ ...options, timing }); + }, }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { serverManager: manager, @@ -338,6 +375,7 @@ class RecoveryServerProcess extends EventEmitter { async function createRecoveryUpstream() { const streams: ServerResponse[] = []; + let listeningResponse: ServerResponse | null = null; const dispatchIds: string[] = []; const mcpAddNames: string[] = []; let messages: unknown[] = []; @@ -345,6 +383,10 @@ async function createRecoveryUpstream() { let status: "busy" | "idle" = "idle"; const server = createServer(async (request, response) => { const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + if (pathname === "/test/listening") { + listeningResponse = response; + return; + } if (request.url?.startsWith("/global/event")) { response.writeHead(200, { "content-type": "text/event-stream" }); response.flushHeaders(); @@ -378,6 +420,10 @@ async function createRecoveryUpstream() { return { port: address.port, url: `http://127.0.0.1:${address.port}`, + awaitingListening: async () => + expect.poll(() => listeningResponse, { timeout: 3_000 }).not.toBeNull(), + reportListening: () => listeningResponse?.end(), + connectionCount: () => streams.length, connected: async (count: number) => eventually(() => expect(streams).toHaveLength(count)), send(index: number, value: unknown) { streams[index]?.write(`data: ${JSON.stringify(value)}\n\n`); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 5b75733f33..7e49bc50d3 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1429,10 +1429,11 @@ export class OpenCodeAgentClient implements AgentClient { OpenCodeServerManager.getInstance(this.logger, runtimeSettings, { managedProcesses: deps.managedProcesses, resolveHomeDir: deps.resolveHomeDir, - createEventSource: ({ serverUrl, processExit, logger: eventLogger }) => + createEventSource: ({ serverUrl, processExit, listening, logger: eventLogger }) => new OpenCodeEventConsumer({ serverUrl, processExit, + listening, logger: eventLogger, createClient: (baseUrl) => this.createOpenCodeClient({ baseUrl, directory: "" }), }), 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/event-consumer.test.ts b/packages/server/src/server/agent/providers/opencode/event-consumer.test.ts index aad9702ad0..2a0999f74e 100644 --- a/packages/server/src/server/agent/providers/opencode/event-consumer.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-consumer.test.ts @@ -49,9 +49,11 @@ describe("OpenCodeEventConsumer", () => { const upstream = await createSseUpstream(); const timing = new ControlledTiming(); const processExit = deferred<Error>(); + const listening = deferred<void>(); const consumer = new OpenCodeEventConsumer({ serverUrl: upstream.url, processExit: processExit.promise, + listening: listening.promise, logger: createRecordingLogger(), timing, }); @@ -62,6 +64,10 @@ describe("OpenCodeEventConsumer", () => { const inputs: OpenCodeEventSourceInput[] = []; consumer.subscribe((input) => inputs.push(input)); + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(upstream.requests).toHaveLength(0); + expect(consumer.diagnostics().attempt).toBe(0); + listening.resolve(); await upstream.connected(1); expect(await promiseState(consumer.ready())).toBe("pending"); upstream.send(0, arbitraryRecord("/one")); @@ -106,10 +112,7 @@ describe("OpenCodeEventConsumer", () => { await consumer.ready(); timing.expireWatchdog(); - expect(timing.watchdogDelays).toEqual([ - EXPECTED_STREAM_WATCHDOG_MS, - EXPECTED_STREAM_WATCHDOG_MS, - ]); + expect(timing.watchdogDelays).toEqual([5_000, EXPECTED_STREAM_WATCHDOG_MS]); await timing.waiting(); timing.advanceWait(); await upstream.connected(2); @@ -120,6 +123,7 @@ describe("OpenCodeEventConsumer", () => { test("retries a first-record watchdog and exposes the recovery attempt", async () => { const upstream = await createSseUpstream(); + upstream.stallNext(1); const timing = new ControlledTiming(); const logger = createRecordingLogger(); const consumer = new OpenCodeEventConsumer({ @@ -127,6 +131,12 @@ describe("OpenCodeEventConsumer", () => { processExit: new Promise<Error>(() => undefined), logger, timing, + createClient: (baseUrl) => + createOpencodeClient({ + baseUrl, + // Keep the abandoned socket open to reproduce a fetch that does not unwind on abort. + fetch: (request) => fetch(new Request(request, { signal: null })), + }), }); cleanups.push(async () => { await consumer.close(); @@ -134,6 +144,7 @@ describe("OpenCodeEventConsumer", () => { }); await upstream.connected(1); + expect(timing.watchdogDelays).toEqual([5_000]); timing.expireWatchdog(); await timing.waiting(); expect(consumer.diagnostics()).toMatchObject({ @@ -156,7 +167,18 @@ describe("OpenCodeEventConsumer", () => { await upstream.connected(2); upstream.send(1, connectedRecord("/recovered")); await consumer.ready(); - expect(consumer.diagnostics()).toMatchObject({ attempt: 2, phase: "stream" }); + expect(consumer.diagnostics()).toMatchObject({ + attempt: 2, + phase: "stream", + }); + expect(upstream.isOpen(0)).toBe(true); + upstream.resume(0); + upstream.send(0, connectedRecord("/abandoned")); + upstream.send(0, arbitraryRecord("/abandoned")); + const inputs: OpenCodeEventSourceInput[] = []; + consumer.subscribe((input) => inputs.push(input)); + await new Promise<void>((resolve) => setImmediate(resolve)); + expect(inputs).toEqual([]); }); test("publishes one terminal and stops reconnecting on process exit", async () => { @@ -422,6 +444,16 @@ describe("OpenCodeEventConsumer", () => { await consumer.close(); expect(inputs).toEqual([]); + + const waitingConsumer = new OpenCodeEventConsumer({ + serverUrl: upstream.url, + processExit: new Promise<Error>(() => undefined), + listening: new Promise<void>(() => undefined), + logger: createRecordingLogger(), + }); + await waitingConsumer.close(); + await expect(waitingConsumer.ready()).rejects.toThrow("OpenCode event source closed"); + expect(upstream.requests).toHaveLength(1); }); }); @@ -499,8 +531,14 @@ async function createSseUpstream() { const responses: ServerResponse[] = []; const requests: Array<{ url: string | undefined }> = []; let failuresRemaining = 0; + let stallsRemaining = 0; const server = createServer((request, response) => { requests.push({ url: request.url }); + if (stallsRemaining > 0) { + stallsRemaining -= 1; + responses.push(response); + return; + } if (failuresRemaining > 0) { failuresRemaining -= 1; response.writeHead(503).end(); @@ -533,7 +571,21 @@ async function createSseUpstream() { failNext(count: number) { failuresRemaining = count; }, - close: async () => new Promise<void>((resolve) => server.close(() => resolve())), + stallNext(count: number) { + stallsRemaining = count; + }, + isOpen(index: number) { + return responses[index]?.socket?.destroyed === false; + }, + resume(index: number) { + responses[index]?.writeHead(200, { "content-type": "text/event-stream" }); + responses[index]?.flushHeaders(); + }, + close: async () => + new Promise<void>((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), }; } diff --git a/packages/server/src/server/agent/providers/opencode/event-consumer.ts b/packages/server/src/server/agent/providers/opencode/event-consumer.ts index 064d6d6905..9471a28745 100644 --- a/packages/server/src/server/agent/providers/opencode/event-consumer.ts +++ b/packages/server/src/server/agent/providers/opencode/event-consumer.ts @@ -29,11 +29,13 @@ export interface OpenCodeEventConsumerTiming { export interface OpenCodeEventConsumerOptions { serverUrl: string; processExit: Promise<Error>; + listening?: Promise<void>; logger: Pick<Logger, "debug" | "warn">; createClient?: (baseUrl: string) => OpencodeClient; timing?: OpenCodeEventConsumerTiming; } +const FIRST_RECORD_WATCHDOG_MS = 5_000; const WATCHDOG_MS = 30_000; const MAX_BACKOFF_MS = 5_000; const FAILURE_WARNING_ATTEMPT = 4; @@ -98,7 +100,7 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource { this.rejectReady = reject; }); void this.readyPromise.catch(() => undefined); - this.connectionTask = this.consume(options.processExit); + this.connectionTask = this.consume(options.processExit, options.listening); void this.connectionTask.catch(() => undefined); } @@ -131,8 +133,23 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource { await this.connectionTask.catch(() => undefined); } - private async consume(processExit: Promise<Error>): Promise<void> { + private async consume(processExit: Promise<Error>, listening?: Promise<void>): Promise<void> { void processExit.then((error) => this.exit(error)); + if (listening) { + const signal = this.connectionAbort.signal; + let stopWaiting!: () => void; + const stopped = new Promise<void>((resolve) => { + stopWaiting = resolve; + }); + signal.addEventListener("abort", stopWaiting, { once: true }); + try { + await Promise.race([listening, stopped]); + } catch { + return; + } finally { + signal.removeEventListener("abort", stopWaiting); + } + } let reconnectAttempt = 0; while (!this.closed) { this.attempt += 1; @@ -150,62 +167,83 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource { private async consumeConnection(signal: AbortSignal): Promise<OpenCodeConnectionResult> { const requestAbort = new AbortController(); - const abortRequest = () => requestAbort.abort(signal.reason); + const attemptToken = { active: true }; + let resolveAbandoned!: (result: OpenCodeConnectionResult) => void; + const abandoned = new Promise<OpenCodeConnectionResult>((resolve) => { + resolveAbandoned = resolve; + }); + const abortRequest = () => { + attemptToken.active = false; + resolveAbandoned({ delivered, phase, outcome: "ended" }); + requestAbort.abort(signal.reason); + }; signal.addEventListener("abort", abortRequest, { once: true }); let cancelWatchdog: () => void = () => undefined; let delivered = false; let phase: OpenCodeEventStreamPhase = "first-record"; - let watchdogPhase: OpenCodeEventStreamPhase | null = null; let sseError: unknown; const armWatchdog = () => { cancelWatchdog(); - cancelWatchdog = this.timing.arm(WATCHDOG_MS, () => { - watchdogPhase = phase; - requestAbort.abort(new Error(`OpenCode event stream ${phase} watchdog expired`)); - }); - }; - try { - const result = await this.client.global.event({ - signal: requestAbort.signal, - sseMaxRetryAttempts: 0, - onSseError: (error) => { - sseError = error; + cancelWatchdog = this.timing.arm( + this.connected ? WATCHDOG_MS : FIRST_RECORD_WATCHDOG_MS, + () => { + const error = new Error(`OpenCode event stream ${phase} watchdog expired`); + // Abandon a stalled request even when fetch ignores abort, for example during OpenCode startup. + attemptToken.active = false; + resolveAbandoned({ delivered, phase, outcome: "watchdog", error }); + requestAbort.abort(error); }, - }); - armWatchdog(); - for await (const event of result.stream) { - if (this.closed) { - return { delivered, phase, outcome: "ended" }; + ); + }; + const consumeStream = async (): Promise<OpenCodeConnectionResult> => { + try { + const result = await this.client.global.event({ + signal: requestAbort.signal, + sseMaxRetryAttempts: 0, + onSseError: (error) => { + sseError = error; + }, + }); + for await (const event of result.stream) { + // Ignore late records from abandoned requests, for example a delayed server.connected event. + if (this.closed || !attemptToken.active) { + return { delivered, phase, outcome: "ended" }; + } + delivered = true; + phase = "stream"; + this.phase = phase; + if (!this.connected && event.payload.type === "server.connected") { + this.connected = true; + this.resolveReady(); + armWatchdog(); + continue; + } + armWatchdog(); + this.logPluginFailure(event); + this.publish(event); } - armWatchdog(); - delivered = true; - phase = "stream"; - this.phase = phase; - if (!this.connected && event.payload.type === "server.connected") { - this.connected = true; - this.resolveReady(); - continue; - } - this.logPluginFailure(event); - this.publish(event); + let outcome: OpenCodeConnectionOutcome = "ended"; + if (sseError !== undefined) outcome = "error"; + return { + delivered, + phase, + outcome, + ...(sseError === undefined ? {} : { error: sseError }), + }; + } catch (error) { + return { + delivered, + phase, + outcome: "error", + error, + }; } - let outcome: OpenCodeConnectionOutcome = "ended"; - if (watchdogPhase) outcome = "watchdog"; - else if (sseError !== undefined) outcome = "error"; - return { - delivered, - phase: watchdogPhase ?? phase, - outcome, - ...(sseError === undefined ? {} : { error: sseError }), - }; - } catch (error) { - return { - delivered, - phase: watchdogPhase ?? phase, - outcome: watchdogPhase ? "watchdog" : "error", - error, - }; + }; + armWatchdog(); + try { + return await Promise.race([consumeStream(), abandoned]); } finally { + attemptToken.active = false; cancelWatchdog(); signal.removeEventListener("abort", abortRequest); requestAbort.abort(); @@ -285,5 +323,5 @@ function containsPluginError(error: unknown): boolean { } export type OpenCodeEventConsumerFactory = ( - options: Pick<OpenCodeEventConsumerOptions, "serverUrl" | "processExit" | "logger">, + options: Pick<OpenCodeEventConsumerOptions, "serverUrl" | "processExit" | "logger" | "listening">, ) => OpenCodeEventConsumer; diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index cd560af7ba..9ff5dc6c96 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -356,6 +356,12 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { const processExit = new Promise<Error>((resolve) => { resolveProcessExit = resolve; }); + let resolveListening!: () => void; + let rejectListening!: (error: Error) => void; + const ready = new Promise<void>((resolve, reject) => { + resolveListening = resolve; + rejectListening = reject; + }); const server: OpenCodeServerGeneration = { environment, process: serverProcess, @@ -364,7 +370,12 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { refCount: 0, retired: false, ready: Promise.resolve(), - events: this.createEventSource({ serverUrl: url, processExit, logger: this.logger }), + events: this.createEventSource({ + serverUrl: url, + processExit, + logger: this.logger, + listening: ready, + }), managedProcessRecord, }; this.logger.info( @@ -403,7 +414,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { return sections.join("\n"); }; - const ready = new Promise<void>((resolve, reject) => { + { let timeout: ReturnType<typeof setTimeout>; const failStartup = (error: Error) => { if (settled) { @@ -411,7 +422,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } settled = true; clearTimeout(timeout); - reject(error); + rejectListening(error); }; timeout = setTimeout(() => { if (!started) { @@ -426,7 +437,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { started = true; settled = true; clearTimeout(timeout); - resolve(); + resolveListening(); } }); @@ -462,7 +473,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } } }); - }); + } server.ready = ready.catch(async (error) => { await this.killServer(server); diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 8c91854fb8..26cb65885d 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -1,10 +1,11 @@ -import type { ModelInfo, SessionMessageInfo } from "@opencode/client"; +import type { FormInfo, ModelInfo, SessionMessageInfo } from "@opencode/client"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; import { OpenCodeV2AgentClient } from "./agent.js"; import { V2Harness } from "../test-utils/v2-harness.js"; +import type { V2Api } from "./api.js"; function terminalEvents(events: AgentStreamEvent[]) { return events.filter( @@ -210,6 +211,172 @@ describe("OpenCode v2 session lifecycle", () => { } }); + test("ends the turn when the user dismisses a question", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + let form: FormInfo | null = null; + harness.api.session.form.list = async () => (form ? [form] : []); + harness.api.session.form.cancel = async () => { + form = null; + harness.active = false; + // OpenCode 2.0.20 reports a dismissed question as an interruption without a user reason. + harness.push({ + id: "dismissed", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("ask me something"); + await expect.poll(() => harness.prompts).toHaveLength(1); + form = { + id: "question", + sessionID: "session", + title: "Color", + fields: [{ key: "color", type: "string", options: [{ label: "Red", value: "red" }] }], + }; + harness.push({ + id: "form", + created: 2, + type: "form.created", + data: { form }, + }); + await expect.poll(() => session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("question", { behavior: "deny" }); + + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" }); + + await session.startTurn("next message"); + await expect.poll(() => harness.prompts).toHaveLength(2); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(2); + expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("ends the turn when the user denies a tool permission", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = null; + harness.api.permission.list = async () => (permission ? [permission] : []); + harness.api.permission.reply = async () => { + permission = null; + harness.active = false; + // OpenCode 2.0.20 reports a denied tool call as an interruption without a user reason. + harness.push({ + id: "denied", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("run printf"); + await expect.poll(() => harness.prompts).toHaveLength(1); + permission = { + id: "approval", + sessionID: "session", + action: "shell", + resources: ["printf TOOL_PERMISSION_QA"], + }; + harness.push({ + id: "asked", + created: 2, + type: "permission.asked", + data: permission, + }); + await expect.poll(() => session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("approval", { behavior: "deny" }); + + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" }); + + await session.startTurn("next message"); + await expect.poll(() => harness.prompts).toHaveLength(2); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(2); + expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("keeps the turn resumable after the user denies a child session's permission", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const child = { ...harness.info, id: "child", parentID: "session" }; + harness.api.session.list = async (input) => ({ + data: input?.parentID === "session" ? [child] : [], + cursor: {}, + }); + let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = { + id: "child-approval", + sessionID: "child", + action: "shell", + resources: ["pwd"], + }; + harness.api.permission.list = async (input) => + permission && input.sessionID === "child" ? [permission] : []; + harness.api.permission.reply = async () => { + permission = null; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("delegate work"); + await expect.poll(() => harness.prompts).toHaveLength(1); + expect(session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("child-approval", { behavior: "deny" }); + + harness.active = false; + harness.push({ + id: "shutdown", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + const reads = harness.activeReads; + harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} }); + await expect.poll(() => harness.activeReads).toBeGreaterThan(reads); + expect(terminalEvents(events)).toEqual([]); + harness.startExecution(); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + test("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => { const harness = new V2Harness(); harness.autoComplete = false; diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs index b14540a1c3..6b845d19ed 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs @@ -13,7 +13,16 @@ export default { ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); if (allowMissing && response.status === 404) return null; - if (!response.ok) throw new Error(`Paseo tool bridge returned HTTP ${response.status}`); + if (!response.ok) { + const payload = await response.json().catch(() => null); + const message = + typeof payload?.error === "string" && payload.error + ? payload.error + : `Paseo tool bridge returned HTTP ${response.status}`; + const error = new Error(message); + error.status = response.status; + throw error; + } return response.json(); } async function scope(sessionID) { @@ -45,7 +54,10 @@ export default { `/sessions/${encodeURIComponent(binding.sessionID)}/tools/${encodeURIComponent(definition.name)}`, input, ); - return { content: result.content, metadata: { paseoTool: definition.name } }; + return { + content: result.content.map(toOpenCodeContent), + metadata: { paseoTool: definition.name }, + }; }, }); } @@ -65,3 +77,11 @@ export default { }; }, }; + +// OpenCode tool content is text or a file; MCP images arrive as base64 data. +function toOpenCodeContent(part) { + if (part.type === "image") { + return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType }; + } + return part; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts index 176fc455be..cc13b99e4b 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -33,6 +33,12 @@ export class SessionPermissions { list() { return [...this.pending.values()]; } + isOwnedBy(requestId: string, sessionID: string) { + return ( + this.forms.get(requestId)?.sessionID === sessionID || + this.permissionOwners.get(requestId) === sessionID + ); + } async respondToPermission(requestId: string, response: AgentPermissionResponse) { const form = this.forms.get(requestId); if (form) { diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index cb71ff7212..5cd45b1ff7 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -405,7 +405,10 @@ export class OpenCodeV2Session implements AgentSession { } async respondToPermission(requestId: string, response: AgentPermissionResponse) { await this.reconnectIfExited(); + const deniesOwnRequest = + response.behavior === "deny" && this.permissions.isOwnedBy(requestId, this.id); await this.permissions.respondToPermission(requestId, response); + if (deniesOwnRequest) this.turns.requestDenied(); } private async reconcileSnapshot() { const [info, history] = await Promise.all([ 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/opencode/v2/turns.ts b/packages/server/src/server/agent/providers/opencode/v2/turns.ts index 5c8765ec02..2b2c21d0c5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/turns.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/turns.ts @@ -60,6 +60,7 @@ interface Turn { completion: Promise<void>; settle(): void; accepted: boolean; + requestDenied: boolean; } export class SessionTurns { @@ -112,6 +113,7 @@ export class SessionTurns { settle, accepted, output, + requestDenied: false, }; this.turn = turn; this.execution = null; @@ -162,6 +164,13 @@ export class SessionTurns { this.sequence = event.durable.seq; this.requestReconciliation(); } + // OpenCode ends the execution when the user denies its permission or dismisses its question, + // but reports the interruption as "shutdown", the same reason as an execution it will resume. + requestDenied() { + if (!this.turn) return; + this.turn.requestDenied = true; + this.requestReconciliation(); + } private requestReconciliation() { void this.reconcile().catch((error: unknown) => this.options.reportReconciliationError(error)); } @@ -216,7 +225,9 @@ export class SessionTurns { this.sequence = execution.durable.seq; if ( execution.type === "session.execution.started" || - (execution.type === "session.execution.interrupted" && execution.data.reason === "shutdown") + (execution.type === "session.execution.interrupted" && + execution.data.reason === "shutdown" && + !turn.requestDenied) ) return; // Read the final history only after observing idle. Never turn an observation transport error into an execution failure. 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 8035d59830..927a9defd2 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -1036,6 +1036,47 @@ describe("PiRpcAgentSession", () => { ]); }); + test("keeps custom context in separate tools while a turn continues", async () => { + const { pi, session, events } = await createSession(); + const runtime = pi.latestSession(); + await session.startTurn("Explain the project"); + runtime.emit({ type: "agent_start" }); + runtime.emit({ type: "turn_start" }); + for (const display of [true, false, true]) { + runtime.emit({ + type: "message_end", + message: { + role: "custom", + customType: "project-context", + content: [{ type: "text", text: "Project instructions" }], + details: { project: "example" }, + display, + }, + }); + } + const items = events.timelineItems(); + expect(items).toEqual( + [1, 2].map(() => ({ + type: "tool_call", + callId: expect.stringMatching(/^pi-custom-/), + name: "project-context", + status: "completed", + detail: { type: "plain_text", text: "Project instructions" }, + metadata: { + synthetic: true, + customType: "project-context", + details: { project: "example" }, + }, + error: null, + })), + ); + expect(new Set(items.map((item) => item.type === "tool_call" && item.callId)).size).toBe(2); + expect(events.turnCompletedEvents()).toHaveLength(0); + runtime.finishTurn(); + await events.nextTurnCompletion(); + await session.close(); + }); + test("surfaces Pi extension command messages and completes when no agent turn starts", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -1052,7 +1093,15 @@ describe("PiRpcAgentSession", () => { expect(events.timelineAndCompletionEvents()).toEqual([ { type: "timeline", - item: { type: "assistant_message", text: "Extension command output" }, + item: { + type: "tool_call", + callId: expect.stringMatching(/^pi-custom-/), + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "Extension command output" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, }, { type: "turn_completed" }, ]); @@ -1081,7 +1130,18 @@ describe("PiRpcAgentSession", () => { }); expect(events.timelineItems()).toEqual([ - { type: "assistant_message", text: "Background process completed" }, + { + type: "tool_call", + callId: expect.stringMatching(/^pi-custom-/), + name: "custom-message", + status: "completed", + detail: { + type: "plain_text", + text: "Background process completed", + }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, ]); expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_started", turnId: undefined }]); @@ -1883,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 5c9c7bb949..146b56b36f 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1,3 +1,4 @@ +import { mapCustomMessageToToolCall } from "../custom-message.js"; import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -1004,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": @@ -2135,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(); @@ -2368,12 +2380,16 @@ export class PiRpcAgentSession implements AgentSession { const customMapping = this.extensionHost.mapCustomMessage(event.message); this.emitExtensionOutput(customMapping, turnId); const text = getUserMessageText(event.message.content); - if (text) { + if (event.message.display !== false && text) { this.emit({ type: "timeline", provider: this.provider, turnId, - item: { type: "assistant_message", text }, + item: mapCustomMessageToToolCall( + event.message, + text, + `${this.provider}-custom-${randomUUID()}`, + ), }); } if (!this.activeTurnStarted) { diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts index 8e3d6e64e9..6bbe1b3b06 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts @@ -129,15 +129,28 @@ describe("@gotgenes/pi-subagents adapter", () => { expect(new Set(upserts.map((event) => event?.id)).size).toBe(1); expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); }); - test("keeps claimed notification text visible in history", async () => { + test("keeps claimed notification text in a tool row in history", async () => { const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); - const text = fixture.messages.find((message) => message.role === "custom")?.content; + const notification = fixture.messages.find((message) => message.role === "custom"); + const text = notification?.content; const events = []; for await (const event of streamPiHistory("pi", fixture.messages)) events.push(event); expect(events).toContainEqual({ type: "timeline", provider: "pi", - item: { type: "assistant_message", text }, + item: { + type: "tool_call", + callId: "pi-custom-1", + name: notification?.customType, + status: "completed", + detail: { type: "plain_text", text }, + metadata: { + synthetic: true, + customType: notification?.customType, + details: notification?.details, + }, + error: null, + }, }); expect(events.some((event) => event.type === "provider_subagent")).toBe(true); }); diff --git a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts index 56d541a680..8a9ca61d08 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts @@ -65,16 +65,46 @@ export async function verifySubagentFixture( }); for (const message of fixture.messages) { if (message.role !== "custom" || typeof message.content !== "string") continue; - const assistantText = { - type: "assistant_message" as const, - text: message.content, - }; - expect(live).toContainEqual( - expect.objectContaining({ type: "timeline", item: assistantText }), - ); - expect(replay).toContainEqual( - expect.objectContaining({ type: "timeline", item: assistantText }), - ); + for (const events of [live, replay]) { + const tools = events.flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.detail.type === "plain_text" && + event.item.detail.text === message.content + ? [event.item] + : [], + ); + expect(tools).toEqual( + message.display === false + ? [] + : [ + { + type: "tool_call", + callId: expect.stringMatching(/^pi-custom-/), + name: message.customType ?? "custom-message", + status: "completed", + detail: { + type: "plain_text", + text: message.content, + }, + metadata: { + synthetic: true, + customType: message.customType ?? "custom-message", + ...(message.details === undefined ? {} : { details: message.details }), + }, + error: null, + }, + ], + ); + expect( + events.filter( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text === message.content, + ), + ).toEqual([]); + } } const liveSubagents = live.filter((event) => event.type === "provider_subagent"); const firstTimeline = liveSubagents.findIndex((event) => event.event.type === "timeline"); 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/history-mapper.test.ts b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts index 380cf4ee75..24d18306b2 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts @@ -16,6 +16,35 @@ async function collectHistory( } describe("Pi history mapper", () => { + test("renders visible custom messages as completed tools and hides private context", async () => { + const events = await collectHistory([ + { + role: "custom", + customType: "project-context", + content: "Project instructions", + display: true, + }, + { + role: "custom", + customType: "private-context", + content: "Hidden instructions", + display: false, + }, + { role: "custom", customType: "project-context", content: "Project instructions" }, + ]); + expect(events.map((event) => event.item)).toEqual( + [1, 2].map((index) => ({ + type: "tool_call", + callId: `pi-custom-${index}`, + name: "project-context", + status: "completed", + detail: { type: "plain_text", text: "Project instructions" }, + metadata: { synthetic: true, customType: "project-context" }, + error: null, + })), + ); + }); + test("replays user, assistant, reasoning, and completed tool calls", async () => { await expect( collectHistory([ @@ -128,14 +157,22 @@ describe("Pi history mapper", () => { ]); }); - test("replays non-notice custom messages as assistant text, matching the live path", async () => { + test("replays custom messages as completed tools, matching the live path", async () => { await expect( collectHistory([{ role: "custom", content: "Extension command output" }]), ).resolves.toEqual([ { type: "timeline", provider: "pi", - item: { type: "assistant_message", text: "Extension command output" }, + item: { + type: "tool_call", + callId: "pi-custom-1", + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "Extension command output" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, }, ]); }); diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.ts b/packages/server/src/server/agent/providers/pi/history-mapper.ts index 4ec02e0133..27a0250b00 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -1,3 +1,4 @@ +import { mapCustomMessageToToolCall } from "../custom-message.js"; import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js"; import { createPiExtensionHost, @@ -61,6 +62,7 @@ export class PiHistoryMapper { private readonly hydrations: Promise<AgentStreamEvent[]>[] = []; private userIndex = 0; private assistantIndex = 0; + private customIndex = 0; constructor( private readonly provider: string, @@ -125,6 +127,7 @@ export class PiHistoryMapper { ): AgentStreamEvent[] { const extensionMapping = this.extensionHost.mapCustomMessage(message); const extensionEvents = this.extensionEvents(extensionMapping); + if (message.display === false) return extensionEvents; const text = getUserMessageText(message.content); const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null; if (mappedEvent) { @@ -137,7 +140,11 @@ export class PiHistoryMapper { { type: "timeline", provider: this.provider, - item: { type: "assistant_message", text }, + item: mapCustomMessageToToolCall( + message, + text, + `${this.provider}-custom-${++this.customIndex}`, + ), } as AgentStreamEvent, ] : []), 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/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index d8128a6a2b..1f72cdc498 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -87,7 +87,8 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail) return incoming; } -function mergeToolCallItems( +/** Fold a later update for the same tool call into the one it follows. */ +export function mergeToolCallItems( existing: Extract<AgentTimelineItem, { type: "tool_call" }>, incoming: Extract<AgentTimelineItem, { type: "tool_call" }>, ): Extract<AgentTimelineItem, { type: "tool_call" }> { diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 07f9f21098..dae9c16f4f 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,4 @@ +import { AgentDirectory } from "../directory.js"; import { stat } from "node:fs/promises"; import { z } from "zod"; import { ensureValidJson } from "../../json-utils.js"; @@ -59,9 +60,9 @@ import { sanitizePermissionRequest, serializeSnapshotWithMetadata, toScheduleSummary, - waitForAgentWithTimeout, } from "../mcp-shared.js"; import { + type PromptDispatchDisposition, sendPromptToAgent, setupFinishNotification, waitForAgentRunStartWithTimeout, @@ -111,7 +112,7 @@ export interface PaseoToolHostDependencies { github?: ForgeService; workspaceGitService?: Pick< WorkspaceGitService, - "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" | "invalidateWorktrees" >; findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; @@ -123,6 +124,7 @@ export interface PaseoToolHostDependencies { cwd: string, title?: string | null, projectId?: string, + context?: { background?: boolean; callerWorkspaceId?: string }, ) => Promise<PersistedWorkspaceRecord>; workspaceScripts?: Pick<WorkspaceScriptsService, "list" | "launch" | "stop">; markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; @@ -132,6 +134,7 @@ export interface PaseoToolHostDependencies { ensureWorkspaceForCreate?: ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ) => Promise<string>; browserToolsEnabled?: boolean; browserToolsBroker?: BrowserToolsBroker | null; @@ -189,6 +192,7 @@ const WorkspaceAutomationSummarySchema = z.object({ isolation: z.enum(["local", "worktree"]), kind: z.enum(["directory", "local_checkout", "worktree"]), title: z.string().nullable(), + background: z.boolean().optional(), }); function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { @@ -199,6 +203,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { isolation: workspace.kind === "worktree" ? ("worktree" as const) : ("local" as const), kind: workspace.kind, title: workspace.title, + background: workspace.background, }; } @@ -1047,13 +1052,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const canonicalTopLevelInputSchema = { ...canonicalCreateAgentFields, - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), notifyOnFinish: z .boolean() .optional() @@ -1071,7 +1069,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...commonCreateAgentFields, relationship: legacyCreateAgentPlacementFields.relationship.optional(), workspace: legacyCreateAgentPlacementFields.workspace.optional(), - background: canonicalTopLevelInputSchema.background, notifyOnFinish: canonicalTopLevelInputSchema.notifyOnFinish, cwd: z .string() @@ -1129,13 +1126,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const agentToAgentSendAgentPromptInputSchema = { ...commonSendAgentPromptInputSchema, - background: z - .boolean() - .optional() - .default(true) - .describe( - "Run agent in background. Agent-scoped default is true so you can continue until the finish notification arrives. Set false only when you need a blocking response.", - ), notifyOnFinish: z .boolean() .optional() @@ -1146,13 +1136,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const topLevelSendAgentPromptInputSchema = { ...commonSendAgentPromptInputSchema, - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), notifyOnFinish: z .boolean() .optional() @@ -1249,6 +1232,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ), projectId: z.string().optional().describe("Existing project id to own the workspace."), title: z.string().trim().min(1).optional(), + background: z + .boolean() + .optional() + .describe( + "True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks.", + ), mode: z .enum(["branch-off", "checkout-branch", "checkout-pr"]) .optional() @@ -1287,6 +1276,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase path, projectId, title, + background, mode, worktreeSlug, branchName, @@ -1316,7 +1306,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (!options.createDirectoryWorkspace) { throw new Error("Workspace provisioning is not configured"); } - workspace = await options.createDirectoryWorkspace(cwd, title, projectId); + workspace = await options.createDirectoryWorkspace(cwd, title, projectId, { + background, + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }); } else { let cwd = path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null; @@ -1347,6 +1340,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(worktreeSlug ? { worktreeSlug } : {}), ...worktreeTarget, ...(title ? { title } : {}), + background, + callerWorkspaceId: resolveCallerAgent()?.workspaceId, }, ); if (!result.ok) { @@ -1367,15 +1362,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { title: "List workspaces", description: "List active workspaces.", - inputSchema: {}, + inputSchema: { includeBackground: z.boolean().optional().default(false) }, outputSchema: { workspaces: z.array(WorkspaceAutomationSummarySchema) }, }, - async () => { + async ({ includeBackground = false }) => { if (!options.workspaceRegistry) { throw new Error("Workspace registry is not configured"); } const workspaces = (await options.workspaceRegistry.list()) - .filter((workspace) => !workspace.archivedAt) + .filter( + (workspace) => !workspace.archivedAt && (includeBackground || !workspace.background), + ) .map(toWorkspaceAutomationSummary); return { content: [], @@ -1442,30 +1439,21 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase workspaceId: z.string().optional(), currentModeId: z.string().nullable(), availableModes: z.array(ProviderModeSchema), - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), guidance: z.string().optional(), }, }, async (args: unknown) => { const resolvedArgs = await resolveCreateAgentToolArgs(args); const { parsedArgs, worktree } = resolvedArgs; - let requestedBackground: boolean; let notifyOnFinish: boolean; if (resolvedArgs.kind === "agent-scoped") { - requestedBackground = true; notifyOnFinish = parsedArgs.notifyOnFinish; } else { - requestedBackground = resolvedArgs.parsedArgs.background; notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; } const selectedProvider = resolveRequiredProviderModel(parsedArgs.provider).provider; const inheritedConfig = resolveInheritedProviderConfig(selectedProvider); - const { - snapshot, - background: createdInBackground, - initialPromptStarted, - } = await createAgentCommand( + const { snapshot, initialPromptStarted } = await createAgentCommand( { agentManager, agentStorage, @@ -1491,7 +1479,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase features: parsedArgs.settings?.features, labels: parsedArgs.labels, mode: parsedArgs.settings?.modeId, - background: requestedBackground, + promptFailure: "throw", notifyOnFinish, detached: resolvedArgs.detached, callerAgentId, @@ -1500,43 +1488,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); - try { - if (!createdInBackground && initialPromptStarted) { - const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { - waitForActive: true, - }); - - const liveSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; - const responseData = { - agentId: snapshot.id, - type: snapshot.provider, - status: result.status, - cwd: liveSnapshot.cwd, - ...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}), - currentModeId: liveSnapshot.currentModeId, - availableModes: liveSnapshot.availableModes, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } - } catch (error) { - childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); - throw error; - } - // Return immediately for async creation. const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; const guidance = callerAgentId && notifyOnFinish && initialPromptStarted ? "You will get notified when the created agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives." - : undefined; + : "Creation acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs."; const response = { content: [], structuredContent: ensureValidJson({ @@ -1547,8 +1504,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}), currentModeId: currentSnapshot.currentModeId, availableModes: currentSnapshot.availableModes, - lastMessage: null, - permission: null, ...(guidance ? { guidance } : {}), }), }; @@ -1682,7 +1637,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const cwd = process.cwd(); return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }), }; } const caller = resolveCallerAgent(); @@ -1850,7 +1807,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }), worktree: undefined, }; } @@ -1897,88 +1856,62 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { title: "Send agent prompt", description: - "Send a task to a running agent. Agent-scoped callers run in background by default; top-level callers wait by default.", + "Send a task and return after dispatch acknowledgement. Calling agents receive finish notifications by default.", inputSchema: sendAgentPromptInputSchema, outputSchema: { success: z.boolean(), + disposition: z.enum(["turn_started", "out_of_band"]).optional(), status: AgentStatusEnum, - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), guidance: z.string().optional(), }, }, - async ({ - agentId, - prompt, - sessionMode, - background = Boolean(callerAgentId), - notifyOnFinish = Boolean(callerAgentId), - }) => { - function armFinishNotification(): boolean { + async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => { + let stopNotification: (() => void) | undefined; + function armFinishNotification(disposition: PromptDispatchDisposition): void { if (!callerAgentId || !notifyOnFinish) { - return false; + return; } - setupFinishNotification({ + stopNotification = setupFinishNotification({ agentManager, agentStorage, childAgentId: agentId, callerAgentId, + waitForTurnStart: disposition === "turn_started", logger: childLogger, }); - return true; } + const notifying = Boolean(callerAgentId && notifyOnFinish); const { disposition } = await sendPromptToAgent({ agentManager, agentStorage, agentId, prompt, + source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined, sessionMode, + onDispatch: armFinishNotification, logger: childLogger, + }).catch((error) => { + stopNotification?.(); + throw error; }); - // If not running in background, wait for completion - if (!background) { - const result = await waitForAgentWithTimeout(agentManager, agentId, { - waitForActive: true, - }); - // The wait ran out while the agent keeps working, so its result arrives as a - // finish notification instead of in this response. - const notifying = - result.timedOut && - agentManager.getAgent(agentId)?.lifecycle === "running" && - armFinishNotification(); - - const responseData = { - success: true, - status: result.status, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - ...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } - - const notifying = armFinishNotification(); - // Return once the provider has accepted the turn, so the status reports it running. if (disposition === "turn_started") { - await waitForAgentRunStartWithTimeout(agentManager, agentId); + await waitForAgentRunStartWithTimeout(agentManager, agentId).catch((error) => { + stopNotification?.(); + throw error; + }); } const currentSnapshot = agentManager.getAgent(agentId); const responseData = { success: true, + disposition, status: currentSnapshot?.lifecycle ?? "idle", - lastMessage: null, - permission: null, - ...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}), + guidance: notifying + ? PROMPTED_AGENT_NOTIFICATION_GUIDANCE + : "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.", }; const validJson = ensureValidJson(responseData); @@ -2046,6 +1979,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase title: "List agents", description: "List recent agents as compact metadata.", inputSchema: { + includeBackground: z.boolean().optional().default(false), includeArchived: z.boolean().optional().default(false), cwd: z.string().optional(), sinceHours: z @@ -2062,29 +1996,38 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase agents: z.array(AgentListItemPayloadSchema), }, }, - async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => { + async ({ + includeBackground = false, + includeArchived = false, + cwd, + sinceHours = 48, + statuses, + limit = 50, + }) => { const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined; const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd; const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000; - const liveSnapshots = agentManager.listAgents(); - const liveAgents = await Promise.all( - liveSnapshots.map((snapshot) => + if (!options.workspaceRegistry) throw new Error("Workspace registry is not configured"); + const directory = new AgentDirectory({ + manager: agentManager, + storage: agentStorage, + workspaces: options.workspaceRegistry, + projectLive: (snapshot) => serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), - ), - ); - const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); - const storedRecords = await agentStorage.list(); - const registeredProviderIds = new Set(providerSnapshotManager.listRegisteredProviderIds()); - const storedAgents = storedRecords - .filter((record) => !record.internal && !liveIds.has(record.id)) - .filter((record) => includeArchived || !record.archivedAt) - .filter( - (record) => - includeArchived || isStoredAgentProviderAvailable(record, registeredProviderIds), - ) - .map((record) => buildStoredAgentPayload(record, registeredProviderIds)); - const agents = [...liveAgents, ...storedAgents] + projectStored: (record) => + buildStoredAgentPayload( + record, + new Set(providerSnapshotManager.listRegisteredProviderIds()), + ), + isProviderVisible: () => true, + isStoredProviderAvailable: (record) => + isStoredAgentProviderAvailable( + record, + new Set(providerSnapshotManager.listRegisteredProviderIds()), + ), + }); + const agents = (await directory.list({ includeBackground, includeArchived })) .map(toAgentListItemPayload) .filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd)) .filter((agent) => !statusFilter || statusFilter.has(agent.status)) diff --git a/packages/server/src/server/bootstrap-web-ui.test.ts b/packages/server/src/server/bootstrap-web-ui.test.ts index 3822cfaa55..81afc3c166 100644 --- a/packages/server/src/server/bootstrap-web-ui.test.ts +++ b/packages/server/src/server/bootstrap-web-ui.test.ts @@ -134,6 +134,44 @@ describe("daemon web UI bootstrap", () => { }); }); + test("spells out the scheme default port when the Host header omits it", async () => { + const distDir = await createWebUiDist(); + + daemonHandle = await createTestPaseoDaemon({ + mcpEnabled: false, + webUi: { + enabled: true, + distDir, + }, + }); + + // A browser reaching a proxy that terminates TLS on 443 sends a Host header + // with no port at all. The app rejects a portless `listen`, so the daemon + // has to supply the default itself. + const httpsHint = readInjectedConnectionHint( + await fetchDaemonWebUi({ + port: daemonHandle.port, + headers: { host: "paseo.example.test", "x-forwarded-proto": "https" }, + }), + ); + const httpHint = readInjectedConnectionHint( + await fetchDaemonWebUi({ + port: daemonHandle.port, + headers: { host: "paseo.example.test" }, + }), + ); + const ipv6Hint = readInjectedConnectionHint( + await fetchDaemonWebUi({ + port: daemonHandle.port, + headers: { host: "[::1]", "x-forwarded-proto": "https" }, + }), + ); + + expect(httpsHint.listen).toBe("paseo.example.test:443"); + expect(httpHint.listen).toBe("paseo.example.test:80"); + expect(ipv6Hint.listen).toBe("[::1]:443"); + }); + test("ignores forwarded HTTPS when proxy trust is disabled", async () => { const distDir = await createWebUiDist(); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e63ce89fb3..727c7b7899 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -126,6 +126,7 @@ import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-wo import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { createPaseoWorktreeWorkflow } from "./worktree-session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; +import { formatAttachmentContentDisposition } from "./file-download/content-disposition.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; @@ -414,7 +415,6 @@ export interface PaseoDaemonConfig { plugins?: Record<string, PluginSource>; pluginRegistries?: PluginRegistries; pluginRegistryUrl?: string; - pluginRegistryEnabled?: boolean; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -625,7 +625,6 @@ export async function createPaseoDaemon( usageSession: (id) => agentManager.usageSession(id), }, managedSources: new ManagedPluginSources(config.paseoHome, { - enabled: config.pluginRegistryEnabled ?? false, registries: config.pluginRegistries, defaultUrl: config.pluginRegistryUrl, }), @@ -833,18 +832,21 @@ export async function createPaseoDaemon( try { fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS); const fileStats = await fileHandle.stat(); + if (res.destroyed) { + return; + } if (!fileStats.isFile()) { res.status(404).json({ error: "File not found" }); return; } - const safeFileName = entry.fileName.replace(/["\r\n]/g, "_"); res.setHeader("Content-Type", entry.mimeType); - res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`); + res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName)); res.setHeader("Content-Length", fileStats.size.toString()); const stream = fileHandle.createReadStream(); fileHandle = null; + res.once("close", () => stream.destroy()); stream.on("error", (err) => { logger.error({ err }, "Failed to stream download"); if (!res.headersSent) { @@ -1033,10 +1035,13 @@ export async function createPaseoDaemon( const ensureWorkspaceForCreateExternal = async ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ): Promise<string> => { const workspace = await workspaceProvisioning.createWorkspaceForDirectory( cwd, resolveFirstAgentPromptTitle(firstAgentContext), + undefined, + context, ); if (firstAgentContext) { workspaceAutoName.scheduleForDirectory({ @@ -1083,8 +1088,9 @@ export async function createPaseoDaemon( const ensureWorkspaceForCreateAndBroadcastExternal = async ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ): Promise<string> => { - const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext); + const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext, context); await emitWorkspaceUpdatesExternal([workspaceId]); return workspaceId; }; @@ -1401,11 +1407,12 @@ export async function createPaseoDaemon( emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, workspaceRegistry, projectRegistry, - createDirectoryWorkspace: async (cwd, title, projectId) => { + createDirectoryWorkspace: async (cwd, title, projectId, context) => { const workspace = await workspaceProvisioning.createWorkspaceForDirectory( cwd, title, projectId, + context, ); await emitWorkspaceUpdatesExternal([workspace.workspaceId]); return workspace; @@ -1876,7 +1883,7 @@ export async function createPaseoDaemon( const AGENT_CLOSE_TIMEOUT_MS = 5_000; async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promise<void> { - const agents = agentManager.listAgents(); + const agents = agentManager.listAgents({ includeInternal: true }); await Promise.all( agents.map(async (agent) => { try { diff --git a/packages/server/src/server/cli-agent-logs.test.ts b/packages/server/src/server/cli-agent-logs.test.ts new file mode 100644 index 0000000000..634d0615c0 --- /dev/null +++ b/packages/server/src/server/cli-agent-logs.test.ts @@ -0,0 +1,283 @@ +import { test } from "vitest"; +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import type { Readable } from "node:stream"; +import { join } from "node:path"; +import { createDaemonTestContext } from "./test-utils/daemon-test-context.js"; +import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; +import type { AgentStreamEvent } from "@getpaseo/protocol/agent-types"; + +interface CliResult { + exitCode: number | null; + stdout: string; + stderr: string; +} +type CliProcess = Promise<CliResult> & { stdout: Readable; kill: (signal: NodeJS.Signals) => void }; + +function waitForCliOutput(child: CliProcess, text: string): Promise<void> { + return new Promise((resolve, reject) => { + let output = ""; + const onData = (chunk: Buffer) => { + output += chunk.toString(); + if (output.includes(text)) { + cleanup(); + resolve(); + } + }; + const cleanup = () => { + clearTimeout(timer); + child.stdout.off("data", onData); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`CLI did not print ${text}:\n${output}`)); + }, 20_000); + child.stdout.on("data", onData); + void child.then( + (result) => { + cleanup(); + return reject(new Error(`CLI exited before ${text}:\n${result.stderr}`)); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + +test("logs --since filters history and live output through the CLI and daemon", async () => { + const cliHome = await mkdtemp(join(tmpdir(), "paseo-logs-cli-home-")); + const port = 1; // Invalid dates must fail before trying this unavailable endpoint. + const runLocalPaseo = ( + args: string[], + env: NodeJS.ProcessEnv = {}, + cwd = cliHome, + ): CliProcess => { + const child = spawn( + process.execPath, + [ + "--no-warnings", + "--conditions=source", + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)), + ...args, + ], + { + cwd, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + ...env, + HOME: cliHome, + USERPROFILE: cliHome, + }, + stdio: ["ignore", "pipe", "pipe"], + timeout: 25_000, + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const done = new Promise<CliResult>((resolve, reject) => { + child.on("error", reject); + child.on("close", (exitCode) => resolve({ exitCode, stdout, stderr })); + }); + return Object.assign(done, { + stdout: child.stdout, + kill: (signal: NodeJS.Signals) => { + child.kill(signal); + }, + }); + }; + // Follow assertions check output; Windows forcibly terminates Node on SIGINT. + try { + console.log("--since filters logs through the real CLI and daemon"); + const historyEvents: AgentStreamEvent[] = []; + const ctx = await createDaemonTestContext({ + agentClients: createTestAgentClients({ historyEvents }), + }); + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd: ctx.daemon.paseoHome, + title: "logs since", + }); + const manager = ctx.daemon.daemon.agentManager; + await manager.appendTimelineItem(agent.id, { type: "user_message", text: "OLD_USER" }); + await manager.appendTimelineItem(agent.id, { type: "assistant_message", text: "OLD_REPLY" }); + const host = `127.0.0.1:${ctx.daemon.port}`; + const logs = (args: string[]) => + runLocalPaseo(["--host", host, ...args], {}, ctx.daemon.paseoHome); + + const baseline = await logs(["logs", agent.id]); + assert.strictEqual(baseline.exitCode, 0, baseline.stderr); + assert.strictEqual(baseline.stdout, "[User] OLD_USER\nOLD_REPLY\n"); + + for (const command of [["logs"], ["agent", "logs"]]) { + const result = await logs([...command, agent.id, "--since", "2100-01-01T00:00:00Z"]); + console.log(`paseo ${command.join(" ")} <id> --since 2100-01-01T00:00:00Z`); + console.log(`exit=${result.exitCode}\nstdout:\n${result.stdout}stderr:\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.strictEqual(result.stdout, "No activity to display.\n"); + } + + const timeline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const firstTimestamp = timeline.entries[0]?.timestamp; + assert(firstTimestamp !== undefined, "the fixture should have timestamped activity"); + for (const since of ["2000-01-01T00:00:00Z", firstTimestamp]) { + const result = await logs(["logs", agent.id, "--since", since]); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.strictEqual(result.stdout, baseline.stdout); + } + + const offset = await logs(["logs", agent.id, "--since", "2100-01-01T08:00:00+08:00"]); + assert.strictEqual(offset.exitCode, 0, offset.stderr); + assert.strictEqual(offset.stdout, "No activity to display.\n"); + + const tail = await logs([ + "logs", + agent.id, + "--since", + "2000-01-01T00:00:00Z", + "--filter", + "text", + "--tail", + "1", + ]); + assert.strictEqual(tail.exitCode, 0, tail.stderr); + assert.strictEqual(tail.stdout, "OLD_REPLY\n"); + + const empty = await logs(["logs", agent.id, "--since", firstTimestamp, "--tail", "0"]); + assert.strictEqual(empty.exitCode, 0, empty.stderr); + assert.strictEqual(empty.stdout, ""); + + for (const followArgs of [[], ["--follow"]]) { + const invalid = await runLocalPaseo([ + "--host", + `127.0.0.1:${port}`, + "logs", + "missing-agent", + "--since", + "not-a-date", + "--json", + ...followArgs, + ]); + assert.strictEqual(invalid.exitCode, 1, invalid.stderr); + assert.strictEqual(invalid.stdout, ""); + assert.deepStrictEqual(JSON.parse(invalid.stderr), { + error: { + code: "INVALID_TIMESTAMP", + message: "Invalid --since value: not-a-date", + details: "Use a timestamp such as 2026-01-01T00:00:00Z.", + }, + }); + } + + console.log("--since filters initial history in follow mode"); + const follow = logs(["logs", agent.id, "--follow", "--since", "2100-01-01T00:00:00Z"]); + try { + await waitForCliOutput(follow, "--- Following logs"); + } finally { + await follow.kill("SIGINT"); + } + const followed = await follow; + console.log(`follow exit=${followed.exitCode}\nstdout:\n${followed.stdout}`); + assert.strictEqual(followed.stdout.includes("OLD_USER"), false); + assert.strictEqual(followed.stdout.includes("OLD_REPLY"), false); + + console.log("--since filters live events in follow mode"); + const futureFollow = logs([ + "agent", + "logs", + agent.id, + "-f", + "--tail", + "0", + "--since", + "2100-01-01T00:00:00Z", + ]); + try { + await waitForCliOutput(futureFollow, "--- Following logs"); + // A timestamped history replay follows the live item on the same socket. + // Its visible output proves the CLI consumed the earlier item too. + await Promise.all([ + waitForCliOutput(futureFollow, "AFTER_CUTOFF"), + (async () => { + await manager.appendTimelineItem(agent.id, { + type: "user_message", + text: "LIVE_BEFORE_CUTOFF", + }); + historyEvents.push({ + type: "timeline", + provider: "codex", + timestamp: "2110-01-01T00:00:00Z", + item: { type: "user_message", text: "AFTER_CUTOFF" }, + }); + await ctx.client.refreshAgent(agent.id); + })(), + ]); + } finally { + await futureFollow.kill("SIGINT"); + } + const futureOutput = await futureFollow; + console.log(`future follow exit=${futureOutput.exitCode}\nstdout:\n${futureOutput.stdout}`); + assert.strictEqual(futureOutput.stdout.includes("LIVE_BEFORE_CUTOFF"), false); + + console.log("follow still prints live events after --since"); + const pastFollow = logs([ + "logs", + agent.id, + "--follow", + "--tail", + "0", + "--filter", + "text", + "--since", + "2000-01-01T00:00:00Z", + ]); + try { + await waitForCliOutput(pastFollow, "--- Following logs"); + await Promise.all([ + // Wait for a later visible item to close the streamed reply. + // Windows SIGINT forcibly exits without running shutdown handlers. + waitForCliOutput(pastFollow, "[User] LIVE_AFTER_REPLY\n"), + (async () => { + await manager.appendTimelineItem(agent.id, { type: "user_message", text: "LIVE_USER" }); + await manager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: "LIVE_REPLY", + }); + await manager.appendTimelineItem(agent.id, { + type: "user_message", + text: "LIVE_AFTER_REPLY", + }); + })(), + ]); + } finally { + await pastFollow.kill("SIGINT"); + } + const pastOutput = await pastFollow; + console.log(`past follow exit=${pastOutput.exitCode}\nstdout:\n${pastOutput.stdout}`); + assert(pastOutput.stdout.includes("[User] LIVE_USER\nLIVE_REPLY\n"), pastOutput.stdout); + } finally { + await ctx.cleanup(); + } + } finally { + await rm(cliHome, { recursive: true, force: true }); + } +}, 120_000); diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts index 2442ee5dc5..24134035de 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -30,7 +30,6 @@ export const DAEMON_SETTING_ENV_KEYS = [ "PASEO_LOG_ROTATE_SIZE", "PASEO_PASSWORD", "PASEO_PLUGIN_REGISTRY", - "PASEO_PLUGIN_REGISTRY_ENABLED", "PASEO_RELAY_ENABLED", "PASEO_RELAY_ENDPOINT", "PASEO_RELAY_PUBLIC_ENDPOINT", diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 02a315c9a5..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -211,15 +211,6 @@ test("loads private plugin registry settings through the configuration boundary" }); expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); expect(config.pluginRegistries).toEqual(pluginRegistries); - expect(config.pluginRegistryEnabled).toBe(false); - await writeFile( - path.join(home, "config.json"), - JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }), - ); - expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true); - expect( - loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled, - ).toBe(false); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 61481ce1f9..7b2d06560e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -628,10 +628,6 @@ export function resolveConfigFromPersisted( plugins: persisted.plugins, pluginRegistries: persisted.pluginRegistries, pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, - pluginRegistryEnabled: - parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ?? - persisted.pluginRegistryEnabled ?? - false, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/creation/creation.e2e.test.ts b/packages/server/src/server/creation/creation.e2e.test.ts index f0d21be18f..f4d35bd3f3 100644 --- a/packages/server/src/server/creation/creation.e2e.test.ts +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import type { z } from "zod"; +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import { WebSocket } from "ws"; import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; import { randomUUID } from "node:crypto"; @@ -9,10 +10,23 @@ import { join } from "node:path"; import { expect, test, vi } from "vitest"; import pino from "pino"; import type { CreationSnapshot, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +function createCreationDaemon(options: Parameters<typeof createTestPaseoDaemon>[0] = {}) { + const agentClients = options.agentClients ?? createTestAgentClients(); + return createTestPaseoDaemon({ + ...options, + agentClients, + // Installed provider CLIs must not change when background naming starts. + providerOverrides: Object.fromEntries( + BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: id in agentClients }]), + ), + }); +} + function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { @@ -29,7 +43,7 @@ test("creation progresses before agent readiness and continues after the disconn const directory = await mkdtemp(join(tmpdir(), "creation-wire-")); let agents = 0; let prompts = 0; - const daemon = await createTestPaseoDaemon({ + const daemon = await createCreationDaemon({ logger: pino( { level: "trace" }, { @@ -41,11 +55,13 @@ test("creation progresses before agent readiness and continues after the disconn }, ), agentClients: createTestAgentClients({ - beforeCreateSession: async () => { + beforeCreateSession: async (config) => { + if (config.internal) return; agents++; await provider.promise; }, - onStartTurn: () => { + onStartTurn: (_prompt, config) => { + if (config.internal) return; prompts++; }, }), @@ -122,11 +138,12 @@ test("creation progresses before agent readiness and continues after the disconn await client.close(); await observer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000); async function connectCreationPeer(port: number) { + const timeout = 30_000; const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); const frames: SessionOutboundMessage[] = []; socket.on("message", (data) => { @@ -147,7 +164,9 @@ async function connectCreationPeer(port: number) { }), ); await expect - .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info")) + .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info"), { + timeout, + }) .toBe(true); return { close: () => socket.close(), @@ -160,7 +179,7 @@ async function connectCreationPeer(port: number) { (m) => "payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId, ); - await expect.poll(response).toBeDefined(); + await expect.poll(response, { timeout }).toBeDefined(); return response()!; }, }; @@ -170,7 +189,7 @@ test.each([false, true])( "workspace identity does not depend on subscribing (first subscribe=%s)", async (subscribe) => { const directory = await mkdtemp(join(tmpdir(), "creation-identity-")); - const daemon = await createTestPaseoDaemon(); + const daemon = await createCreationDaemon(); const peer = await connectCreationPeer(daemon.port); try { const request = { @@ -188,7 +207,7 @@ test.each([false, true])( } finally { peer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000, @@ -199,10 +218,13 @@ test.each(["create_agent_request", "agent.create.request"] as const)( async (type) => { const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-")); let creations = 0; - const daemon = await createTestPaseoDaemon({ + const daemon = await createCreationDaemon({ agentClients: createTestAgentClients({ - beforeCreateSession: async () => { + beforeCreateSession: async (config) => { + if (config.internal) return; creations++; + // A real provider or Git checkout may take longer than Vitest's default 1s poll budget. + await new Promise((resolve) => setTimeout(resolve, 1_200)); }, }), }); @@ -241,7 +263,7 @@ test.each(["create_agent_request", "agent.create.request"] as const)( } finally { peer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000, @@ -264,7 +286,7 @@ test("legacy keyed creation preserves checkout error codes", async () => { ], { cwd: directory, stdio: "pipe" }, ); - const daemon = await createTestPaseoDaemon({ agentClients: createTestAgentClients() }); + const daemon = await createCreationDaemon({ agentClients: createTestAgentClients() }); const peer = await connectCreationPeer(daemon.port); try { const result = await peer.request({ @@ -281,6 +303,95 @@ test("legacy keyed creation preserves checkout error codes", async () => { } finally { peer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000); + +test.each(["agent", "workspace"] as const)( + "%s creation succeeds when the provider rejects its initial prompt", + async (kind) => { + const directory = await mkdtemp(join(tmpdir(), "creation-prompt-rejected-")); + const rejection = "Input exceeds the maximum length of 1048576 characters."; + let sessions = 0; + let prompts = 0; + const daemon = await createCreationDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async (config) => { + if (config.internal) return; + sessions++; + }, + onStartTurn: (_prompt, config) => { + if (config.internal) return; + prompts++; + throw new Error(rejection); + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.1", + }); + const snapshots: CreationSnapshot[] = []; + const agent = { + config: { provider: "codex", cwd: directory }, + initialPrompt: "Continue the attached conversation.", + clientMessageId: "draft:initial-message", + }; + const streamed: AgentTimelineItem[] = []; + const unsubscribe = daemon.daemon.agentManager.subscribe((event) => { + if (event.type === "agent_stream" && event.event.type === "timeline") { + streamed.push(event.event.item); + } + }); + try { + await client.connect(); + const submit = async () => { + const common = { + idempotencyKey: "rejected-first-prompt", + onEvent: (snapshot: CreationSnapshot) => snapshots.push(snapshot), + }; + if (kind === "agent") return client.createAgent({ ...common, ...agent }); + const result = await client.createWorkspace({ + ...common, + source: { kind: "directory", path: directory }, + agent, + }); + expect(result.error).toBeNull(); + return result.agent!; + }; + const created = await submit(); + expect(created).toMatchObject({ status: "error", lastError: rejection }); + expect(snapshots.map((snapshot) => snapshot.phase)).not.toContain("prompt_started"); + expect(snapshots.at(-1)).toMatchObject({ + phase: "completed", + error: null, + agent: { id: created.id }, + }); + const expectedTimeline = [ + { + type: "user_message", + text: agent.initialPrompt, + clientMessageId: agent.clientMessageId, + messageId: agent.clientMessageId, + }, + { type: "assistant_message", text: `[System Error] ${rejection}` }, + ]; + expect( + (await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item), + ).toEqual(expectedTimeline); + expect(streamed).toEqual(expectedTimeline); + expect((await submit()).id).toBe(created.id); + expect( + (await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item), + ).toEqual(expectedTimeline); + expect((await client.fetchAgents()).entries).toHaveLength(1); + expect({ sessions, prompts }).toEqual({ sessions: 1, prompts: 1 }); + } finally { + unsubscribe(); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + }, + 60000, +); diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index 1eaf4a7fd9..a165110ef8 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -93,7 +93,7 @@ async function fixture() { calls.push("agent"); await onReady(agent); calls.push("prompt"); - return agent; + return { agent, initialPromptStarted: true }; }, }; const updates: CreationSnapshot[] = []; @@ -129,7 +129,7 @@ test("a failed agent startup retries only that stage with the reserved IDs", asy expect(readyWorkspace).toEqual(workspace); if (attempts === 1) throw new Error("provider unavailable"); await onReady(agent); - return agent; + return { agent, initialPromptStarted: true }; }; expect(await f.service.create(f.input)).toMatchObject({ phase: "failed", @@ -210,7 +210,7 @@ test("omitted resource IDs are generated once and preserved on replay", async () f.input.createAgent = async (id, createdWorkspace, onReady) => { const created = { ...agent, id, workspaceId: createdWorkspace!.id }; await onReady(created); - return created; + return { agent: created, initialPromptStarted: true }; }; const result = await f.service.create(f.input); expect(result.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/); @@ -231,7 +231,7 @@ test("restart after a committed agent without a prompt completes without recreat await onReady(agent); // Capture the durable state at the exact milestone a process could stop after. await cp(f.directory, recoveredDirectory, { recursive: true }); - return agent; + return { agent, initialPromptStarted: false }; }; await f.service.create(f.input); const recovered = await new CreationService(recoveredDirectory, silentLogger).create(f.input); diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts index 3d7a42efdb..e0b28b8069 100644 --- a/packages/server/src/server/creation/index.ts +++ b/packages/server/src/server/creation/index.ts @@ -13,6 +13,10 @@ import { writeFileAtomic } from "../atomic-file.js"; import { generateWorkspaceId } from "../workspace-registry-model.js"; type Observer = (snapshot: CreationSnapshot) => void; +export interface CreatedAgent { + agent: AgentSnapshotPayload; + initialPromptStarted: boolean; +} interface CreationRequest { key: string; request: { [key: string]: unknown }; @@ -28,7 +32,7 @@ interface CreationRequest { agentId: string, workspace: WorkspaceDescriptorPayload | undefined, onReady: (agent: AgentSnapshotPayload) => Promise<void>, - ) => Promise<AgentSnapshotPayload>; + ) => Promise<CreatedAgent>; } export type CreationInput = CreationRequest & ( @@ -167,7 +171,7 @@ export class CreationService { if (input.hasAgent && input.createAgent && !record.snapshot.agent) { record.inFlight = "agent"; await this.write(identity, record); - const agent = await input.createAgent( + const { agent, initialPromptStarted } = await input.createAgent( record.snapshot.agentId!, record.snapshot.workspace, async (readyAgent) => { @@ -180,7 +184,7 @@ export class CreationService { }, ); record.inFlight = null; - if (input.hasPrompt) + if (initialPromptStarted) await this.publish(identity, record, { phase: "prompt_started", agent }); else record.snapshot = { ...record.snapshot, agent }; } diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index f429cb672c..0bc3310b41 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -831,6 +831,29 @@ test("send_agent_message auto-unarchives archived agents", async () => { } }, 180000); +test("send_agent_message leaves an archived agent archived when its directory is gone", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + const archived = await ctx.client.archiveAgent(created.id); + rmSync(cwd, { recursive: true, force: true }); + + await expect(ctx.client.sendMessage(created.id, "hello")).rejects.toThrow( + "Working directory does not exist", + ); + + const afterSend = await ctx.client.fetchAgent({ agentId: created.id }); + expect(afterSend?.agent.archivedAt).toBe(archived.archivedAt); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("refresh_agent auto-unarchives archived agents", async () => { const cwd = tmpCwd(); try { diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts new file mode 100644 index 0000000000..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 f9d7fce7b4..862923dec6 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -1,5 +1,15 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + rmSync, + readdirSync, + readlinkSync, + realpathSync, + symlinkSync, +} from "fs"; +import { once } from "node:events"; +import { get } from "node:http"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -8,6 +18,41 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } +// Linux exposes the real open descriptors without replacing file or HTTP methods. +function openDownloadDescriptors(filePath: string): string[] { + const realFilePath = realpathSync(filePath); + return readdirSync("/proc/self/fd").filter((fd) => { + try { + return readlinkSync(`/proc/self/fd/${fd}`) === realFilePath; + } catch (error) { + // A descriptor can close between listing it and reading its link. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }); +} + +async function expectDownloadClosed(filePath: string): Promise<void> { + await expect.poll(() => openDownloadDescriptors(filePath)).toEqual([]); +} + +async function cancelDownloadAfterFirstChunk(url: string): Promise<void> { + const request = get(url); + try { + const [response] = await once(request, "response"); + expect(response.statusCode).toBe(200); + const [chunk] = await once(response, "data"); + expect(chunk.length).toBeGreaterThan(0); + expect(chunk.length).toBeLessThan(64 * 1024 * 1024); + const closed = once(response, "close"); + response.destroy(); + request.destroy(); + await closed; + } finally { + request.destroy(); + } +} + // Use gpt-5.4-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; @@ -24,42 +69,110 @@ describe("daemon E2E", () => { }, 60000); describe("file download tokens", () => { - test("issues token over WS and downloads via HTTP", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "download.txt"); - const fileContents = "download test payload"; - writeFileSync(filePath, fileContents, "utf-8"); + test.skipIf(process.platform !== "linux")( + "releases file descriptors after repeated HTTP download cancellations", + async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "linked-files"); + symlinkSync(mkdtempSync(path.join(directory, "files-")), cwd, "dir"); + const filePath = path.join(cwd, "large.bin"); + const fileSize = 64 * 1024 * 1024; + writeFileSync(filePath, Buffer.alloc(fileSize)); + try { + const completedToken = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(completedToken.error).toBeNull(); + const completed = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${completedToken.token}`, + ); + expect(completed.status).toBe(200); + expect((await completed.arrayBuffer()).byteLength).toBe(fileSize); + await expectDownloadClosed(filePath); - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Download Token Test Agent", - }); + for (let attempt = 0; attempt < 3; attempt++) { + const token = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(token.error).toBeNull(); + await cancelDownloadAfterFirstChunk( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${token.token}`, + ); + await expectDownloadClosed(filePath); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); - expect(agent.id).toBeTruthy(); + test.each([ + { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "報告書.md", + disposition: + "attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md", + }, + { + fileName: "보고서.txt", + disposition: + "attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + ])( + "issues token over WS and downloads $fileName via HTTP", + async ({ fileName, disposition }) => { + const cwd = tmpCwd(); + try { + const filePath = path.join(cwd, fileName); + const fileContents = "download test payload"; + writeFileSync(filePath, fileContents, "utf-8"); - const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt"); + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Download Token Test Agent", + }); - expect(tokenResponse.error).toBeNull(); - expect(tokenResponse.token).toBeTruthy(); - expect(tokenResponse.fileName).toBe("download.txt"); + expect(agent.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); - const response = await fetch( - `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, - ); + const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); - const disposition = response.headers.get("content-disposition") ?? ""; - expect(disposition).toContain("download.txt"); + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(tokenResponse.fileName).toBe(fileName); - const body = await response.text(); - expect(body).toBe(fileContents); + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, + ); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); + expect(response.headers.get("content-disposition")).toBe(disposition); + + const body = await response.text(); + expect(body).toBe(fileContents); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60000, + ); test("rejects invalid token", async () => { const response = await fetch( diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 4bb4736133..0c99744e62 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 { @@ -22,7 +31,524 @@ afterEach(async () => { await ctx.cleanup(); }, 60000); +describe("checkout discard", () => { + test.each([".", "apps/web"])( + "discards selected Changes from the %s workspace without changing siblings", + async (workspacePath) => { + const cwd = tmpCwd(); + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + git(["init", "-b", "main"]); + git(["config", "user.name", "Paseo Test"]); + git(["config", "user.email", "test@getpaseo.local"]); + git(["config", "core.autocrlf", "false"]); + const nested = path.join(cwd, "apps", "web"); + mkdirSync(nested, { recursive: true }); + writeFileSync(path.join(nested, "target[ab].txt"), "target base\n"); + writeFileSync(path.join(nested, "targeta.txt"), "sibling base\n"); + writeFileSync(path.join(nested, "deleted.txt"), "deleted base\n"); + git(["add", "."]); + git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]); + const creation = await ctx.client.createWorkspace({ + source: { kind: "directory", path: path.join(cwd, workspacePath) }, + title: "Discard regression", + }); + const workspaceCwd = creation.workspace?.workspaceDirectory; + if (!workspaceCwd) { + throw new Error(creation.error ?? "Workspace creation failed"); + } + + writeFileSync(path.join(nested, "target[ab].txt"), "target edited\n"); + writeFileSync(path.join(nested, "targeta.txt"), "sibling edited\n"); + writeFileSync(path.join(nested, "staged.txt"), "staged new file\n"); + writeFileSync(path.join(nested, "scratch.txt"), "untracked file\n"); + git(["--literal-pathspecs", "add", "--", "apps/web/target[ab].txt", "apps/web/staged.txt"]); + git(["rm", "apps/web/deleted.txt"]); + + const subscription = ctx.client.observeCheckoutDiff(workspaceCwd, { mode: "uncommitted" }); + try { + const changes = await subscription.ready; + expect(changes.error).toBeNull(); + expect(changes.files.map((file) => file.path).sort()).toEqual([ + "apps/web/deleted.txt", + "apps/web/scratch.txt", + "apps/web/staged.txt", + "apps/web/target[ab].txt", + "apps/web/targeta.txt", + ]); + const selectedPaths = changes.files + .filter((file) => file.path !== "apps/web/targeta.txt") + .map((file) => file.path); + const discarded = await ctx.client.checkoutDiscardChanges(workspaceCwd, { + paths: selectedPaths, + }); + expect(discarded).toMatchObject({ success: true, error: null }); + + const target = await ctx.client.readFile(cwd, "apps/web/target[ab].txt"); + expect(new TextDecoder().decode(target.bytes)).toBe("target base\n"); + const restored = await ctx.client.readFile(cwd, "apps/web/deleted.txt"); + expect(new TextDecoder().decode(restored.bytes)).toBe("deleted base\n"); + const sibling = await ctx.client.readFile(cwd, "apps/web/targeta.txt"); + expect(new TextDecoder().decode(sibling.bytes)).toBe("sibling edited\n"); + await expect(ctx.client.readFile(cwd, "apps/web/staged.txt")).rejects.toThrow(/ENOENT/); + await expect(ctx.client.readFile(cwd, "apps/web/scratch.txt")).rejects.toThrow(/ENOENT/); + expect(git(["status", "--porcelain=v1", "-z"])).toBe(" M apps/web/targeta.txt\0"); + } finally { + await subscription.release(); + } + } finally { + // afterEach shuts down the daemon and its repository observers first. + onTestFinished(() => rmSync(cwd, { recursive: true, force: true })); + } + }, + ); +}); + 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 +651,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/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts index 7c70fce315..1f2086c879 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -165,52 +165,3 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({ await rm(cwd, { recursive: true, force: true }); } }, 120000); - -// Hoplite selects its project from the GitHub repository of the session directory, so this test -// needs a checkout of a repository linked to a Hoplite project. -test("Hoplite creates an agent and answers a prompt with real hoplite acp", async ({ skip }) => { - const command = await findExecutable(process.env.HOPLITE_COMMAND ?? "hoplite"); - const cwd = process.env.HOPLITE_E2E_CWD; - if (command === null || !cwd) { - skip("hoplite is not resolvable or HOPLITE_E2E_CWD is unset"); - return; - } - const daemon = await createTestPaseoDaemon({ - agentClients: {}, - mcpEnabled: false, - builtinPlugins: new BuiltinPluginLoader(undefined, ["hoplite-provider"]), - providerOverrides: { hoplite: { command: [command, "acp"], paseoTools: { enabled: false } } }, - }); - const client = new DaemonClient({ - url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.11.0", - }); - try { - await client.connect(); - await client.fetchAgents({ subscribe: {} }); - await expect - .poll( - async () => { - const snapshot = await client.getProvidersSnapshot({ cwd }); - return snapshot.entries.find((entry) => entry.provider === "hoplite")?.status; - }, - { timeout: 60000 }, - ) - .toBe("ready"); - const agent = await client.createAgent({ provider: "hoplite", cwd, title: "Hoplite E2E" }); - await client.sendMessage(agent.id, "Reply with exactly HOPLITE_E2E_OK. No tools."); - const result = await client.waitForFinish(agent.id, 180000); - expect(result.status).toBe("idle"); - const items = (await client.fetchAgentTimeline(agent.id, { limit: 100 })).entries.map( - (entry) => entry.item, - ); - expect( - items.some( - (item) => item.type === "assistant_message" && item.text.includes("HOPLITE_E2E_OK"), - ), - ).toBe(true); - } finally { - await client.close(); - await daemon.close(); - } -}, 240000); diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index f336f325bf..ab407e31bc 100644 --- a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -10,6 +10,7 @@ import { afterEach, beforeAll, describe, expect, test } from "vitest"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import { MIN_SUPPORTED_OMP_VERSION, + OmpAgentClient, formatOmpVersionSupport, } from "../agent/providers/omp/agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; @@ -48,14 +49,53 @@ async function preflight(): Promise<void> { } } -async function createHarness(): Promise<Harness> { +// OMP exits at startup when no model is usable at all, so the home offers one offline +// model, and an agent on any other provider's model has no credentials. +function createOfflineOmpHome(): string { + const home = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-offline-")); + roots.add(home); + const agentDir = path.join(home, ".omp", "agent"); + mkdirSync(agentDir, { recursive: true }); + writeFileSync( + path.join(agentDir, "models.yml"), + [ + "providers:", + " offline:", + " baseUrl: http://127.0.0.1:9/v1", + " api: openai-completions", + " auth: none", + " models:", + " - id: offline-1", + " name: Offline", + " api: openai-completions", + " reasoning: false", + " input: [text]", + " contextWindow: 128000", + " maxTokens: 4096", + " cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0}", + "", + ].join("\n"), + ); + return home; +} + +async function createHarness(options: { ompHome?: string } = {}): Promise<Harness> { const cwd = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-")); const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-home-")); const staticDir = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-static-")); for (const root of [cwd, paseoHomeRoot, staticDir]) roots.add(root); const logger = pino({ level: process.env.OMP_E2E_LOG_LEVEL ?? "silent" }); const daemon = await createTestPaseoDaemon({ - agentClients: createRealProviderClients(["omp"], logger), + agentClients: options.ompHome + ? { + omp: new OmpAgentClient({ + logger, + runtimeSettings: { + env: { HOME: options.ompHome, ANTHROPIC_API_KEY: "", ANTHROPIC_OAUTH_TOKEN: "" }, + }, + }), + } + : createRealProviderClients(["omp"], logger), providerOverrides: { omp: { enabled: true } }, logger, paseoHomeRoot, @@ -91,6 +131,15 @@ async function timeline(client: DaemonClient, agentId: string): Promise<AgentTim return result.entries.map((entry) => entry.item); } +function compactionFiller(records = 1200): string { + const teas = ["assam", "darjeeling", "sencha", "oolong"]; + return Array.from( + { length: records }, + (_, index) => + `Record ${String(index).padStart(5, "0")}: tea=${teas[index % teas.length]} origin=region-${index % 37} notes=lorem ipsum dolor sit amet`, + ).join("\n"); +} + function latestTools(items: AgentTimelineItem[]) { const latest = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>(); for (const item of items) if (item.type === "tool_call") latest.set(item.callId, item); @@ -185,7 +234,93 @@ afterEach(() => { roots.clear(); }); +async function waitForTimelineItem( + client: DaemonClient, + agentId: string, + predicate: (item: AgentTimelineItem) => boolean, +): Promise<AgentTimelineItem> { + const deadline = Date.now() + 60_000; + let items: AgentTimelineItem[] = []; + while (Date.now() < deadline) { + items = await timeline(client, agentId); + const item = items.find(predicate); + if (item) return item; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for OMP timeline item: ${JSON.stringify(items)}`); +} + +async function contextWindowUsedTokens(client: DaemonClient, agentId: string) { + const result = await client.fetchAgent(agentId); + return result?.agent.lastUsage?.contextWindowUsedTokens; +} + describe("daemon E2E (real OMP)", () => { + test( + "a prompt OMP rejects before its agent runs fails the turn with OMP's error", + async () => { + const harness = await createHarness({ ompHome: createOfflineOmpHome() }); + try { + const agent = await harness.client.createAgent({ + cwd: harness.cwd, + title: "no-credentials", + provider: "omp", + model: "anthropic/claude-haiku-4-5", + modeId: "full", + }); + await harness.client.sendMessage(agent.id, "Reply with ok."); + const finish = await harness.client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("error"); + expect(finish.error).toContain("No API key found for anthropic"); + } finally { + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + + test( + "manual compact shows its progress and refreshes context usage", + async () => { + const harness = await createHarness(); + try { + const agent = await createAgent(harness, "manual-compact"); + await promptAndFinish(harness, agent.id, "Reply exactly: ready"); + await promptAndFinish( + harness, + agent.id, + `${compactionFiller(2500)}\n\nWhich tea is in record 00007? Reply with one word.`, + ); + await promptAndFinish(harness, agent.id, "Reply exactly: after-filler"); + const usedBefore = await contextWindowUsedTokens(harness.client, agent.id); + expect(usedBefore).toBeGreaterThan(20_000); + + await harness.client.sendMessage(agent.id, "/compact"); + await waitForTimelineItem( + harness.client, + agent.id, + (item) => item.type === "compaction" && item.status === "completed", + ); + + expect(await timeline(harness.client, agent.id)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "compaction", status: "loading", trigger: "manual" }), + expect.objectContaining({ type: "compaction", status: "completed", trigger: "manual" }), + ]), + ); + await harness.client.waitForAgentUpsert( + agent.id, + (snapshot) => + (snapshot.lastUsage?.contextWindowUsedTokens ?? Infinity) < (usedBefore ?? 0), + 60_000, + ); + } finally { + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "prompt, native tool, and resumed follow-up", async () => { @@ -447,6 +582,56 @@ describe("daemon E2E (real OMP)", () => { TIMEOUT_MS, ); + test( + "a compaction shows as a compaction row after a daemon restart", + async () => { + const harness = await createHarness(); + let restarted: TestPaseoDaemon | null = null; + let restartedClient: DaemonClient | null = null; + try { + const agent = await createAgent(harness, "compact-restart"); + await promptAndFinish( + harness, + agent.id, + `${compactionFiller()}\n\nReply exactly OMP_COMPACT_FIRST`, + ); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_SECOND"); + await harness.client.sendMessage(agent.id, "/compact"); + await harness.client.waitForFinish(agent.id, TIMEOUT_MS); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_THIRD"); + + await harness.client.close(); + await harness.daemon.close(); + restarted = await createTestPaseoDaemon({ + agentClients: createRealProviderClients(["omp"], pino({ level: "silent" })), + providerOverrides: { omp: { enabled: true } }, + logger: pino({ level: "silent" }), + paseoHomeRoot: harness.paseoHomeRoot, + staticDir: harness.staticDir, + cleanup: false, + }); + restartedClient = new DaemonClient({ url: `ws://127.0.0.1:${restarted.port}/ws` }); + await restartedClient.connect(); + await restartedClient.fetchAgents({ subscribe: {} }); + const replayed = await timeline(restartedClient, agent.id); + + expect( + latestTools(replayed) + .map(toolResult) + .filter((text) => text.includes("Unsupported history record")), + ).toEqual([]); + expect(replayed.filter((item) => item.type === "compaction")).toEqual([ + expect.objectContaining({ type: "compaction", status: "completed" }), + ]); + } finally { + await restartedClient?.close().catch(() => undefined); + await restarted?.close().catch(() => undefined); + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "native branch rewinds conversation history and keeps the session usable", async () => { diff --git a/packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts b/packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts new file mode 100644 index 0000000000..fc68fe44f4 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; + +let ctx: DaemonTestContext; +let cwd: string; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); + cwd = mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); +}); + +afterEach(async () => { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); + +describe("a message larger than the daemon accepts", () => { + const oversizedImage = { data: "A".repeat(101 * 1024 * 1024), mimeType: "image/png" }; + + test("fails the creation and keeps the connection up", async () => { + const statuses: string[] = []; + const unsubscribe = ctx.client.subscribeConnectionStatus((state) => { + statuses.push(state.status); + }); + + await expect( + ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Oversized first message", + initialPrompt: "Describe these screens.", + images: [oversizedImage], + }), + ).rejects.toThrow(/too large/); + unsubscribe(); + + expect(statuses).toEqual(["connected"]); + expect((await ctx.client.fetchAgents()).entries).toEqual([]); + }, 30000); + + test("fails the send and keeps the connection up", async () => { + const agent = await ctx.client.createAgent({ provider: "claude", cwd, title: "Agent" }); + const statuses: string[] = []; + const unsubscribe = ctx.client.subscribeConnectionStatus((state) => { + statuses.push(state.status); + }); + + await expect( + ctx.client.sendMessage(agent.id, "Describe these screens.", { images: [oversizedImage] }), + ).rejects.toThrow(/too large/); + unsubscribe(); + + expect(statuses).toEqual(["connected"]); + }, 30000); +}); diff --git a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts index a4db7ce7bf..16dae500f4 100644 --- a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts @@ -1,6 +1,6 @@ import { execFile as execFileCallback } from "node:child_process"; import { mkdtempSync, realpathSync } from "node:fs"; -import { readFile, rm } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -63,10 +63,12 @@ test("an empty project becomes Git without changing its identity or creating a w cleanupClients.add(client); await client.connect(); await client.fetchAgents({ subscribe: {} }); + const projectEvents = client.observeEvents(["project.update"]); + await projectEvents.ready; const added = await client.addProject(projectRoot); - expect(added).toEqual({ + expect(added).toMatchObject({ requestId: expect.any(String), project: { projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/), @@ -77,7 +79,11 @@ test("an empty project becomes Git without changing its identity or creating a w }, error: null, }); - const project = added.project!; + const { + projectIconRevision: _icon, + projectCustomIconRevision: _customIcon, + ...project + } = added.project!; const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] }); @@ -96,15 +102,15 @@ test("an empty project becomes Git without changing its identity or creating a w label: "project.update after git init", }); - expect(update).toEqual({ + expect(update).toMatchObject({ kind: "upsert", - project: { ...project, projectKind: "git" }, + project: { ...project, projectKind: "git", projectKey: expect.any(String) }, }); const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } }); expect(afterGitInit).toMatchObject({ entries: [], - emptyProjects: [{ ...project, projectKind: "git" }], + emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }], }); const persistedProjects = JSON.parse( @@ -119,5 +125,72 @@ test("an empty project becomes Git without changing its identity or creating a w createdAt: expect.any(String), updatedAt: expect.any(String), archivedAt: null, + projectKey: expect.any(String), + customIconRevision: null, }); }, 30_000); + +test("a failed worktree attempt before git init does not prevent creation afterward", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-"))); + const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-"))); + cleanupPaths.add(projectRoot); + cleanupPaths.add(paseoHomeRoot); + await writeFile(path.join(projectRoot, "README.md"), "hi\n"); + + const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false }); + cleanupDaemons.add(daemon); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + cleanupClients.add(client); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const projectEvents = client.observeEvents(["project.update"]); + await projectEvents.ready; + const added = await client.addProject(projectRoot); + expect(added.project?.projectKind).toBe("non_git"); + const source = { + kind: "worktree" as const, + cwd: projectRoot, + projectId: added.project!.projectId, + action: "branch-off" as const, + refName: "main", + branchName: "after-init", + worktreeSlug: "after-init", + }; + const before = await client.createWorkspace({ source }); + expect(before.error).toBe("Create worktree requires a git repository"); + + const gitProjectUpdate = waitForProjectUpdate( + client, + (payload) => + payload.kind === "upsert" && + payload.project.projectId === added.project!.projectId && + payload.project.projectKind === "git", + ); + await execFile("git", ["init", "-b", "main"], { cwd: projectRoot }); + await execFile("git", ["add", "README.md"], { cwd: projectRoot }); + await execFile( + "git", + [ + "-c", + "user.name=Paseo Test", + "-c", + "user.email=test@getpaseo.dev", + "-c", + "commit.gpgSign=false", + "commit", + "-m", + "initial", + ], + { cwd: projectRoot }, + ); + await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" }); + const projects = await client.fetchWorkspaces({ + filter: { projectId: added.project!.projectId }, + }); + expect(projects.emptyProjects[0]?.projectKind).toBe("git"); + + const created = await client.createWorkspace({ source }); + expect(created.error).toBeNull(); + expect(created.workspace?.workspaceKind).toBe("worktree"); + expect(created.workspace?.projectId).toBe(added.project!.projectId); +}, 30_000); diff --git a/packages/server/src/server/daemon-e2e/project-path-spaces.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-path-spaces.e2e.test.ts new file mode 100644 index 0000000000..89385c67db --- /dev/null +++ b/packages/server/src/server/daemon-e2e/project-path-spaces.e2e.test.ts @@ -0,0 +1,73 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, expect, onTestFinished, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +// Windows does not preserve trailing spaces in folder names. +const testOnPosix = test.skipIf(process.platform === "win32"); + +let client: DaemonClient; +let repoDir: string; +let projectId: string; + +beforeEach(async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-path-spaces-"))); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + repoDir = join(root, "repo "); + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Paseo Test", + "-c", + "user.email=test@getpaseo.local", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repoDir, stdio: "pipe" }, + ); + const daemon = await createTestPaseoDaemon(); + onTestFinished(() => daemon.close()); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const testClient = client; + onTestFinished(() => testClient.close()); + await client.connect(); + const added = await client.addProject(repoDir); + expect(added.error).toBeNull(); + expect(added.project?.projectRootPath).toBe(repoDir); + projectId = added.project!.projectId; +}); + +testOnPosix("a project folder ending in a space keeps its Git status", async () => { + const opened = await client.createWorkspace({ + source: { kind: "directory", path: repoDir, projectId }, + }); + expect(opened.error).toBeNull(); + expect(opened.workspace?.workspaceDirectory).toBe(repoDir); + const status = await client.getCheckoutStatus(repoDir); + expect(status.isGit).toBe(true); + expect(status.repoRoot).toBe(repoDir); + expect(status.currentBranch).toBe("main"); +}); + +testOnPosix("a project folder ending in a space can create a worktree", async () => { + const created = await client.createWorkspace({ + source: { + kind: "worktree", + projectId, + action: "branch-off", + branchName: "feature/spaces", + baseBranch: "main", + }, + }); + expect(created.error).toBeNull(); + expect(created.workspace?.gitRuntime?.currentBranch).toBe("feature/spaces"); +}); diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 3edede83d3..4da97d6eba 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "vitest"; +import { withTimeout } from "../../utils/promise-timeout.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; import { WebSocket } from "ws"; import pino from "pino"; import { Writable } from "node:stream"; @@ -6,9 +8,13 @@ import net from "node:net"; import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { Buffer } from "node:buffer"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { deriveSharedKey, @@ -73,6 +79,10 @@ function decodeOfferFromFragmentUrl(url: string): { return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 }; } +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + function encodeCiphertext(ciphertext: ArrayBuffer): string { return Buffer.from(new Uint8Array(ciphertext)).toString("base64"); } @@ -202,12 +212,20 @@ async function waitForCapturedLog( if (options.useLocalRelay) { relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:"); } - relayProcess = spawn("npx", relayArgs, { - cwd: relayDir, - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); + relayProcess = spawn( + process.execPath, + [ + "--experimental-vm-modules", + path.resolve(relayDir, "../../node_modules/wrangler/wrangler-dist/cli.js"), + ...relayArgs.slice(1), + ], + { + cwd: relayDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }, + ); relayProcess.stdout?.on("data", (data: Buffer) => { const lines = data @@ -241,6 +259,73 @@ async function waitForCapturedLog( relayProcess = null; }; + test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { + await startRelay({ useLocalRelay: true }); + const daemon = await createTestPaseoDaemon({ + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayUseTls: false, + relayPublicUseTls: false, + }); + let client: DaemonClient | null = null; + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: true, + relayEndpoint: daemon.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + client = new DaemonClient({ + url: buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + e2ee: { enabled: true, daemonPublicKeyB64 }, + reconnect: { baseDelayMs: 100, maxDelayMs: 100 }, + }); + await client.connect(); + const statuses: string[] = []; + client.subscribeConnectionStatus((state) => statuses.push(state.status)); + for (const imageMiB of [30, 61]) { + // Attachments are base64 in the JSON, then E2EE base64 expands the frame again. + const images = [ + { data: "A".repeat(4 * Math.ceil((imageMiB * 1024 * 1024) / 3)), mimeType: "image/png" }, + ]; + await expect( + withTimeout( + client.createWorkspace({ + source: { kind: "directory", path: daemon.staticDir }, + agent: { + provider: "claude", + cwd: daemon.staticDir, + initialPrompt: "Describe these screens.", + images, + }, + }), + 10000, + "Workspace creation did not settle", + ), + ).rejects.toThrow(/too large.*32\.0 MiB/); + expect((await client.fetchAgents()).entries).toEqual([]); + } + const agent = await client.createAgent({ provider: "claude", cwd: daemon.staticDir }); + await expect( + client.sendMessage(agent.id, "Describe these screens.", { + images: [{ data: "A".repeat(40 * 1024 * 1024), mimeType: "image/png" }], + }), + ).rejects.toThrow(/too large.*32\.0 MiB/); + await client.sendMessage(agent.id, "A small follow-up still works."); + expect((await client.fetchAgents()).entries).toHaveLength(1); + expect(statuses).toEqual(["connected"]); + } finally { + await client?.close(); + await daemon.close(); + await stopRelay(); + } + }, 60000); + test("daemon connects to relay and client ping/pong works through relay", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; @@ -783,4 +868,67 @@ async function waitForCapturedLog( await stopRelay(); } }, 90000); + + test("reads a workspace file over the E2EE relay session byte-for-byte", async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + let daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | null = null; + let workspaceDir: string | null = null; + const fileName = "rapport données.bin"; + const source = randomBytes(4 * FILE_EXPLORER_STREAM_CHUNK_BYTES + 123); + let client: DaemonClient | null = null; + + try { + await startRelay({ useLocalRelay: true }); + daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + }); + workspaceDir = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-download-")); + await writeFile(path.join(workspaceDir, fileName), source); + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + + client = new DaemonClient({ + url: buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + clientType: "cli", + connectTimeoutMs: 30_000, + e2ee: { enabled: true, daemonPublicKeyB64 }, + reconnect: { enabled: false }, + }); + await client.connect(); + + const result = await client.readFile(workspaceDir, fileName, undefined, undefined, 0); + + expect(result.size).toBe(source.byteLength); + expect(result.bytes.byteLength).toBe(source.byteLength); + expect(sha256(result.bytes)).toBe(sha256(source)); + } catch (err) { + const tail = lines.slice(-50).join(""); + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", tail); + throw err; + } finally { + await client?.close().catch(() => undefined); + await daemon?.close(); + await stopRelay(); + if (workspaceDir) { + await rm(workspaceDir, { recursive: true, force: true }); + } + } + }, 90000); }); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 2471ff8c5a..5eea6dab24 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -304,6 +304,166 @@ async function waitForRunningClaudeSleep( ); } +// Explicit point-in-time polls of agent status, on top of (not instead of) the pushed +// agent_update events the collector already records. A phantom "running" flip that starts and +// ends between two polls would still be caught by the pushed-event scan the callers pair this +// with; polling here additionally proves the daemon answers fetchAgent with "idle" throughout, +// not just that it eventually pushes one. +async function sampleAgentStatuses( + client: DaemonClient, + agentId: string, + samples: number, + intervalMs: number, +): Promise<string[]> { + const statuses: string[] = []; + for (let i = 0; i < samples; i += 1) { + const snapshot = await client.fetchAgent({ agentId }); + statuses.push(snapshot?.agent.status ?? "missing"); + if (i < samples - 1) { + await sleep(intervalMs); + } + } + return statuses; +} + +// NOTE: agent_stream events on the wire (AgentStreamEventPayloadSchema in +// packages/protocol/src/messages.ts) do not include a "provider_subagent" variant. A backgrounded +// subagent's descriptor updates are forwarded exclusively as separate "agent.provider_subagents.update" +// messages (see forwardProviderSubagentUpdate in session.ts), and that push channel's delivery is +// gated by capabilities this test's plain DaemonClient does not declare. list_provider_subagents is a +// plain request/response RPC unaffected by that gating, so status history below polls it directly +// instead of trying to reconstruct status transitions from the stream. +type ProviderSubagentDescriptor = Awaited< + ReturnType<DaemonClient["listProviderSubagents"]> +>["subagents"][number]; + +interface ProviderSubagentPoll { + subagent: ProviderSubagentDescriptor; + // Every status observed while polling, in order, so a transient cancel cannot hide behind a + // later "completed" read even though the store's status field is not expected to un-cancel. + history: string[]; +} + +// Polls agent.provider_subagents.list until the backgrounded helper is registered at all. +// "Main's turn is running" (waitForAgentUpsert) fires on generic turn-start bookkeeping, which +// can be true before Claude has actually issued the Agent tool call; canceling that early +// cancels a turn with no helper launched yet, which is not what "Stop spares a helper" means to +// test. This confirms the helper actually exists before the caller acts on it. +async function waitForProviderSubagentToExist( + client: DaemonClient, + agentId: string, + timeoutMs = 60_000, +): Promise<ProviderSubagentDescriptor> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const { subagents } = await client.listProviderSubagents(agentId); + if (subagents[0]) { + return subagents[0]; + } + await sleep(300); + } + throw new Error( + `Timed out waiting for a background helper to be registered for agent ${agentId}`, + ); +} + +// Claude starts the wake-up turn a moment after the helper's completion lands (0.1 to 5 s in +// recorded traces), so poll for it instead of checking the instant the helper reads completed. +// The daemon announces an autonomous turn to subscribers as a pushed "running" agent_update, +// not as an agent_stream turn_started (that stream reaches the client that started a run). +async function waitForWakeTurn( + collector: ReturnType<typeof createMessageCollector>, + agentId: string, + fromIndex: number, + timeoutMs = 30_000, +): Promise<boolean> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const window = collector.messages.slice(fromIndex); + const woke = + getAgentStatuses(window, agentId).includes("running") || + window.some( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "turn_started", + ); + if (woke) return true; + await sleep(500); + } + return false; +} + +// The wake-up turn runs briefly after it opens, so the final state is read once it settles. +async function waitForAgentStatus( + client: DaemonClient, + agentId: string, + status: string, + timeoutMs = 60_000, +): Promise<string | undefined> { + const deadline = Date.now() + timeoutMs; + let last: string | undefined; + while (Date.now() < deadline) { + last = (await client.fetchAgent({ agentId }))?.agent.status; + if (last === status) return last; + await sleep(500); + } + return last; +} + +// Polls agent.provider_subagents.list until the single backgrounded helper leaves "running". +// Scenarios in this file always launch exactly one background helper, so subagents[0] is it. +async function waitForProviderSubagentTerminal( + client: DaemonClient, + agentId: string, + timeoutMs = 90_000, +): Promise<ProviderSubagentPoll> { + const deadline = Date.now() + timeoutMs; + const history: string[] = []; + let lastSeen: ProviderSubagentDescriptor[] = []; + while (Date.now() < deadline) { + const { subagents } = await client.listProviderSubagents(agentId); + lastSeen = subagents; + const subagent = subagents[0]; + if (subagent) { + history.push(subagent.status); + if (subagent.status !== "running") { + return { subagent, history }; + } + } + await sleep(1_000); + } + throw new Error( + `Timed out waiting for the background helper to leave "running". history=${JSON.stringify(history)} lastSeen=${JSON.stringify(lastSeen)}`, + ); +} + +// Pulls the main agent's own assistant text via fetchAgentTimeline rather than the push +// collector. Assistant text delivery is paced (docs/agent-stream-performance.md), so checking +// the collector immediately after waitForFinish resolves can still miss the final text; the +// timeline fetch is authoritative and matches what the pre-existing tests in this file do. +async function fetchAssistantTexts( + client: DaemonClient, + agentId: string, + limit = 100, +): Promise<string[]> { + const timeline = await client.fetchAgentTimeline(agentId, { limit }); + return timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => (entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text); +} + +async function fetchSubagentAssistantTexts( + client: DaemonClient, + agentId: string, + subagentId: string, +): Promise<string[]> { + const timeline = await client.fetchProviderSubagentTimeline(agentId, subagentId, { limit: 50 }); + return timeline.rows + .filter((row) => row.item.type === "assistant_message") + .map((row) => (row.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text); +} + describe("daemon E2E (real claude) - send message during tool call", () => { let canRun = false; interface SteeringResources { @@ -504,6 +664,141 @@ describe("daemon E2E (real claude) - send message during tool call", () => { } }, 210_000); + test("a background subagent keeps running after a message is sent while it works", async () => { + // The scenario the rest of this file explicitly rules out: every other test here tells Claude + // "Do not use a background task", so steering during a backgrounded subagent was never + // covered. A subagent streams on the parent's channel, so a parent that mistakes the child's + // frames for its own work reports "running" after it has already finished, and the message + // sent into that window is refused, or delivered by cancelling the turn and taking the + // subagent with it. Both outcomes are user-visible, and both are what this asserts against. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Claude background-subagent test client was not created"); + await within("connect background-subagent test client", 15_000, client.connect()); + await within( + "subscribe background-subagent test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create background-subagent test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-background-survives-send", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + await within( + "launch one background subagent and end the turn", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor".`, + `prompt: "Use the Bash tool with run_in_background: true to run exactly: sleep 40.`, + `Wait for its completion notification, then reply exactly: CHILD_DONE."`, + "Immediately after launching it, reply with the single word LAUNCHED and end your turn.", + "Do not wait for it. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // The main session has ended its turn. It must read as idle even though the subagent it + // launched is still streaming on the shared channel. + const afterLaunch = await within( + "wait for the main session to go idle after launching the subagent", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect( + afterLaunch.status, + "a main session that finished its turn must not report busy because a subagent is running", + ).not.toBe("running"); + + // And a message sent in that window must be accepted rather than refused. + const followUpId = generateClientMessageId(); + await within( + "send a message to the idle main session while the subagent runs", + 30_000, + client.sendAgentMessage(agent.id, "Are you there? Reply exactly: MAIN_ALIVE.", { + messageId: followUpId, + }), + ); + await within( + "wait for the main session to answer", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + + const timeline = await within( + "fetch background-subagent timeline", + 15_000, + client.fetchAgentTimeline(agent.id, { limit: 200 }), + ); + const assistantText = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map( + (entry) => (entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>).text, + ) + .join("\\n"); + expect( + assistantText, + "the main session must be reachable while its background subagent still runs", + ).toContain("MAIN_ALIVE"); + + // The subagent must have survived the send rather than being cancelled with the turn. Its + // status is read from agent.provider_subagents.list (see the note above + // waitForProviderSubagentToExist): agent_stream carries no provider_subagent events. + const { subagents } = await within( + "list provider subagents after the send", + 15_000, + client.listProviderSubagents(agent.id), + ); + expect(subagents.length, "the background subagent must be tracked").toBeGreaterThan(0); + expect( + subagents.map((subagent) => subagent.status), + "a backgrounded subagent must not be canceled by a message sent to the main session", + ).not.toContain("canceled"); + const { subagent, history } = await within( + "wait for the background subagent to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect(history, "no status poll may read canceled").not.toContain("canceled"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Claude background-subagent E2E cleanup failures").toEqual([]); + } + }, 300_000); + test("Stop cancels a queued Claude steer before it can resume the interrupted turn", async () => { const logger = pino({ level: "silent" }); const cwd = tmpCwd(); @@ -678,4 +973,430 @@ describe("daemon E2E (real claude) - send message during tool call", () => { rmSync(cwd, { recursive: true, force: true }); } }, 300_000); + + test("a helper's own nested background job does not phantom-run the parent or kill the helper", async () => { + // The exact reported bug: main backgrounds a helper, the helper itself starts its OWN + // background job (a grandchild from main's perspective) and then keeps working in the + // foreground. The grandchild's completion is bookkeeping that belongs to the helper, not to + // main, and must not make main phantom-report "running" or get treated as busy. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("nested background-job test client was not created"); + await within("connect nested background-job test client", 15_000, client.connect()); + await within( + "subscribe nested background-job test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create nested background-job test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-nested-background-job", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch a helper with its own nested background job and end the turn", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "nested bg survivor".`, + `prompt: "Use the Bash tool with run_in_background: true to run exactly:`, + `python3 -c 'import time; time.sleep(5)'.`, + `Immediately after starting it, without waiting for it, use the Bash tool again`, + `in the foreground to run exactly: python3 -c 'import time; time.sleep(50)'.`, + `After that foreground command finishes, reply exactly: CHILD_DONE."`, + "Immediately after launching it, reply with the single word LAUNCHED and end your turn.", + "Do not wait for it. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + const afterLaunch = await within( + "wait for the main session to go idle after launching the nested-background helper", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect(afterLaunch.status, "the main session must be idle once it replied LAUNCHED").toBe( + "idle", + ); + + // The helper's own nested background job (5s) finishes while the helper is still busy + // in its unrelated foreground sleep (50s). Sample for 30s, well past the nested job's + // completion even when the helper is slow to start it, + // both by polling and by scanning every pushed agent_update in the window, so a brief + // phantom flip cannot slip between polls. + const sampleWindowStart = resources.collector.messages.length; + const polledStatuses = await sampleAgentStatuses(client, agent.id, 11, 3_000); + const pushedStatuses = getAgentStatuses( + resources.collector.messages.slice(sampleWindowStart), + agent.id, + ); + const observedRunning = [...polledStatuses, ...pushedStatuses].filter( + (status) => status === "running", + ); + expect( + observedRunning, + `the parent must stay idle across the nested job's completion; polled=${JSON.stringify(polledStatuses)} pushed=${JSON.stringify(pushedStatuses)}`, + ).toEqual([]); + + // A message sent in that window must still be answered normally. + await within( + "send PONG request while the nested job settles", + 30_000, + client.sendMessage(agent.id, "Reply with exactly PONG.", { + messageId: generateClientMessageId(), + }), + ); + await within("wait for the PONG reply", 60_000, client.waitForFinish(agent.id, 50_000)); + // Pulled via fetchAgentTimeline, not the push collector: assistant text delivery is paced + // (docs/agent-stream-performance.md) and can trail waitForFinish's own completion signal, + // so the collector can still be missing the final text the instant waitForFinish resolves. + const pongTexts = await within( + "fetch timeline for the PONG reply", + 15_000, + fetchAssistantTexts(client, agent.id, 20), + ); + expect( + pongTexts.join("\n"), + "the agent must answer PONG while the helper is still running", + ).toContain("PONG"); + + const messagesBeforeWake = resources.collector.messages.length; + + // The helper itself must survive: never recorded canceled, and it eventually reports + // CHILD_DONE from its own foreground sleep finishing. + const { subagent, history } = await within( + "wait for the nested-background helper to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + + // The helper's completion should wake the main session with a fresh turn (the + // session-state-driven autonomous turn), separate from the PONG exchange above. + const wokeMainAgent = await waitForWakeTurn( + resources.collector, + agent.id, + messagesBeforeWake, + ); + if (!wokeMainAgent) { + const wakeWindowMessages = resources.collector.messages.slice(messagesBeforeWake); + const wakeWindowEventTypes = wakeWindowMessages + .filter( + (message) => message.type === "agent_stream" && message.payload.agentId === agent.id, + ) + .map((message) => + message.type === "agent_stream" ? message.payload.event.type : "unknown", + ); + const timeline = await client + .fetchAgentTimeline(agent.id, { limit: 100 }) + .catch(() => null); + throw new Error( + `the helper's completion must wake the main session with a new turn. ` + + `wakeWindowEventTypes=${JSON.stringify(wakeWindowEventTypes)} ` + + `recentTimeline=${JSON.stringify(timeline ? summarizeTimelineItems(timeline) : null)}`, + ); + } + + expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe( + "idle", + ); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "nested background-job E2E cleanup failures").toEqual([]); + } + }, 300_000); + + test("the app's default steer survives a backgrounded helper while the main session works", async () => { + // appSettings.sendBehavior defaults to "steer" (packages/app/src/hooks/use-settings/storage.ts), + // so this is what the app actually sends when a user replies while an agent looks busy. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("default-steer background-helper test client was not created"); + await within("connect default-steer test client", 15_000, client.connect()); + await within( + "subscribe default-steer test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create default-steer test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-default-steer-background-helper", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch the background helper and start the main session's own foreground sleep", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor b".`, + `prompt: "Use the Bash tool in the foreground to run exactly:`, + `python3 -c 'import time; time.sleep(42)'. After it finishes, reply exactly: CHILD_DONE."`, + "Do not wait for that background helper to finish before continuing.", + "After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,", + "to run exactly: python3 -c 'import time; time.sleep(16)'.", + // Matching the proven pattern from the first test in this file: tell Claude to wait + // for a later message rather than relying on the sleep call itself to block, since a + // Bash call can resolve through Claude Code's own async task_notification path once a + // background subagent is already active instead of blocking the turn. + "Do not reply or finish your turn until a later user message arrives, no matter how", + "quickly that Bash command finishes. When that later message arrives, address it and", + "end your turn. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // Confirm the daemon considers main's own turn active before steering it. This does not + // wait for a specific "sleep 16" shell call to show as running: Claude Code 2.1.280 can + // report a slower Bash call through its own async task_notification tool instead of a + // classic blocking call once a background subagent is already active, so pinning to a + // literal running shell tool_call is not reliable here. Busy at the daemon level is what + // "while main is busy with its own sleep" actually needs. + await within( + "confirm main's own turn is active before steering", + 30_000, + client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 25_000), + ); + // The running status precedes the Agent tool call, so a steer sent on it alone can reach + // Claude before the helper exists and prove nothing about a helper surviving it. + await within( + "confirm the background helper is registered before steering", + 60_000, + waitForProviderSubagentToExist(client, agent.id, 55_000), + ); + + await within( + "send the app's default steer while main is busy with its own sleep", + 30_000, + client.sendMessage(agent.id, "Reply with exactly PONG.", { + messageId: generateClientMessageId(), + activeTurnBehavior: "steer", + }), + ); + + const finish = await within( + "wait for the main session to finish after the steer", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect(finish.status, "the main session must end idle after the steer").toBe("idle"); + // Pulled via fetchAgentTimeline, not the push collector: see fetchAssistantTexts. + const postSteerTexts = await within( + "fetch timeline for the post-steer PONG reply", + 15_000, + fetchAssistantTexts(client, agent.id, 20), + ); + expect(postSteerTexts.join("\n"), "PONG must be answered after the steer").toContain("PONG"); + + const { subagent, history } = await within( + "wait for the background helper to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "default-steer background-helper E2E cleanup failures").toEqual([]); + } + }, 300_000); + + test("Stop cancels the main session's own turn but spares its background helper", async () => { + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Stop-spares-helper test client was not created"); + await within("connect Stop-spares-helper test client", 15_000, client.connect()); + await within( + "subscribe Stop-spares-helper test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create Stop-spares-helper test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-stop-spares-helper", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch the background helper and start the main session's own foreground sleep", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor c".`, + `prompt: "Use the Bash tool in the foreground to run exactly:`, + `python3 -c 'import time; time.sleep(43)'. After it finishes, reply exactly: CHILD_DONE."`, + "Do not wait for that background helper to finish before continuing.", + "After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,", + "to run exactly: python3 -c 'import time; time.sleep(23)'.", + "Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // Confirm the helper has actually been launched (registered) before Stop, not just that + // main's turn shows "running": that status can flip true on generic turn-start bookkeeping + // before Claude has issued the Agent tool call, and canceling that early cancels a turn + // with no helper to spare yet. + await within( + "confirm the background helper is registered before Stop", + 60_000, + waitForProviderSubagentToExist(client, agent.id, 55_000), + ); + + const messagesBeforeStop = resources.collector.messages.length; + await within("Stop the main session's own turn", 30_000, client.cancelAgent(agent.id)); + const afterStop = await within( + "wait for the agent to go idle after Stop", + 60_000, + client.waitForFinish(agent.id, 50_000), + ); + expect(afterStop.status, "Stop must leave the agent idle").toBe("idle"); + + // The helper must survive Stop: it keeps running and later reports CHILD_DONE, which + // wakes the main session with a fresh autonomous turn. + const { subagent, history } = await within( + "wait for the background helper to finish after Stop", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "Stop must not cancel or fail the background helper").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + + const wokeMainAgent = await waitForWakeTurn( + resources.collector, + agent.id, + messagesBeforeStop, + ); + expect( + wokeMainAgent, + "the helper's completion after Stop must wake the main session with a new turn", + ).toBe(true); + + expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe( + "idle", + ); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Stop-spares-helper E2E cleanup failures").toEqual([]); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index 19ccdf45e8..80c9409cef 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -244,3 +244,28 @@ async function observeScriptLifecycles( await subscription.ready; return lifecycles; } + +test.runIf(process.platform !== "win32")( + "a missing command fails creation without leaving a terminal listed", + async () => { + const workspaceId = await createWorkspace("Missing command"); + await expect( + sdk.terminals.create({ + workspaceId, + name: "Missing command", + command: "paseo-test-absent-terminal-command", + }), + ).rejects.toThrow("paseo-test-absent-terminal-command: command not found"); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); + const control = await sdk.terminals.create({ + workspaceId, + command: process.execPath, + args: ["-e", "process.stdin.resume()"], + }); + expect((await sdk.terminals.list({ workspaceId })).entries.map((entry) => entry.id)).toEqual([ + control.id, + ]); + await control.kill(); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); + }, +); diff --git a/packages/server/src/server/daemon-instance.test.ts b/packages/server/src/server/daemon-instance.test.ts index 9d7ea66c5a..ee428b2e58 100644 --- a/packages/server/src/server/daemon-instance.test.ts +++ b/packages/server/src/server/daemon-instance.test.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir, uptime } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js"; import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js"; @@ -94,3 +94,42 @@ describe("daemon instance identity across a reboot", () => { await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/); }); }); + +// Linux keeps a boot's id while a VM is paused for a host sleep, though the VM's wall clock +// runs ahead of its uptime once it resumes. A sleep longer than the time between boot and +// the supervisor's start puts the wall-clock boot instant after the lock's startedAt. +describe.runIf(process.platform === "linux")("daemon instance identity on Linux", () => { + let paseoHome: string; + + beforeEach(async () => { + paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-linux-")); + }); + + afterEach(async () => { + vi.useRealTimers(); + await rm(paseoHome, { recursive: true, force: true }); + }); + + test("a supervisor still holds the lock after its paused VM resumes", async () => { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid }); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + uptime() * 1000 + 12 * 60 * 60_000); + + expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid }); + expect(await isLocked(paseoHome)).toMatchObject({ locked: true }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + }); + + test("a lock written during another boot has no running owner", async () => { + await writeLock(paseoHome, { + ...lockFor(process.pid, new Date()), + bootId: "00000000-0000-0000-0000-000000000000", + }); + + expect(await readDaemonInstance(paseoHome)).toBeNull(); + expect(await isLocked(paseoHome)).toMatchObject({ locked: false }); + }); +}); diff --git a/packages/server/src/server/directory-sync/index.ts b/packages/server/src/server/directory-sync/index.ts index 1b08360cb7..e16e20302f 100644 --- a/packages/server/src/server/directory-sync/index.ts +++ b/packages/server/src/server/directory-sync/index.ts @@ -69,27 +69,29 @@ export class DirectorySyncService { synchronizeWorkspaces( snapshot: Iterable<WorkspaceDescriptorPayload>, cursor: DirectorySyncCursor, + includeBackground = false, ): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> { this.workspaces.replaceAll(snapshot); - const read = this.read(this.workspaces, cursor); + const read = this.read(this.workspaces, cursor, includeBackground); return { entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), emptyProjects: [], pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - sync: this.metadata(read), + sync: this.metadata(read, includeBackground), }; } synchronizeAgents( snapshot: Iterable<AgentDirectoryEntry>, cursor: DirectorySyncCursor, + includeBackground = false, ): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> { this.agents.replaceAll(snapshot); - const read = this.read(this.agents, cursor); + const read = this.read(this.agents, cursor, includeBackground); return { entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - sync: this.metadata(read), + sync: this.metadata(read, includeBackground), }; } @@ -111,13 +113,19 @@ export class DirectorySyncService { value: WorkspaceDescriptorPayload | null, id: string, includeSequence: boolean, + includeBackground = false, ): T & Partial<DirectoryVersion> { if (value) { this.workspaces.replace(value); } else { this.workspaces.remove(id); } - return this.withVersion(payload, includeSequence, this.version(this.workspaces, id)); + return this.withVersion( + payload, + includeSequence, + this.version(this.workspaces, id), + includeBackground, + ); } sequenceAgentUpdate<T extends object>( @@ -125,18 +133,24 @@ export class DirectorySyncService { value: AgentDirectoryEntry | null, id: string, includeSequence: boolean, + includeBackground = false, ): T & Partial<DirectoryVersion> { if (value) { this.agents.replace(value); } else { this.agents.remove(id); } - return this.withVersion(payload, includeSequence, this.version(this.agents, id)); + return this.withVersion( + payload, + includeSequence, + this.version(this.agents, id), + includeBackground, + ); } - private metadata(read: CollectionRead<unknown>) { + private metadata(read: CollectionRead<unknown>, includeBackground = false) { return { - generation: this.generation, + generation: this.generationFor(includeBackground), headSeq: read.headSeq, mode: read.mode, ...(read.reason ? { reason: read.reason } : {}), @@ -148,15 +162,19 @@ export class DirectorySyncService { payload: T, includeSequence: boolean, version: DirectoryVersion | null, + includeBackground = false, ): T & Partial<DirectoryVersion> { - return version && includeSequence ? { ...payload, ...version } : payload; + return version && includeSequence + ? { ...payload, ...version, generation: this.generationFor(includeBackground) } + : payload; } private read<T>( collection: VersionedCollection<T>, cursor: DirectorySyncCursor, + includeBackground = false, ): CollectionRead<T> { - if (cursor.generation !== this.generation) { + if (cursor.generation !== this.generationFor(includeBackground)) { return collection.readSnapshot( cursor.generation === undefined ? "no_cursor" : "generation_changed", ); @@ -167,6 +185,10 @@ export class DirectorySyncService { return collection.readAfter(cursor.afterSeq); } + private generationFor(includeBackground: boolean): string { + return includeBackground ? `${this.generation}:background` : this.generation; + } + private version<T>(collection: VersionedCollection<T>, id: string): DirectoryVersion | null { const seq = collection.sequenceFor(id); return seq === null ? null : { generation: this.generation, seq }; diff --git a/packages/server/src/server/file-download/content-disposition.test.ts b/packages/server/src/server/file-download/content-disposition.test.ts new file mode 100644 index 0000000000..46748c21ef --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { formatAttachmentContentDisposition } from "./content-disposition.js"; + +describe("formatAttachmentContentDisposition", () => { + test.each([ + { fileName: "report.txt", disposition: 'attachment; filename="report.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "Résumé Ångström.pdf", + disposition: + "attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + { + fileName: 'say "hi" \\ bye.txt', + disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"', + }, + { + fileName: "100%25 done.txt", + disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt", + }, + { + fileName: "l'été (1)*.txt", + disposition: + "attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt", + }, + ])("$fileName", ({ fileName, disposition }) => { + expect(formatAttachmentContentDisposition(fileName)).toBe(disposition); + }); +}); diff --git a/packages/server/src/server/file-download/content-disposition.ts b/packages/server/src/server/file-download/content-disposition.ts new file mode 100644 index 0000000000..1ad7997b7c --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.ts @@ -0,0 +1,32 @@ +const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g; +const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/; +const QUOTED_STRING_SPECIALS = /[\\"]/g; +const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g; + +/** + * Content-Disposition for a file download (RFC 6266). + * + * Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because + * browsers do not read raw Latin-1 bytes in `filename` and fall back to a name + * taken from the URL. A name with a percent escape gets it too, so browsers do + * not decode the escape in `filename`. + */ +export function formatAttachmentContentDisposition(fileName: string): string { + const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?"); + const disposition = `attachment; filename=${quote(asciiFileName)}`; + if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) { + return disposition; + } + return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`; +} + +function quote(value: string): string { + return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`; +} + +function encodeExtendedValue(value: string): string { + return encodeURIComponent(value).replace( + NON_ATTR_CHARS_LEFT_BY_URI_ENCODING, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index ea22ac24b9..a82e1a8f80 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -12,8 +12,8 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { runGitCommand } from "../../utils/run-git-command.js"; +import { describe, expect, it, vi } from "vitest"; +import { runGitCommand, snapshotGitCommandRuntimeMetrics } from "../../utils/run-git-command.js"; import { createExplorerEntry, deleteExplorerEntry, @@ -517,6 +517,129 @@ describe("file explorer service", () => { } }); + it("renames an untracked bracketed filename without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-entry-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "i.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i.tsx"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "untracked\n", "utf8"); + + snapshotGitCommandRuntimeMetrics(); + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(snapshotGitCommandRuntimeMetrics().operationsTop).toEqual([ + ["ls-files", 1], + ["rev-parse", 1], + ]); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("renames an untracked bracketed folder without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-folder-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.join(root, "i")); + await writeFile(path.join(root, "i", "route.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i"], { cwd: root }); + await mkdir(path.join(root, "[id]")); + await writeFile(path.join(root, "[id]", "route.tsx"), "untracked\n", "utf8"); + + const result = await renameExplorerEntry({ root, relativePath: "[id]", name: "route" }); + + expect(result).toEqual({ status: "ok", path: "route" }); + expect(await readFile(path.join(root, "route", "route.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id]"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i", "route.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i/route.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("uses Git to rename a tracked bracketed filename and preserves its tracked sibling", async () => { + const root = await createTempDir("paseo-tracked-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "--", "[id].tsx", "i.tsx"], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + "i.tsx\0renamed.tsx\0", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it.each([ + { + sourcePath: "[id].tsx", + sourceFile: "[id].tsx", + targetName: "renamed.tsx", + targetFile: "renamed.tsx", + }, + { + sourcePath: "[id]", + sourceFile: "[id]/route.tsx", + targetName: "route", + targetFile: "route/route.tsx", + }, + ])( + "keeps tracked renames in the index with inherited literal pathspecs ($sourcePath)", + async ({ sourcePath, sourceFile, targetName, targetFile }) => { + const root = await createTempDir("paseo-inherited-literal-rename-"); + try { + vi.stubEnv("GIT_LITERAL_PATHSPECS", "1"); + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.dirname(path.join(root, sourceFile)), { recursive: true }); + await writeFile(path.join(root, sourceFile), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "."], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: sourcePath, + name: targetName, + }); + + expect(result).toEqual({ status: "ok", path: targetName }); + expect(await readFile(path.join(root, targetFile), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, sourcePath))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + `i.tsx\0${targetFile}\0`, + ); + expect(process.env.GIT_LITERAL_PATHSPECS).toBe("1"); + } finally { + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + } + }, + ); + it("supports case-only renames for tracked and untracked files", async () => { const root = await createTempDir("paseo-entry-case-rename-"); try { diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 5aa4efe974..d99c97616b 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -489,7 +489,7 @@ export async function writeExplorerFile({ const temporaryPath = path.join( path.dirname(filePath.resolvedPath), - `.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`, + `.paseo-${randomUUID()}.tmp`, ); let temporaryHandle: FileHandle | null = null; try { @@ -669,6 +669,7 @@ export async function duplicateExplorerEntry({ errorOnExist: true, force: false, preserveTimestamps: true, + verbatimSymlinks: true, }); return { status: "ok", path: normalizeRelativePath({ root, targetPath }) }; } catch (error) { @@ -724,7 +725,11 @@ export async function renameExplorerEntry({ }); const tracked = repository.exitCode === 0 - ? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot }) + ? await runGitCommand(["ls-files", "-z", "--", `:(literal)${sourcePath}`], { + cwd: realRoot, + // Inherited literal mode disables the :(literal) pathspec magic. + envOverlay: { GIT_LITERAL_PATHSPECS: undefined }, + }) : null; if (tracked?.stdout) { await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot }); @@ -820,8 +825,9 @@ async function resolveScopedPath({ function assertWithinWorkspace(root: string, candidate: string): void { const relative = path.relative(root, candidate); - if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return; - throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); + const isOutsideWorkspace = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (isOutsideWorkspace) throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); } async function openFileForRead(filePath: string): Promise<FileHandle> { diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 711b826348..95fe416c54 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -236,7 +236,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and const deletions = expectFileChanges(removedPaths, "delete"); const movedFiles = expectFileChanges(movedPaths); const recovery = expectFileChanges([sentinel]); - const subscription = await subscribeToFileChanges(root, (error, events) => { + const observer = createObserver(); + const subscription = await observer.subscribe(root, (error, events) => { expect(error).toBeNull(); for (const event of events) { population.record(event); @@ -254,9 +255,18 @@ test("observes a thousand concurrent writes and remains healthy after delete and await Promise.all(paths.map((path, index) => writeFile(path, `${index}`))); await writes.complete; - // A native watcher may recover coalesced paths through its safety audit. - // Completion comes from the delivered deletions, not an idle diagnostic or - // a deadline shorter than that recovery cycle. + // The first notice for each write can arrive before its remaining + // classifications. Drain those deliveries before starting a separate deletion + // phase, so a late create cannot coalesce with the expected delete as update. + await expect + .poll(() => observer.getDiagnostics(), { timeout: 10_000 }) + .toMatchObject({ + pendingEventCount: 0, + pendingClassificationCount: 0, + reconciliationInFlightCount: 0, + }); + + // Completion still requires the delivered deletion notices. await Promise.all(removedPaths.map((path) => rm(path))); await deletions.complete; diff --git a/packages/server/src/server/git-filename-diffs.e2e.test.ts b/packages/server/src/server/git-filename-diffs.e2e.test.ts new file mode 100644 index 0000000000..343e1c81c8 --- /dev/null +++ b/packages/server/src/server/git-filename-diffs.e2e.test.ts @@ -0,0 +1,62 @@ +import { test, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js"; + +test.skipIf(process.platform === "win32")( + "checkout and commit diffs preserve filenames that Git quotes", + async () => { + const cwd = mkdtempSync(join(tmpdir(), "git-filename-diffs-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const names = [ + 'report "draft".txt', + "back\\slash.txt", + "tab\tname.txt", + "normal.txt", + "with spaces.txt", + ]; + const editLines = expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.quotepath", "false"); + for (const name of names) writeFileSync(join(cwd, name), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "initial"); + for (const name of names) writeFileSync(join(cwd, name), "after\n"); + + await client.connect(); + const checkout = await client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + expect(checkout.error).toBeNull(); + expect(checkout.files.map((file) => file.path).sort()).toEqual([...names].sort()); + for (const file of checkout.files) { + expect(file).toMatchObject({ status: "ok", additions: 1, deletions: 1 }); + expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines); + } + + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const sha = git("rev-parse", "HEAD"); + for (const name of names) { + const commit = await client.getCommitFileDiff(cwd, sha, name); + expect(commit.file).toMatchObject({ path: name, additions: 1, deletions: 1 }); + expect(commit.file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines); + const file = await client.readFile(cwd, name); + expect(file.kind).toBe("text"); + expect(new TextDecoder().decode(file.bytes)).toBe("after\n"); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/hub/daemon-executions.ts b/packages/server/src/server/hub/daemon-executions.ts index a619be18a8..ec629c3fc9 100644 --- a/packages/server/src/server/hub/daemon-executions.ts +++ b/packages/server/src/server/hub/daemon-executions.ts @@ -205,7 +205,6 @@ export class DaemonExecutions implements HubExecutionAgents { } : {}), worktree: toCreateAgentWorktree(input.worktree), - background: true, notifyOnFinish: false, owner, onWorktreeCreated: (worktree) => { diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index 51596b2384..d994a7280e 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -773,8 +773,12 @@ describe("Hub relationship", () => { const second = await relationship.ownedCreateResult("second-create"); expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId); - expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } }); - expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } }); + expect(first).toMatchObject({ + payload: { success: true, executionId: "first-execution", error: null }, + }); + expect(second).toMatchObject({ + payload: { success: true, executionId: "second-execution", error: null }, + }); expect(await relationship.durableOwnedAgentIds()).toHaveLength(2); }); diff --git a/packages/server/src/server/local-service-connection.ts b/packages/server/src/server/local-service-connection.ts new file mode 100644 index 0000000000..a96a0a7094 --- /dev/null +++ b/packages/server/src/server/local-service-connection.ts @@ -0,0 +1,27 @@ +import http from "node:http"; +import net from "node:net"; + +// A host can support IPv6 loopback without listing ::1 for localhost in DNS. +const lookupLoopback: net.LookupFunction = (_hostname, _options, callback) => { + callback(null, [ + { address: "127.0.0.1", family: 4 }, + { address: "::1", family: 6 }, + ]); +}; + +export function connectToLocalService(port: number): net.Socket { + return net.connect({ host: "localhost", port, autoSelectFamily: true, lookup: lookupLoopback }); +} + +export function requestLocalService( + options: Pick<http.RequestOptions, "port" | "path" | "method" | "headers">, + onResponse: (response: http.IncomingMessage) => void, +): http.ClientRequest { + const requestOptions = { + ...options, + hostname: "localhost", + autoSelectFamily: true, + lookup: lookupLoopback, + }; + return http.request(requestOptions, onResponse); +} diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index cbcef548e4..a5120aa5ff 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -791,13 +791,14 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename createDeps(), ); + // baseRefName must be the repo's default branch, not the checked-out branch + // itself — otherwise the diff panel compares the branch against itself and + // always shows no changes. expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, - baseRefName: "dev", + baseRefName: "main", }); - // A checkout-branch worktree has no distinct base, so the workspace records a - // null baseBranch even though worktree.json's baseRefName is the branch itself. - expect(created.workspace.baseBranch).toBe(null); + expect(created.workspace.baseBranch).toBe("main"); await expect( attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 247fe46c18..94e82d5e95 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -33,6 +33,8 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { workspaceId?: string; projectId?: string; title?: string; + background?: boolean; + callerWorkspaceId?: string; } export interface CreatePaseoWorktreeResult { @@ -101,6 +103,8 @@ async function createPaseoWorktreeWithPriority( baseBranch: createdWorktree.worktree.comparisonBaseRef, title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext), expectsInitialAgent: Boolean(input.firstAgentContext), + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, ...(createdWorktree.intent.kind === "checkout-change-request" && createdWorktree.intent.headRepository ? { diff --git a/packages/server/src/server/persisted-config-json-schema.test.ts b/packages/server/src/server/persisted-config-json-schema.test.ts new file mode 100644 index 0000000000..f943f859ec --- /dev/null +++ b/packages/server/src/server/persisted-config-json-schema.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import Ajv from "ajv"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { PersistedConfigSchema } from "./persisted-config.js"; + +const publishedSchema = JSON.parse( + readFileSync( + new URL("../../../website/public/schemas/paseo.config.v1.json", import.meta.url), + "utf8", + ), +); + +test("editors accept the daemon's browser, profile, plugin and metadata configuration", () => { + const config = { + $schema: "https://paseo.sh/schemas/paseo.config.v1.json", + version: 1, + daemon: { + browserTools: { enabled: true }, + terminalProfiles: [], + agentProfiles: [], + }, + pluginsEnabled: true, + plugins: {}, + agents: { + metadataGeneration: { providers: [{ provider: "claude", model: "claude-opus-5" }] }, + }, + }; + expect(PersistedConfigSchema.parse(config)).toEqual(config); + const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema); + const valid = validate(config); + expect(validate.errors).toBeNull(); + expect(valid).toBe(true); +}); + +test("the published schema stays current with the daemon's configuration schema", () => { + const generated = z.toJSONSchema(PersistedConfigSchema, { + target: "draft-07", + unrepresentable: "any", + io: "input", + }); + generated.title = "PaseoConfigV1"; + expect( + publishedSchema, + "Run npm run generate:config-schema --workspace=@getpaseo/server", + ).toEqual(generated); +}); + +test("editors retain the existing proxy configuration help", () => { + const daemon = publishedSchema.properties.daemon.properties; + expect(daemon.trustedProxies.description).toBe( + 'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.', + ); + expect(daemon.serviceProxy.properties.enabled.description).toBe( + "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + ); + expect(daemon.serviceProxy.properties.listen.description).toBe( + "Optional service-only listener address. Presence enables the standalone service proxy listener.", + ); + expect(daemon.serviceProxy.properties.publicBaseUrl.description).toBe( + "Optional public base URL for service host aliases.", + ); +}); + +test.each([ + { daemon: { browserTools: { enabled: "yes" } } }, + { daemon: { terminalProfiles: "default" } }, + { daemon: { agentProfiles: "default" } }, + { pluginsEnabled: "yes" }, + { plugins: [] }, + { agents: { metadataGeneration: { providers: [{ provider: "" }] } } }, + { unknownSetting: true }, +])("editors still reject invalid configuration: %j", (config) => { + expect(PersistedConfigSchema.safeParse(config).success).toBe(false); + const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema); + expect(validate(config)).toBe(false); +}); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index f443f84865..6b23a6f365 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -1,4 +1,14 @@ -import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; @@ -664,6 +674,19 @@ describe("PersistedConfigSchema voice mode config", () => { expect(parsed.features?.dictation?.stt?.language).toBe("fr"); expect(parsed.features?.voiceMode?.stt?.language).toBe("de"); }); + + test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => { + const parsed = PersistedConfigSchema.parse({ + features: { + voiceMode: { + tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "aiden" }, + }, + }, + }); + + expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0"); + expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden"); + }); }); describe("loadPersistedConfig", () => { @@ -843,3 +866,35 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); }); + +describe.skipIf(process.platform === "win32")( + "config.json symlinked from another directory", + () => { + test("saving writes through the symlink and leaves it in place", () => { + const parent = createTempHome(); + const home = path.join(parent, "home"); + const dotfiles = path.join(parent, "dotfiles"); + const linkedConfigPath = path.join(dotfiles, "config.json"); + const configPath = path.join(home, "config.json"); + try { + mkdirSync(home); + mkdirSync(dotfiles, { mode: 0o755 }); + chmodSync(dotfiles, 0o755); + writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); + symlinkSync(linkedConfigPath, configPath); + + savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } }); + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + expect(modeOf(dotfiles)).toBe(0o755); + expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + }, +); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f924a88ebc..11d94495b1 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -144,7 +144,8 @@ const FeatureVoiceModeSchema = z .object({ provider: SpeechProviderIdSchema.optional(), model: z.string().min(1).optional(), - voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(), + // Free-form: self-hosted OpenAI-compatible servers expose their own voice names. + voice: z.string().trim().min(1).optional(), speakerId: z.number().int().optional(), speed: z.number().optional(), }) @@ -237,7 +238,12 @@ export const PersistedConfigSchema = z listen: z.string().optional(), hostnames: z.union([z.literal(true), z.array(z.string())]).optional(), allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(), - trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(), + trustedProxies: z + .union([z.literal(true), z.array(z.string())]) + .optional() + .describe( + 'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.', + ), mcp: z .object({ enabled: z.boolean().optional(), @@ -284,9 +290,22 @@ export const PersistedConfigSchema = z // COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02. // Parsed only to suppress optional public/listen layers for old configs; // localhost service proxying remains always enabled. - enabled: z.boolean().optional(), - listen: z.string().optional(), - publicBaseUrl: z.url().optional(), + enabled: z + .boolean() + .optional() + .describe( + "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + ), + listen: z + .string() + .optional() + .describe( + "Optional service-only listener address. Presence enables the standalone service proxy listener.", + ), + publicBaseUrl: z + .url() + .optional() + .describe("Optional public base URL for service host aliases."), }) .strict() .optional(), @@ -308,7 +327,6 @@ export const PersistedConfigSchema = z providers: ProvidersSchema.optional(), pluginRegistries: PluginRegistriesSchema.optional(), - pluginRegistryEnabled: z.boolean().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index db95d4786d..314b44ea47 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { open, readFile, stat, unlink, utimes } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { ensurePrivateDirectory } from "./private-files.js"; @@ -15,6 +16,7 @@ export const pidLockInfoSchema = z.object({ serverId: z.string().nullable().optional(), desktopManaged: z.boolean().optional(), heartbeat: z.literal(true).optional(), + bootId: z.string().optional(), }); export interface PidLockInfo extends z.infer<typeof pidLockInfoSchema> {} @@ -62,16 +64,38 @@ function precedesThisBoot(startedAt: string): boolean { return stamped < Date.now() - uptime() * 1000 - BOOT_INSTANT_TOLERANCE_MS; } +let cachedBootId: string | null | undefined; + +// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while +// its host sleeps keeps its uptime, so after it resumes the instant lands after locks its +// running supervisor wrote. +function currentBootId(): string | null { + if (cachedBootId === undefined) { + try { + cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null; + } catch { + cachedBootId = null; + } + } + return cachedBootId; +} + +function writtenDuringThisBoot(lock: PidLockInfo): boolean { + const thisBoot = currentBootId(); + if (lock.bootId && thisBoot) return lock.bootId === thisBoot; + return !precedesThisBoot(lock.startedAt); +} + /** * Whether the process that wrote this lock is still running. * * A PID alone does not identify the supervisor: the operating system hands the number to * something else once the supervisor is gone, and a reboot reassigns it freely. A process - * cannot predate the boot it runs under, so a lock stamped before this boot is abandoned + * cannot predate the boot it runs under, so a lock written during another boot is abandoned * however alive its PID looks. */ export function isPidLockOwnerRunning(lock: PidLockInfo): boolean { - if (precedesThisBoot(lock.startedAt)) return false; + if (!writtenDuringThisBoot(lock)) return false; return isPidRunning(lock.pid); } @@ -214,6 +238,7 @@ export async function acquirePidLock( } // Create new lock with exclusive flag + const bootId = currentBootId(); const lockInfo: PidLockInfo = { pid: lockOwnerPid, startedAt: new Date().toISOString(), @@ -221,6 +246,7 @@ export async function acquirePidLock( uid: process.getuid?.() ?? 0, listen, heartbeat: true, + ...(bootId ? { bootId } : {}), ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index cf6ffe9eee..c14c027853 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -9,7 +9,6 @@ export const builtinPlugins = [ "copilot-usage-source", "cursor-usage-source", "grok-usage-source", - "hoplite-provider", "kimi-usage-source", "minimax-usage-source", "muse-provider", diff --git a/packages/server/src/server/plugins/bundle-evaluator.ts b/packages/server/src/server/plugins/bundle-evaluator.ts index 2941d4c4ff..e80db885ff 100644 --- a/packages/server/src/server/plugins/bundle-evaluator.ts +++ b/packages/server/src/server/plugins/bundle-evaluator.ts @@ -3,6 +3,7 @@ import * as pluginSharedRuntime from "@getpaseo/plugin"; import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; import * as pluginUsageRuntime from "@getpaseo/plugin/server/usage"; +import * as pluginServerRuntime from "@getpaseo/plugin/server"; import type { PluginServerContribution } from "@getpaseo/plugin/server"; import * as zod from "zod"; import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js"; @@ -14,7 +15,7 @@ function runtimeRequire(name: string): unknown { throw new Error(`${name} is available only in plugin client code`); } if (name === "@getpaseo/plugin") return pluginSharedRuntime; - if (name === "@getpaseo/plugin/server") return {}; + if (name === "@getpaseo/plugin/server") return pluginServerRuntime; if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime; if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime; if (name === "@getpaseo/plugin/server/usage") return pluginUsageRuntime; diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 544b609df5..71fe2b96e9 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -10,6 +10,7 @@ import { stat, writeFile, } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -418,7 +419,7 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); - it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => { + it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const running = await createPlugin("running", "export default () => () => {};"); @@ -426,7 +427,13 @@ describe("PluginService", () => { const malformed = await createPlugin("malformed", "export default () => () => {};"); await writeFile( path.join(running, "paseo-plugin.json"), - JSON.stringify({ id: "running", description: "Runs checks" }), + JSON.stringify({ + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }), ); await writeFile( path.join(disabled, "paseo-plugin.json"), @@ -442,16 +449,123 @@ describe("PluginService", () => { await service.start(); expect( - (await service.listPlugins()).map(({ id, description }) => ({ id, description })), + (await service.listPlugins()).map(({ id, description, name, icon, media }) => ({ + id, + description, + name, + icon, + media, + })), ).toEqual([ { id: "disabled", description: "Waits until enabled" }, { id: "malformed", description: undefined }, - { id: "running", description: "Runs checks" }, + { + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }, ]); await service.stopAllPlugins(); }); - it("prefers an existing directory and installs its selected plugin subdirectory", async () => { + it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`); + await mkdir(owner); + roots.push(home, owner); + const source = `${path.basename(owner)}/example`; + const repository = await createPlugin("registry-example", "export default () => () => {};\n"); + await mkdir(path.join(owner, "example")); + await cp(repository, path.join(owner, "example"), { recursive: true }); + await runGitCommand(["init", "-b", "main"], { cwd: repository }); + await runGitCommand(["add", "-A"], { cwd: repository }); + await runGitCommand( + [ + "-c", + "user.name=Paseo Tests", + "-c", + "user.email=paseo@example.test", + "commit", + "-m", + "fixture", + ], + { cwd: repository }, + ); + const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const requests: Array<string | undefined> = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + id: source, + name: "Example", + description: "Test", + categories: [], + author: { github: path.basename(owner) }, + repository: { url: "https://github.com/fixture/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit: stdout.trim(), + pluginPath: ".", + }, + media: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Test", + }), + ); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const url = `http://127.0.0.1:${address.port}`; + const store = createStore(home); + store.patch({ pluginsEnabled: false }); + const service = bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.4.0", { + managedSources: new ManagedPluginSources(home, { defaultUrl: url }), + }), + ); + try { + await service.start(); + await expect(service.installSource({ source })).resolves.toMatchObject({ + id: "registry-example", + status: "disabled", + installation: { + identity: { kind: "git", registry: { url, id: source } }, + currentRevision: stdout.trim(), + }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + await expect( + service.installSource({ source: `./${source}`, id: "local-example" }), + ).resolves.toMatchObject({ + installation: { identity: { kind: "directory", path: path.join(owner, "example") } }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + } finally { + await service.stopAllPlugins(); + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }, 30_000); + + it("names a missing explicit directory instead of trying a managed source", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = path.join(home, "missing"); + const service = createService(home); + await expect(service.installSource({ source: directory })).rejects.toThrow( + `Plugin directory does not exist: ${directory}`, + ); + }); + + it("installs an explicit directory and its selected plugin subdirectory", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-")); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 7f74c847df..6c4af29e62 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -21,6 +21,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js"; import { readPluginManifest } from "./manifest.js"; +import { expandTilde } from "../../utils/path.js"; import { runPluginBuild } from "./preparation.js"; import { PluginRuntime } from "./runtime.js"; import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js"; @@ -215,7 +216,12 @@ export class PluginService { }), }; const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null); - if (manifest?.description) item.description = manifest.description; + if (manifest) { + item.name = manifest.name; + item.description = manifest.description; + item.icon = manifest.icon; + item.media = manifest.media; + } item.installation = await this.managedSources ?.describe(id, source.path) .catch(() => undefined); @@ -286,16 +292,11 @@ export class PluginService { if (input.id && this.builtinPluginIds.has(input.id)) { throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`); } - const directDirectory = path.resolve(input.source); - const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source); - const directInfo = await stat(directDirectory).catch(() => null); - const reference = directInfo?.isDirectory() - ? { source: input.source, pluginPath: undefined } - : parsePluginSourceReference(input.source); - const directory = path.resolve(reference.source); - let info = directInfo; - if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null); - if (info?.isDirectory()) { + const reference = parsePluginSourceReference(input.source); + if (reference.kind === "directory") { + const directory = path.resolve(expandTilde(reference.source)); + const info = await stat(directory).catch(() => null); + if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`); if (input.ref) throw new Error("Plugin --ref is only valid for Git sources"); const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath); return this.installDirectory({ path: pluginDirectory, id: input.id }); diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 40878ae74e..466780b79a 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -280,7 +280,60 @@ describe("managed Git plugin sources", () => { }); describe("registry plugin sources", () => { - it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => { + it("explains how to choose a revision when --ref is used with a registry id", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-")); + roots.push(home); + const sources = new ManagedPluginSources(home); + await expect( + sources.prepareInstall({ source: "fixture/example", ref: "main" }), + ).rejects.toThrow( + "Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.", + ); + }); + + it("names the registry URL and recovery steps when the registry fetch fails", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-")); + roots.push(home); + const server = createServer((request) => request.socket.destroy()); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const url = `http://127.0.0.1:${address.port}`; + try { + const sources = new ManagedPluginSources(home, { defaultUrl: url }); + await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow( + `Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`, + ); + } finally { + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }); + + it("explains explicit local and GitHub sources when a registry ID is missing", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-")); + roots.push(home); + const requests: Array<string | undefined> = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.writeHead(404).end(); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + try { + const sources = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}`, + }); + await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow( + "If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.", + ); + expect(requests).toEqual(["/plugins/fixture/missing.json"]); + } finally { + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }); + + it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => { const repository = await createRepository(); const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-")); roots.push(home); @@ -298,12 +351,34 @@ describe("registry plugin sources", () => { const sources = new ManagedPluginSources(home, { defaultUrl: `http://127.0.0.1:${address.port}`, }); - const candidate = await sources.prepareInstall({ source: "fixture/repository" }); - expect(candidate.record).toEqual({ - kind: "git", - remote: "https://github.com/fixture/repository.git", - }); - await sources.discard(candidate); + for (const source of [ + "git:fixture/repository", + "https://github.com/fixture/repository.git", + ]) { + const candidate = await sources.place( + "managed-example", + await sources.prepareInstall({ source }), + ); + expect(candidate.record).toEqual({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + }); + sources.commit("managed-example", candidate.record); + const restarted = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}/changed`, + }); + expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe( + "current", + ); + expect( + (await restarted.describe("managed-example", candidate.directory)).identity, + ).toEqual({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + pluginPath: ".", + }); + await sources.discard(candidate); + } }); expect(requests).toEqual([]); } finally { @@ -332,12 +407,17 @@ describe("registry plugin sources", () => { intent: string | string[] | undefined; authorization: string | undefined; }> = []; + let missing = false; const server = createServer((req, res) => { requests.push({ path: req.url, intent: req.headers["x-paseo-install"], authorization: req.headers.authorization, }); + if (missing) { + res.writeHead(404).end(); + return; + } res.setHeader("Content-Type", "application/json"); res.end( JSON.stringify({ @@ -353,7 +433,7 @@ describe("registry plugin sources", () => { commit, pluginPath: "packages/example", }, - screenshots: [], + media: [], submittedAt: "2026-10-03", reviewedAt: "2026-10-03", updatedAt: "2026-10-03", @@ -369,7 +449,6 @@ describe("registry plugin sources", () => { const url = `http://${host}/internal`; try { const sources = new ManagedPluginSources(home, { - enabled: true, defaultUrl: url, registries: { [host]: { authorization: "Bearer test" } }, }); @@ -380,7 +459,11 @@ describe("registry plugin sources", () => { ).toBe("managed-example"); await sources.verifyCandidate("managed-example", candidate); sources.commit("managed-example", candidate.record); - const preview = await sources.preview("managed-example", candidate.directory); + const restarted = new ManagedPluginSources(home, { + defaultUrl: "http://127.0.0.1:1/changed", + registries: { [host]: { authorization: "Bearer test" } }, + }); + const preview = await restarted.preview("managed-example", candidate.directory); expect(preview.outcome).toBe("current"); expect(preview.current?.identity).toEqual({ kind: "git", @@ -388,6 +471,10 @@ describe("registry plugin sources", () => { pluginPath: "packages/example", registry: { url, id: "acme/example" }, }); + missing = true; + await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow( + "Check that the installed plugin is still published in this registry before updating", + ); expect(requests).toEqual([ { path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" }, { @@ -395,6 +482,11 @@ describe("registry plugin sources", () => { intent: undefined, authorization: "Bearer test", }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, ]); } finally { await new Promise<void>((resolve, reject) => @@ -424,27 +516,17 @@ it("installs and updates only the npm artifacts pinned by the plugin registry", const server = createServer((request, response) => { intents.push(request.headers["x-paseo-install"]); response.end( + // Install reads only the ID and artifact, so the document omits the directory's display fields. JSON.stringify({ id: "acme/example", - name: "Example", - description: "Test", - categories: [], - author: { github: "acme" }, - repository: { url: "https://github.com/acme/example" }, artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, - screenshots: [], - submittedAt: "2026-10-03", - reviewedAt: "2026-10-03", - updatedAt: "2026-10-03", - publishedAt: "2026-10-03", - readme: "# Example", }), ); }); await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("Missing registry address"); - const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` }; + const options = { defaultUrl: `http://127.0.0.1:${address.port}` }; try { const sources = new ManagedPluginSources(home, options); const candidate = await sources.place( diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index 8ee3b2b6f6..5373b7453a 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -147,11 +147,13 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise<ManagedPluginCandidate> { - const registry = this.registryOptions.enabled - ? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl) - : null; + const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl); if (registry) { - if (input.ref || input.pluginPath) + if (input.ref) + throw new Error( + "Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.", + ); + if (input.pluginPath) throw new Error("Registry sources use their reviewed artifact path and revision"); const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true); const candidate = await this.prepareInstall(resolved.input, resolved.target); @@ -175,7 +177,7 @@ export class ManagedPluginSources { sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName); record = { kind: "npm" }; } else { - const remote = normalizeGitSource(input.source, !this.registryOptions.enabled); + const remote = normalizeGitSource(input.source); sourceRoot = path.join(versionRoot, "checkout"); await clone(remote, sourceRoot); const commit = await resolveRequestedRef(sourceRoot, input.ref); @@ -227,7 +229,7 @@ export class ManagedPluginSources { const location = this.locate(pluginId, configuredPath); let target: PluginUpdateTarget; let outcome: PluginUpdatePreview["outcome"]; - if (this.registryOptions.enabled && current.identity.registry) { + if (current.identity.registry) { if (selection) throw new Error("Registry updates use only the reviewed registry pin"); const resolved = await resolveRegistryPlugin( current.identity.registry, @@ -304,7 +306,7 @@ export class ManagedPluginSources { await this.assertCurrent(proposal, configuredPath); const identity = this.locate(proposal.id, configuredPath).identity; const record = this.records[proposal.id]; - if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) { + if (identity.kind !== "directory" && identity.registry) { const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false); assertRegistryArtifactIdentity(identity, resolved.artifact); if (!isDeepStrictEqual(resolved.target, proposal.target)) @@ -436,10 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s } return []; } -/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */ -function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string { - const expandsShorthand = - bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:"); +/** Only explicit Git prefixes expand GitHub shorthand. */ +function normalizeGitSource(source: string): string { + const expandsShorthand = source.startsWith("github:") || source.startsWith("git:"); if (source.startsWith("github:")) { const shorthand = source.slice(7); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) { diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts index 179bdc5da7..009cac5a68 100644 --- a/packages/server/src/server/plugins/managed-source/registry.ts +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -1,13 +1,19 @@ import { - PublishedPluginDetailSchema, + PluginRegistryArtifactSchema, + PluginRegistryIdSchema, type PluginRegistries, type PluginRegistryIdentity, } from "@getpaseo/protocol/plugin-registry"; import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; +import { z } from "zod"; + +// Install reads only these fields; the rest of a published plugin is the directory's display data. +const RegistryInstallDocumentSchema = z.object({ + id: PluginRegistryIdSchema, + artifact: PluginRegistryArtifactSchema, +}); export interface RegistryOptions { - /** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */ - enabled?: boolean; defaultUrl?: string; registries?: PluginRegistries; } @@ -29,14 +35,29 @@ export async function resolveRegistryPlugin( const headers: Record<string, string> = {}; if (authorization) headers.Authorization = authorization; if (install) headers["X-Paseo-Install"] = "1"; - const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, { - headers, - redirect: "error", - signal: AbortSignal.timeout(30000), - }); + const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`; + let response: Response; + try { + response = await fetch(url, { + headers, + redirect: "error", + signal: AbortSignal.timeout(30000), + }); + } catch (cause) { + const guidance = install + ? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.` + : `Could not check updates for ${identity.id}. Retry later.`; + throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause }); + } + if (response.status === 404) { + const guidance = install + ? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.` + : "Check that the installed plugin is still published in this registry before updating."; + throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`); + } if (!response.ok) throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`); - const plugin = PublishedPluginDetailSchema.parse(await response.json()); + const plugin = RegistryInstallDocumentSchema.parse(await response.json()); if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID"); const artifact = plugin.artifact; const target: PluginUpdateTarget = diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 3ff06e2c3e..399e350493 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -24,6 +24,69 @@ describe("plugin manifest", () => { }); }); + it("reads display metadata and ignores future top-level fields", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"], + }; + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ ...manifest, futureField: { enabled: true } }), + ); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + }); + + it.each([ + { name: " " }, + { name: 42 }, + { icon: "" }, + { icon: 42 }, + { icon: "icon.svg" }, + { icon: "/icon.png" }, + { icon: "../icon.png" }, + { icon: "assets/../../icon.png" }, + { icon: "https://example.com/icon.png" }, + { icon: "C:/icon.png" }, + { icon: "assets\\icon.png" }, + { icon: "icon.png?query" }, + { media: "screenshot.png" }, + { media: [42] }, + { media: [""] }, + { media: ["../demo.mp4"] }, + { media: ["/demo.mp4"] }, + { media: ["http://example.com/demo.mp4"] }, + { media: ["https://"] }, + { media: ["//example.com/demo.mp4"] }, + { media: ["file:///demo.mp4"] }, + { media: ["data:image/png;base64,abc"] }, + { media: ["."] }, + { media: ["assets/"] }, + { id: "INVALID" }, + { description: 42 }, + { requirements: { paseoo: ">=0.11.0" } }, + ])("rejects invalid known fields: %j", async (fields) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "example", futureField: true, ...fields }), + ); + await expect(readPluginManifest(directory)).rejects.toThrow(); + }); + + it("accepts omitted metadata and empty media", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) { + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest)); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + } + }); + it("reads and validates requirements before any plugin code runs", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index d2e13d614b..2312a2cb5a 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json"; const PluginBuildCommandSchema = z .array(z.string().refine((argument) => argument.trim().length > 0)) .min(1); -const PluginManifestSchema = z - .object({ - id: PluginIdSchema, - description: z.string().trim().min(1).optional(), - requirements: PluginRequirementsSchema.strict().optional(), - build: z.array(PluginBuildCommandSchema).min(1).optional(), - }) - .strict(); +// Manifest asset paths use forward slashes and stay inside the plugin package. +const PluginAssetPathSchema = z + .string() + .min(1) + .refine( + (value) => + value === value.trim() && + !value.startsWith("/") && + !/[\\:?#\p{Cc}]/u.test(value) && + !value.split("/").includes("..") && + !value.endsWith("/") && + value.split("/").at(-1) !== ".", + "Expected a relative file path inside the plugin package", + ); +const PluginManifestSchema = z.object({ + id: PluginIdSchema, + name: z.string().trim().min(1).optional(), + description: z.string().trim().min(1).optional(), + icon: PluginAssetPathSchema.refine( + (value) => /\.png$/i.test(value), + "Expected a relative path to a PNG", + ).optional(), + media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(), + // A misspelled requirement must not silently disable compatibility checks. + requirements: PluginRequirementsSchema.strict().optional(), + build: z.array(PluginBuildCommandSchema).min(1).optional(), +}); export type PluginManifest = z.infer<typeof PluginManifestSchema>; diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index d0ad016cec..5da355d5df 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -1,3 +1,7 @@ +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; import { EventEmitter, once } from "node:events"; import { resolveDaemonVersion } from "../daemon-version.js"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -15,6 +19,59 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +test.each(["React Native", "native"])( + "provider readiness works with %s AbortController", + async (runtime) => { + const require = createRequire(import.meta.url); + const reactNativeRequire = createRequire(require.resolve("react-native/package.json")); + const { AbortController: ReactNativeAbortController } = reactNativeRequire( + "abort-controller/dist/abort-controller", + ); + const bundle = await build({ + entryPoints: [fileURLToPath(new URL("../../../../client/src/index.ts", import.meta.url))], + bundle: true, + write: false, + format: "iife", + globalName: "paseoSdk", + platform: "browser", + }); + // A separate runtime starts with React Native's real abort implementation. + // Node's globals and the daemon's runtime remain unchanged. + const sdk: typeof import("@getpaseo/client") = runInNewContext( + `${bundle.outputFiles[0]!.text}\npaseoSdk`, + { + AbortController: runtime === "React Native" ? ReactNativeAbortController : AbortController, + setTimeout, + clearTimeout, + queueMicrotask, + TextEncoder, + TextDecoder, + }, + ); + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const api = sdk.createPaseoApi(client); + try { + await client.connect(); + const snapshot = await api.providers.waitForReady({ cwd: daemon.staticDir }); + expect(snapshot.entries.length).toBeGreaterThan(0); + expect(snapshot.entries.every((entry) => entry.status !== "loading")).toBe(true); + const waiting = api.providers.waitForReady({ cwd: daemon.staticDir }); + const canceled = expect(waiting).rejects.toThrow(/disposed|released/); + await api.dispose(); + await canceled; + const { diagnostic } = await client.collectDiagnostics(); + expect(diagnostic).toContain("Source registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + expect(() => api.providers.waitForReady()).toThrow("disposed"); + } finally { + await api.dispose(); + await client.close(); + await daemon.close(); + } + }, +); + test("plugin handlers create workspaces and agents through their Paseo API", async () => { const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-plugin-")); const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-workspace-")); diff --git a/packages/server/src/server/plugins/plugin-process.test.ts b/packages/server/src/server/plugins/plugin-process.test.ts index 6a4380d503..ddee3cb291 100644 --- a/packages/server/src/server/plugins/plugin-process.test.ts +++ b/packages/server/src/server/plugins/plugin-process.test.ts @@ -37,3 +37,14 @@ it("keeps React out of the plugin host's runtime dependency graph", async () => }), ).resolves.toMatchObject({ errors: [] }); }); + +it("exposes CLI probes through the plugin host's server SDK", async () => { + const { evaluateBundle } = await import("./bundle-evaluator.js"); + const setup = evaluateBundle(`(require) => ({ default: async () => { + const { execCommand } = require("@getpaseo/plugin/server"); + const { stdout } = await execCommand(${JSON.stringify(process.execPath)}, ["-e", "process.stdout.write('plugin CLI ready')"]); + if (stdout !== "plugin CLI ready") throw new Error("CLI output was lost"); + } })`); + // This contribution uses no server context APIs; it exercises the host's real SDK binding. + await expect(setup(undefined as never)).resolves.toBeUndefined(); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 338ebd1d93..eeea242788 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -150,6 +150,28 @@ test("discovery failures log once while other sources still report", async () => expect(warnings).toEqual([[{ sourceId: "kimi", err }, "Usage source discovery failed"]]); }); +test("a failed fetch is logged as well as reported", async () => { + const warnings: unknown[][] = []; + const registry = new UsageSourceRegistry(Date.now, 300_000, { + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }); + const err = new Error("Rate limited by Claude. Try again in 38m."); + registry.register( + source({ + id: "claude", + discover: async () => [{ key: "work", input: {} }], + fetch: async () => { + throw err; + }, + }), + ); + const [report] = await registry.listReports(); + expect(report?.report).toEqual({ status: "error", error: err.message }); + expect(warnings).toEqual([[{ sourceId: "claude", err }, "Usage fetch failed"]]); +}); + test("legacy listing distinguishes labeled accounts and preserves unlabeled names", async () => { const registry = new UsageSourceRegistry(() => 1000); registry.register( @@ -168,7 +190,7 @@ test("legacy listing distinguishes labeled accounts and preserves unlabeled name ).toEqual(["claude (work)", "claude (personal)", "claude"]); }); -test("duplicate logins follow discovery order and fall back on unavailable, error and throws", async () => { +test("duplicate logins prefer discovery order across unavailable, error and thrown fetches", async () => { for (const failure of ["unavailable", "error", "throw"]) { const registry = new UsageSourceRegistry(); const calls: number[] = []; @@ -194,7 +216,7 @@ test("duplicate logins follow discovery order and fall back on unavailable, erro const reports = await registry.listReports(); expect(reports).toHaveLength(1); expect(reports[0]?.report.status).toBe("available"); - expect(calls).toEqual([0, 1]); + expect(calls).toEqual([0, 1, 2]); } }); @@ -316,7 +338,7 @@ test("session discovery reuses accounts, separates launches, and retains live ag await registry.listReports({ agentId: "one" }); await registry.listReports({ agentId: "two" }); expect(discoveries).toEqual(["/one", "/two"]); - expect(fetches).toBe(1); + expect(fetches).toBe(2); expect((await registry.listReports()).map((r) => r.id)).toEqual([ "claude:default", "claude:same", @@ -373,3 +395,164 @@ test("reports arrive independently and a hung fetch settles as an error before c entries[0]?.report, ); }); + +test("#6155: agent reports use only their session login despite host and sibling discoveries", async () => { + const sessions = new Map([ + ["codex-agent", { provider: "codex", env: {}, sessionKey: "codex-launch" }], + ["opencode-agent", { provider: "opencode", env: {}, sessionKey: "opencode-launch" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const codexError = { status: "error", error: "Codex usage API returned 500" }; + const expired = { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["codex", "opencode"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: { harness }, + })), + fetch: async (input) => + (input as { harness: string }).harness === "codex" ? codexError : expired, + }); + await registry.listReports(); + await registry.listReports({ agentId: "opencode-agent" }); + expect((await registry.listReports({ agentId: "codex-agent" }))[0]?.report).toEqual(codexError); +}); + +test("all failed host logins retain each source-supplied harness and remedy", async () => { + const registry = new UsageSourceRegistry(); + const failures = [ + { status: "error", error: "Codex usage API returned 500" }, + { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }, + { status: "unavailable", problem: { kind: "rejected", status: 401, refreshedBy: "pi" } }, + { status: "unavailable", problem: { kind: "rejected", status: 403, refreshedBy: "omp" } }, + ]; + const harnesses = ["Codex", "OpenCode", "Pi", "OMP"]; + registry.register( + source({ + id: "codex", + discover: async () => harnesses.map((harness, input) => ({ key: "same", harness, input })), + fetch: async (input) => failures[Number(input)], + }), + ); + const [entry] = await registry.listReports(); + expect(entry?.loginErrors).toEqual( + harnesses.map((harness, index) => ({ harness, report: failures[index] })), + ); +}); + +test("a working agent login never uses another harness's failure or host fallback cache", async () => { + const sessions = new Map([ + ["working", { provider: "codex", env: {}, sessionKey: "one" }], + ["broken", { provider: "opencode", env: {}, sessionKey: "two" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const available = { + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }], + }; + const error = { status: "error", error: "OpenCode login rejected. Run opencode to refresh it." }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["opencode", "codex"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: harness, + })), + fetch: async (input) => (input === "codex" ? available : error), + }); + const [host] = await registry.listReports(); + expect(host?.report).toEqual(available); + expect(host?.loginErrors).toBeUndefined(); + expect((await registry.listReports({ agentId: "working" }))[0]?.report).toEqual(available); + expect((await registry.listReports({ agentId: "broken" }))[0]?.report).toEqual(error); + expect( + (await registry.listReports({ agentId: "broken", forceRefresh: true }))[0]?.report, + ).toEqual(error); + expect( + (await registry.listReports({ reportIds: ["codex:same"], forceRefresh: true }))[0]?.report, + ).toEqual(available); +}); + +test("concurrent host and agent requests never share a different login's pending fetch", async () => { + let finish!: (report: unknown) => void; + const slow = new Promise<unknown>((resolve) => { + finish = resolve; + }); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [{ key: "same", harness: "Codex", input: scope.kind }], + fetch: async (input) => + input === "global" ? slow : { status: "error", error: "Session login failed" }, + }); + const host = registry.listReports(); + const [agent] = await registry.listReports({ agentId: "agent" }); + expect(agent?.report).toEqual({ status: "error", error: "Session login failed" }); + finish({ status: "available", windows: [] }); + expect((await host)[0]?.report.status).toBe("available"); +}); + +test("duplicate logins across scopes appear only once in the host's errors", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [ + { + key: "same", + harness: "Codex", + input: + scope.kind === "global" + ? { path: "/auth", kind: "file" } + : { kind: "file", path: "/auth" }, + }, + ], + fetch: async () => ({ status: "error", error: "Login failed" }), + }); + await registry.listReports({ agentId: "agent" }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Login failed" } }, + ]); +}); + +test("a hung login records its own timeout and still tries the next login", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, undefined, 25); + registry.register({ + id: "codex", + label: "Codex", + discover: async () => [ + { key: "same", harness: "Codex", input: 0 }, + { key: "same", harness: "OpenCode", input: 1 }, + ], + fetch: async (input) => + input === 0 ? new Promise(() => {}) : { status: "error", error: "Login rejected" }, + }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Usage fetch timed out" } }, + { harness: "OpenCode", report: { status: "error", error: "Login rejected" } }, + ]); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index 157bf7df4e..c0c9d781e5 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { AgentUsageSession } from "../../agent/agent-sdk-types.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { z } from "zod"; @@ -18,9 +19,14 @@ export interface UsageSource { fetch(input: unknown): Promise<unknown>; } +interface Login { + input: unknown; + harness: string; +} + interface KnownReport { source: UsageSource; - inputs: [unknown, ...unknown[]]; + logins: [Login, ...Login[]]; label?: string; } @@ -75,6 +81,7 @@ export class UsageSourceRegistry { } for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); + for (const key of this.pending.keys()) if (key.startsWith(`${id}:`)) this.pending.delete(key); } async listReports(options: ListUsageReportsOptions = {}): Promise<UsageReportEntry[]> { @@ -82,8 +89,10 @@ export class UsageSourceRegistry { throw new Error("agentId and reportIds cannot be combined"); this.pruneAgents(); let ids: string[]; + let reports = this.known; if (options.agentId !== undefined) { ids = await this.discoverAgent(options.agentId); + reports = this.byAgent.get(options.agentId)?.reports ?? new Map(); } else if (options.reportIds !== undefined) { ids = options.reportIds; } else { @@ -93,7 +102,7 @@ export class UsageSourceRegistry { } return Promise.all( [...new Set(ids)].flatMap((id) => { - const known = this.known.get(id); + const known = reports.get(id); if (!known) return []; return [ this.fetchId(id, known, options.forceRefresh).then((entry) => { @@ -149,8 +158,14 @@ export class UsageSourceRegistry { for (const [id, report] of reports) { if (this.sources.get(report.source.id) !== report.source) continue; const known = this.known.get(id); - if (known) known.inputs.push(...report.inputs); - else this.known.set(id, { ...report, inputs: [...report.inputs] }); + if (!known) { + this.known.set(id, { ...report, logins: [...report.logins] }); + continue; + } + for (const login of report.logins) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } } } } @@ -165,6 +180,7 @@ export class UsageSourceRegistry { z.object({ key: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), label: z.string().optional(), + harness: z.string().min(1).optional(), input: z.json(), }), ) @@ -172,8 +188,12 @@ export class UsageSourceRegistry { for (const account of accounts) { const id = `${source.id}:${account.key}`; const known = reports.get(id); - if (known) known.inputs.push(account.input); - else reports.set(id, { source, inputs: [account.input], label: account.label }); + // COMPAT(usageLoginHarness): added in v0.11.0, remove after 2027-04-05 once plugin floor >= v0.11.0. + const login = { input: account.input, harness: account.harness ?? source.label }; + if (known) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } else reports.set(id, { source, logins: [login], label: account.label }); } } catch (error) { this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); @@ -214,10 +234,14 @@ export class UsageSourceRegistry { } private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise<UsageReportEntry> { - const cached = this.cache.get(id); + // Account identity groups cards; the ordered login set identifies a fetch result. + const cacheKey = `${id}:${createHash("sha256") + .update(JSON.stringify([known.label, known.logins.map(loginKey)])) + .digest("hex")}`; + const cached = this.cache.get(cacheKey); if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) return Promise.resolve(cached.entry); - const pending = this.pending.get(id); + const pending = this.pending.get(cacheKey); if (pending) return pending; const request = (async () => { const entry: UsageReportEntry = { @@ -227,14 +251,14 @@ export class UsageSourceRegistry { icon: known.source.icon, account: { label: known.label }, fetchedAt: new Date(this.now()).toISOString(), - report: await this.fetchBeforeDeadline(known), + ...(await this.fetchWithFallback(known)), }; - this.writeCache(id, entry); + if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry); return entry; })(); - this.pending.set(id, request); + this.pending.set(cacheKey, request); void request.finally(() => { - if (this.pending.get(id) === request) this.pending.delete(id); + if (this.pending.get(cacheKey) === request) this.pending.delete(cacheKey); }); return request; } @@ -247,7 +271,7 @@ export class UsageSourceRegistry { this.cache.set(id, { at, entry }); } - private async fetchBeforeDeadline(known: KnownReport): Promise<UsageReport> { + private async fetchBeforeDeadline(source: UsageSource, input: unknown): Promise<UsageReport> { let timer: ReturnType<typeof setTimeout> | undefined; const deadline = new Promise<UsageReport>((resolve) => { timer = setTimeout( @@ -256,25 +280,36 @@ export class UsageSourceRegistry { ); }); try { - return await Promise.race([this.fetchWithFallback(known), deadline]); + return await Promise.race([this.fetchLogin(source, input), deadline]); } finally { clearTimeout(timer); } } - private async fetchWithFallback(known: KnownReport): Promise<UsageReport> { - let report = await this.fetchLogin(known.source, known.inputs[0]); - for (const input of known.inputs.slice(1)) { - if (report.status === "available") break; - report = await this.fetchLogin(known.source, input); + private async fetchWithFallback( + known: KnownReport, + ): Promise<Pick<UsageReportEntry, "report" | "loginErrors">> { + // Start each login within the same deadline window, then prefer discovery order. + // Sequential deadlines can exceed the client's RPC timeout before all errors arrive. + const attempts = known.logins.map((login) => + this.fetchBeforeDeadline(known.source, login.input), + ); + const loginErrors: NonNullable<UsageReportEntry["loginErrors"]> = []; + for (const [index, login] of known.logins.entries()) { + const report = await attempts[index]!; + if (report.status === "available") return { report }; + loginErrors.push({ harness: login.harness, report }); } - return report; + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once app floor >= v0.11.0. + // Keep the final single report so older apps still parse and display failed accounts. + return { report: loginErrors[loginErrors.length - 1]!.report, loginErrors }; } private async fetchLogin(source: UsageSource, input: unknown): Promise<UsageReport> { try { return UsageReportSchema.parse(await source.fetch(input)); } catch (error) { + this.logger.warn({ sourceId: source.id, err: error }, "Usage fetch failed"); return { status: "error", error: error instanceof Error ? error.message : String(error) }; } } @@ -302,3 +337,12 @@ function legacyError(report: UsageReport, now: number): string | null { if (report.status === "unavailable") return legacyProblem(report.problem, now); return null; } + +// Inputs are opaque JSON locators. Canonical equality deduplicates discovery across scopes. +function loginKey(login: Login): string { + return JSON.stringify(login, (_key, value) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + ); +} diff --git a/packages/server/src/server/private-files.ts b/packages/server/src/server/private-files.ts index 8b162f292b..0e12457be1 100644 --- a/packages/server/src/server/private-files.ts +++ b/packages/server/src/server/private-files.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import path from "node:path"; @@ -28,14 +28,27 @@ export function writePrivateFileAtomicSync( data: string | NodeJS.ArrayBufferView, ): void { ensurePrivateDirectory(path.dirname(filePath)); - const parent = path.dirname(filePath); - const temporary = path.join(parent, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}`); + const destination = resolveSymlinkedFile(filePath); + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}`, + ); try { writeFileSync(temporary, data, { mode: PRIVATE_FILE_MODE }); - renameSync(temporary, filePath); - ensurePrivateFile(filePath); + renameSync(temporary, destination); + ensurePrivateFile(destination); } catch (error) { rmSync(temporary, { force: true }); throw error; } } + +// Renaming onto a symlink replaces the link, so write to the file it points at. +function resolveSymlinkedFile(filePath: string): string { + try { + return realpathSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return filePath; + throw error; + } +} diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 06775e5c5c..2dcf29b948 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type pino from "pino"; +import pino from "pino"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { exportPublicKey, generateKeyPair } from "@getpaseo/relay"; import { startRelayTransport } from "./relay-transport"; @@ -146,6 +146,37 @@ describe("relay-transport control lifecycle", () => { vi.useRealTimers(); }); + test("releases a stalled encrypted data connection without restarting control", async () => { + vi.useFakeTimers(); + let attachments = 0; + controllers.push( + startRelayTransport({ + logger: pino({ level: "silent" }), + attachSocket: async () => { + attachments += 1; + }, + relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, + serverId: "srv_test", + daemonKeyPair: generateKeyPair(), + createWebSocket: relay.createWebSocket, + }), + ); + const control = relay.sockets[0]; + control.open(); + const connected = JSON.stringify({ type: "connected", connectionId: "stalled" }); + control.message(connected); + const stalled = relay.sockets[1]; + stalled.open(); + await vi.advanceTimersByTimeAsync(15_000); + expect(stalled.terminateCalls).toBe(1); + expect(attachments).toBe(0); + expect(control.terminateCalls).toBe(0); + control.message(connected); + expect(relay.sockets).toHaveLength(3); + expect(relay.sockets[2].url).toBe(stalled.url); + }); + test("logs relay_control_connected only after first valid control message", () => { const logger = createMockLogger(); const controller = startRelayTransport({ diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index ddf1a3c234..f7b73a5367 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -420,6 +420,10 @@ async function attachEncryptedSocket( attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>, metadata?: ExternalSocketMetadata, ): Promise<void> { + const handshakeTimeout = setTimeout(() => { + logger.warn("relay_e2ee_handshake_timeout_terminating"); + socket.terminate(); + }, 15_000); try { const relayTransport = createRelayTransportAdapter(socket, logger); const emitter = new EventEmitter(); @@ -440,6 +444,7 @@ async function attachEncryptedSocket( emitter.emit("error", error); }, }); + clearTimeout(handshakeTimeout); const encryptedSocket = createEncryptedRelaySocket({ channel, emitter, @@ -459,6 +464,8 @@ async function attachEncryptedSocket( } catch { // ignore } + } finally { + clearTimeout(handshakeTimeout); } } diff --git a/packages/server/src/server/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/daemon/daemon-self-updater.test.ts b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts index faf9491f50..711fd11d80 100644 --- a/packages/server/src/server/session/daemon/daemon-self-updater.test.ts +++ b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts @@ -1,11 +1,18 @@ -import { describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, onTestFinished, test } from "vitest"; import { DaemonSelfUpdateInProgressError, DaemonSelfUpdater, type DaemonSelfUpdateRuntime, type DaemonSelfUpdatePhase, } from "./daemon-self-updater.js"; -import type { CommandResult, NpmGlobalPaseoInstall } from "./npm-global-cli.js"; +import { + DefaultNpmGlobalPaseoCli, + type CommandResult, + type NpmGlobalPaseoInstall, +} from "./npm-global-cli.js"; interface TestLogger { errors: Array<{ obj: object; msg?: string }>; @@ -100,6 +107,47 @@ async function runUpdate(input: { } describe("DaemonSelfUpdater", () => { + test("probes and updates the running install's prefix when npm defaults elsewhere", async () => { + const prefix = mkdtempSync(path.join(tmpdir(), "custom npm prefix ")); + onTestFinished(() => rmSync(prefix, { recursive: true, force: true })); + const root = process.platform === "win32" ? prefix : path.join(prefix, "lib"); + const packagePath = path.join(root, "node_modules", "@getpaseo", "cli"); + mkdirSync(packagePath, { recursive: true }); + const commands: string[][] = []; + let version = "0.1.15"; + const npm = new DefaultNpmGlobalPaseoCli(async (_command, args) => { + commands.push(args); + if (args[args.indexOf("--prefix") + 1] !== prefix) { + return { exitCode: 1, stdout: "{}", stderr: "No CLI in npm's default prefix" }; + } + if (args.includes("install")) version = "0.1.96"; + return { + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + path: root, + dependencies: { "@getpaseo/cli": { version, path: packagePath } }, + }), + }; + }); + const { result } = await runUpdate({ + runtime: { + npm, + installOrigin: { + resolveCurrentServerPackageRoot: () => + path.join(packagePath, "node_modules", "@getpaseo", "server"), + }, + }, + }); + expect(result).toEqual({ success: true, error: null, newVersion: "0.1.96" }); + expect(commands).toHaveLength(3); + expect(commands.map((args) => args.slice(-2))).toEqual([ + ["--prefix", prefix], + ["--prefix", prefix], + ["--prefix", prefix], + ]); + }); + test("refuses a Desktop-managed daemon without touching npm", async () => { const calls: RuntimeCall[] = []; const runtime = createRuntime({ calls, inspections: [] }); diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.ts b/packages/server/src/server/session/daemon/daemon-self-updater.ts index b7ca60eda3..91b5b166d4 100644 --- a/packages/server/src/server/session/daemon/daemon-self-updater.ts +++ b/packages/server/src/server/session/daemon/daemon-self-updater.ts @@ -1,6 +1,7 @@ import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { daemonInstallOriginRuntime, + resolveNpmGlobalPrefix, validateDaemonInstallOrigin, type DaemonInstallOriginRuntime, } from "./install-origin.js"; @@ -63,7 +64,12 @@ export class DaemonSelfUpdater { this.inProgress = true; try { input.onProgress("starting"); - const install = await this.runtime.npm.inspect(); + const npmOptions = { + prefix: resolveNpmGlobalPrefix( + this.runtime.installOrigin.resolveCurrentServerPackageRoot(), + ), + }; + const install = await this.runtime.npm.inspect(npmOptions); const unsupportedReason = validateDaemonInstallOrigin( install, input.daemonVersion, @@ -76,7 +82,7 @@ export class DaemonSelfUpdater { input.onProgress("downloading"); input.onProgress("installing"); - const result = await this.runtime.npm.installLatest(); + const result = await this.runtime.npm.installLatest(npmOptions); if (result.exitCode !== 0) { const error = result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`; @@ -87,7 +93,7 @@ export class DaemonSelfUpdater { return { success: false, error, newVersion: null }; } - const updatedInstall = await this.runtime.npm.inspect().catch((error: unknown) => { + const updatedInstall = await this.runtime.npm.inspect(npmOptions).catch((error: unknown) => { input.logger.warn({ err: error }, "Unable to read updated npm package version"); return null; }); diff --git a/packages/server/src/server/session/daemon/install-origin.ts b/packages/server/src/server/session/daemon/install-origin.ts index 3f305ab3ad..cddd905e47 100644 --- a/packages/server/src/server/session/daemon/install-origin.ts +++ b/packages/server/src/server/session/daemon/install-origin.ts @@ -15,6 +15,21 @@ export const daemonInstallOriginRuntime: DaemonInstallOriginRuntime = { resolveCurrentServerPackageRoot, }; +export function resolveNpmGlobalPrefix(serverPackageRoot: string | null): string | undefined { + if (!serverPackageRoot) return undefined; + let directory = serverPackageRoot; + let prefix: string | undefined; + while (path.dirname(directory) !== directory) { + if (path.basename(directory) === "node_modules") { + const root = path.dirname(directory); + if (process.platform === "win32") prefix = root; + else if (path.basename(root) === "lib") prefix = path.dirname(root); + } + directory = path.dirname(directory); + } + return prefix; +} + export function validateDaemonInstallOrigin( install: NpmGlobalPaseoInstall, daemonVersion: string | null, diff --git a/packages/server/src/server/session/daemon/npm-global-cli.test.ts b/packages/server/src/server/session/daemon/npm-global-cli.test.ts index aece7eb09a..ba852e232e 100644 --- a/packages/server/src/server/session/daemon/npm-global-cli.test.ts +++ b/packages/server/src/server/session/daemon/npm-global-cli.test.ts @@ -1,5 +1,7 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { DefaultNpmGlobalPaseoCli } from "./npm-global-cli.js"; interface CommandCall { @@ -10,25 +12,65 @@ interface CommandCall { } const globalRoot = path.join(path.sep, "global", "lib"); -const globalNodeModules = path.join(globalRoot, "node_modules"); -const cliPackagePath = path.join(globalNodeModules, "@getpaseo", "cli"); -function npmGlobalPaseoCliJson(version: string, options?: { linked?: boolean }): string { - return JSON.stringify({ - name: "lib", - path: globalRoot, - dependencies: { - "@getpaseo/cli": { - version, - path: cliPackagePath, - link: options?.linked === true, - }, - }, - }); +interface GlobalInstall { + root: string; + packagePath: string; + // npm 7+ `ls --json --long` marks neither kind of install with a link flag. + npmLsJson: string; } describe("DefaultNpmGlobalPaseoCli", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function createGlobalInstall(options: { linked: boolean }): GlobalInstall { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-npm-global-")); + tempDirs.push(dir); + const root = path.join(dir, "prefix", "lib"); + const packagePath = path.join(root, "node_modules", "@getpaseo", "cli"); + const checkoutCli = path.join(dir, "checkout", "packages", "cli"); + if (options.linked) { + mkdirSync(checkoutCli, { recursive: true }); + mkdirSync(path.dirname(packagePath), { recursive: true }); + symlinkSync(checkoutCli, packagePath, "junction"); + } else { + mkdirSync(packagePath, { recursive: true }); + } + const npmLsJson = JSON.stringify({ + name: "lib", + path: root, + dependencies: { + "@getpaseo/cli": { + version: "0.1.15", + resolved: options.linked + ? `file:${path.relative(root, checkoutCli)}` + : "https://registry.npmjs.org/@getpaseo/cli/-/cli-0.1.15.tgz", + path: packagePath, + }, + }, + }); + return { root, packagePath, npmLsJson }; + } + + test("reports a global install linked to a local checkout as linked", async () => { + const install = createGlobalInstall({ linked: true }); + const cli = new DefaultNpmGlobalPaseoCli(async () => ({ + exitCode: 0, + stdout: install.npmLsJson, + stderr: "", + })); + + await expect(cli.inspect()).resolves.toMatchObject({ isLinked: true }); + }); + test("inspects the npm global cli install with npm -g ls", async () => { + const install = createGlobalInstall({ linked: false }); const calls: CommandCall[] = []; const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => { calls.push({ @@ -37,13 +79,13 @@ describe("DefaultNpmGlobalPaseoCli", () => { timeout: options?.timeout, maxBuffer: options?.maxBuffer, }); - return { exitCode: 0, stdout: npmGlobalPaseoCliJson("0.1.15"), stderr: "" }; + return { exitCode: 0, stdout: install.npmLsJson, stderr: "" }; }); await expect(cli.inspect()).resolves.toEqual({ version: "0.1.15", - packagePath: cliPackagePath, - globalRootPath: globalRoot, + packagePath: install.packagePath, + globalRootPath: install.root, isLinked: false, }); expect(calls).toEqual([ diff --git a/packages/server/src/server/session/daemon/npm-global-cli.ts b/packages/server/src/server/session/daemon/npm-global-cli.ts index d1e0444058..6133dcd9d1 100644 --- a/packages/server/src/server/session/daemon/npm-global-cli.ts +++ b/packages/server/src/server/session/daemon/npm-global-cli.ts @@ -1,3 +1,4 @@ +import { lstatSync } from "node:fs"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { z } from "zod"; import { execCommand } from "../../../utils/spawn.js"; @@ -19,7 +20,6 @@ const NpmGlobalCliPackageSchema = z .object({ version: z.string(), path: z.string(), - link: z.boolean().optional(), }) .passthrough(); @@ -50,8 +50,12 @@ export interface NpmGlobalPaseoInstall { } export interface NpmGlobalPaseoCli { - inspect(): Promise<NpmGlobalPaseoInstall>; - installLatest(): Promise<CommandResult>; + inspect(options?: NpmGlobalOptions): Promise<NpmGlobalPaseoInstall>; + installLatest(options?: NpmGlobalOptions): Promise<CommandResult>; +} + +interface NpmGlobalOptions { + prefix?: string; } export type CommandRunner = ( @@ -108,17 +112,20 @@ function parseNpmGlobalPaseoInstall(stdout: string): NpmGlobalPaseoInstall | nul version: cliPackage.data.version, packagePath: cliPackage.data.path, globalRootPath: list.data.path ?? null, - isLinked: cliPackage.data.link === true, + // npm links an install by making its node_modules entry a symlink (a junction + // on Windows), and `npm ls --json` reports no flag for it. + isLinked: lstatSync(cliPackage.data.path).isSymbolicLink(), }; } export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli { constructor(private readonly runCommand: CommandRunner = runExternalCommand) {} - async inspect(): Promise<NpmGlobalPaseoInstall> { + async inspect(options: NpmGlobalOptions = {}): Promise<NpmGlobalPaseoInstall> { + const prefixArgs = options.prefix ? ["--prefix", options.prefix] : []; const result = await this.runCommand( "npm", - ["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long"], + ["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long", ...prefixArgs], { timeout: NPM_PROBE_TIMEOUT_MS, maxBuffer: NPM_MAX_BUFFER_BYTES, @@ -136,8 +143,9 @@ export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli { return install; } - installLatest(): Promise<CommandResult> { - return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`], { + installLatest(options: NpmGlobalOptions = {}): Promise<CommandResult> { + const prefixArgs = options.prefix ? ["--prefix", options.prefix] : []; + return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`, ...prefixArgs], { timeout: NPM_INSTALL_TIMEOUT_MS, maxBuffer: NPM_MAX_BUFFER_BYTES, }); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index b84734eb8b..2692250b49 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -145,6 +145,46 @@ test("fresh non-git directory creates a directory workspace at the exact path", expect(workspace.cwd).toBe(dir); }); +test("background workspace creation preserves hooks, inheritance and durable visibility", async () => { + const dir = path.join(tmpDir, "plain"); + const events: string[] = []; + provisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: gitService(), + isDirectory, + logger, + lifecycle: { + emit: (name) => { + events.push(name); + }, + before: async (_name, request) => request, + }, + }); + const parent = await provisioning.createWorkspaceForDirectory(dir, null, undefined, { + background: true, + }); + const child = await provisioning.createWorkspaceForDirectory(dir, null, undefined, { + callerWorkspaceId: parent.workspaceId, + }); + const visible = await provisioning.createWorkspaceForDirectory(dir, null, undefined, { + callerWorkspaceId: parent.workspaceId, + background: false, + }); + const human = await provisioning.createWorkspaceForDirectory(dir); + expect(parent.background).toBe(true); + expect(child.background).toBe(true); + expect(visible.background).toBe(false); + expect(human.background).toBe(false); + expect(events).toEqual(Array(4).fill("workspace.created")); + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await reloaded.initialize(); + expect((await reloaded.get(child.workspaceId))?.background).toBe(true); +}); + test("re-opening an active workspace by exact path returns the same record without duplicating", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); @@ -236,6 +276,30 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps expect(reopened.archivedAt).toBeNull(); }); +test("default directory open skips archived background workspaces while explicit recovery preserves their visibility", async () => { + const cwd = path.join(tmpDir, "repo"); + mkdirSync(cwd, { recursive: true }); + const background = await provisioning.createWorkspaceForDirectory(cwd, undefined, undefined, { + background: true, + }); + await workspaceRegistry.archive(background.workspaceId, ARCHIVED_AT); + + const opened = await provisioning.findOrCreateWorkspaceForDirectory(cwd); + expect(opened).toMatchObject({ background: false, archivedAt: null }); + expect(opened.workspaceId).not.toBe(background.workspaceId); + const archived = await workspaceRegistry.get(background.workspaceId); + expect(archived).toMatchObject({ background: true, archivedAt: ARCHIVED_AT }); + const recovered = await provisioning.ensureWorkspaceRecordUnarchived(archived!); + expect(recovered).toMatchObject({ + workspaceId: background.workspaceId, + background: true, + archivedAt: null, + }); + expect((await provisioning.findOrCreateWorkspaceForDirectory(cwd)).workspaceId).toBe( + opened.workspaceId, + ); +}); + test("reopening archived exact-root records restores the fresh Git project", async () => { const cwd = path.join(tmpDir, "repo"); mkdirSync(cwd, { recursive: true }); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 5149ed5d1c..6dbcabdda6 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -26,6 +26,8 @@ export interface ResolveOrCreateWorkspaceIdInput { requestedWorkspaceId?: string; cwd: string; initialTitle: string | null; + background?: boolean; + callerWorkspaceId?: string; } export interface ImportWorkspaceInput { @@ -50,6 +52,8 @@ export interface CreateWorktreeWorkspaceInput { title: string | null; expectsInitialAgent?: boolean; untrustedSource?: UntrustedWorkspaceSource; + background?: boolean; + callerWorkspaceId?: string; } export interface WorkspaceProvisioningService { @@ -63,7 +67,12 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean; workspaceId?: string }, + context?: { + expectsInitialAgent?: boolean; + workspaceId?: string; + background?: boolean; + callerWorkspaceId?: string; + }, ): Promise<PersistedWorkspaceRecord>; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -215,11 +224,27 @@ export function createWorkspaceProvisioningService(deps: { return project; } + async function resolveBackground(input?: { + background?: boolean; + callerWorkspaceId?: string; + }): Promise<boolean> { + if (input?.background !== undefined) return input.background; + if (!input?.callerWorkspaceId) return false; + const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId); + if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`); + return callerWorkspace.background; + } + async function createWorkspaceForDirectory( cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean; workspaceId?: string }, + context?: { + expectsInitialAgent?: boolean; + workspaceId?: string; + background?: boolean; + callerWorkspaceId?: string; + }, ): Promise<PersistedWorkspaceRecord> { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -235,9 +260,12 @@ export function createWorkspaceProvisioningService(deps: { title: title?.trim() || null, createdAt: timestamp, updatedAt: timestamp, + background: await resolveBackground(context), }); - await workspaceRegistry.upsert(workspace, context); - deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + await workspaceRegistry.upsert(workspace, { + expectsInitialAgent: context?.expectsInitialAgent, + }); + emitWorkspaceCreated(workspace); return workspace; } @@ -269,14 +297,19 @@ export function createWorkspaceProvisioningService(deps: { createdAt: timestamp, updatedAt: timestamp, ...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}), + background: await resolveBackground(input), }); await workspaceRegistry.upsert(workspace, { expectsInitialAgent: input.expectsInitialAgent, }); - deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + emitWorkspaceCreated(workspace); return workspace; } + function emitWorkspaceCreated(workspace: PersistedWorkspaceRecord): void { + deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + } + async function resolveSourceProjectForWorktree(input: { sourceCwd: string; projectId?: string; @@ -320,26 +353,19 @@ export function createWorkspaceProvisioningService(deps: { async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> { const normalizedCwd = resolve(cwd); - const workspaces = await workspaceRegistry.list(); - const active = workspaces + // Path-based discovery selects public work; explicit-ID recovery bypasses this selector. + const workspaces = (await workspaceRegistry.list()) .filter( - (workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd), + (workspace) => !workspace.background && areEquivalentPaths(workspace.cwd, normalizedCwd), ) .sort( (left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.workspaceId.localeCompare(right.workspaceId), - )[0]; + ); + const active = workspaces.find((workspace) => !workspace.archivedAt); if (active) return refreshWorkspaceRecord(active); - const archived = workspaces - .filter( - (workspace) => workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd), - ) - .sort( - (left, right) => - Date.parse(left.createdAt) - Date.parse(right.createdAt) || - left.workspaceId.localeCompare(right.workspaceId), - )[0]; + const archived = workspaces.find((workspace) => workspace.archivedAt); if (archived) { const project = await projectRegistry.get(archived.projectId); if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived); @@ -355,6 +381,8 @@ export function createWorkspaceProvisioningService(deps: { return ( await createWorkspaceForDirectory(input.cwd, input.initialTitle, undefined, { expectsInitialAgent: true, + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, }) ).workspaceId; } diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts new file mode 100644 index 0000000000..e40cc25384 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { + ensureLocalSpeechModels, + getLocalSpeechModelDir, + listLocalSpeechModels, + listMissingLocalSpeechModels, +} from "./models.js"; + +const logger = pino({ level: "silent" }); +const modelId = "parakeet-tdt-0.6b-v2-int8"; +const spec = listLocalSpeechModels().find((model) => model.id === modelId)!; + +function writeCompleteModel(modelDir: string): void { + mkdirSync(modelDir, { recursive: true }); + for (const file of spec.requiredFiles) { + writeFileSync(path.join(modelDir, file), `complete ${file}`.padEnd(64_000, ".")); + } +} + +function setupModelsDirWithArchive(): { modelsDir: string; modelDir: string; archive: string } { + const root = mkdtempSync(path.join(tmpdir(), "paseo-local-models-")); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + const sourceRoot = path.join(root, "source"); + writeCompleteModel(path.join(sourceRoot, spec.extractedDir)); + const modelsDir = path.join(root, "models"); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", sourceRoot, spec.extractedDir]); + return { modelsDir, modelDir: getLocalSpeechModelDir(modelsDir, modelId), archive }; +} + +describe("local speech models", () => { + test("reinstalls a model whose extraction was interrupted", async () => { + // An interrupted extraction leaves the archive in .downloads and a truncated file in place. + const { modelsDir, modelDir, archive } = setupModelsDirWithArchive(); + writeCompleteModel(modelDir); + const encoder = path.join(modelDir, spec.requiredFiles[0]); + truncateSync(encoder, 1_000); + + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ + modelId, + ]); + + await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + + expect(readFileSync(encoder, "utf8")).toBe( + `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), + ); + expect(existsSync(archive)).toBe(false); + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index f6c7cc4e4e..a884be86d7 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,4 +1,8 @@ -import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; +import { + ensureSherpaOnnxModels, + getSherpaOnnxModelDir, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, @@ -30,6 +34,16 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } +export async function listMissingLocalSpeechModels(options: { + modelsDir: string; + modelIds: LocalSpeechModelId[]; +}): Promise<LocalSpeechModelId[]> { + const installed = await Promise.all( + options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), + ); + return options.modelIds.filter((_modelId, index) => !installed[index]); +} + export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts index 993c959b4d..bc2e462a53 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts @@ -1,10 +1,21 @@ import { describe, expect, test } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, + truncateSync, + readdirSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { ensureSherpaOnnxModel, getSherpaOnnxModelDir } from "./model-downloader.js"; +import { getSherpaOnnxModelSpec } from "./model-catalog.js"; function makeTmpDir(): string { return mkdtempSync(path.join(tmpdir(), "paseo-speech-models-")); @@ -13,6 +24,67 @@ function makeTmpDir(): string { const logger = pino({ level: "silent" }); describe("sherpa model downloader", () => { + test("does not expose a partial model when tar extraction fails", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + mkdirSync(sourceDir, { recursive: true }); + mkdirSync(path.dirname(archive), { recursive: true }); + writeFileSync(path.join(sourceDir, "model.onnx"), Buffer.alloc(32_768, 1)); + execFileSync("tar", [ + "cf", + archive, + "-C", + path.dirname(sourceDir), + `${spec.extractedDir}/model.onnx`, + ]); + truncateSync(archive, 1_024); + await expect(ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger })).rejects.toThrow( + "tar exited", + ); + expect(existsSync(getSherpaOnnxModelDir(modelsDir, spec.id))).toBe(false); + expect(existsSync(archive)).toBe(true); + expect(readdirSync(modelsDir)).toEqual([".downloads"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("repairs a partial legacy extraction when its cached archive remains", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const modelDir = getSherpaOnnxModelDir(modelsDir, spec.id); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + for (const dir of [sourceDir, modelDir]) { + mkdirSync(path.join(dir, "espeak-ng-data"), { recursive: true }); + for (const filename of ["model.onnx", "voices.bin", "tokens.txt"]) { + writeFileSync(path.join(dir, filename), "complete model data"); + } + } + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", path.dirname(sourceDir), spec.extractedDir]); + writeFileSync(path.join(modelDir, "model.onnx"), "truncated"); + await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger }); + expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("getSherpaOnnxModelDir maps modelId to extractedDir", () => { const modelsDir = "/tmp/models"; expect(getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8")).toContain( diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index 8ce21f5e01..cdd286389f 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -90,6 +90,38 @@ async function extractTarArchive( }); } +interface SherpaOnnxModelPaths { + modelDir: string; + archivePath: string; + extractionDir: string; +} + +function getSherpaOnnxModelPaths( + modelsDir: string, + modelId: SherpaOnnxModelId, +): SherpaOnnxModelPaths { + const spec = getSherpaOnnxModelSpec(modelId); + const downloadsDir = path.join(modelsDir, ".downloads"); + return { + modelDir: path.join(modelsDir, spec.extractedDir), + archivePath: path.join(downloadsDir, path.basename(new URL(spec.archiveUrl).pathname)), + extractionDir: path.join(downloadsDir, `${spec.extractedDir}.extracting`), + }; +} + +// The archive is removed only as its model moves into place, so a model whose archive is +// still in .downloads did not finish installing and may hold truncated files. +export async function isSherpaOnnxModelInstalled( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise<boolean> { + const spec = getSherpaOnnxModelSpec(modelId); + const { modelDir, archivePath } = getSherpaOnnxModelPaths(modelsDir, modelId); + return ( + !(await isNonEmptyFile(archivePath)) && (await hasRequiredFiles(modelDir, spec.requiredFiles)) + ); +} + async function isNonEmptyFile(filePath: string): Promise<boolean> { try { const s = await stat(filePath); @@ -110,18 +142,17 @@ export async function ensureSherpaOnnxModel( }); const spec = getSherpaOnnxModelSpec(options.modelId); - const modelDir = path.join(options.modelsDir, spec.extractedDir); - if (await hasRequiredFiles(modelDir, spec.requiredFiles)) { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths( + options.modelsDir, + options.modelId, + ); + if (await isSherpaOnnxModelInstalled(options.modelsDir, options.modelId)) { return modelDir; } logger.info({ modelsDir: options.modelsDir }, "Starting model download"); try { - const downloadsDir = path.join(options.modelsDir, ".downloads"); - const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname); - const archivePath = path.join(downloadsDir, archiveFilename); - if (!(await isNonEmptyFile(archivePath))) { await downloadToFile({ url: spec.archiveUrl, @@ -138,32 +169,40 @@ export async function ensureSherpaOnnxModel( }, "Extracting model archive", ); - await extractTarArchive(archivePath, options.modelsDir, options.signal); - - logger.info( - { - modelId: options.modelId, - modelDir, - }, - "Verifying downloaded model files", - ); - if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { - throw new Error( - `Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`, - ); - } - - logger.info( - { - modelId: options.modelId, - archivePath, - }, - "Finalizing model artifacts", - ); + // Extract beside the archive and move into place once complete, so an interrupted + // extraction never leaves partial files at modelDir. + await rm(extractionDir, { recursive: true, force: true }); try { - await rm(archivePath, { force: true }); - } catch { - // ignore + await extractTarArchive(archivePath, extractionDir, options.signal); + const extractedModelDir = path.join(extractionDir, spec.extractedDir); + + logger.info( + { + modelId: options.modelId, + modelDir, + }, + "Verifying downloaded model files", + ); + if (!(await hasRequiredFiles(extractedModelDir, spec.requiredFiles))) { + throw new Error( + `Downloaded and extracted ${path.basename(archivePath)}, but required files are missing.`, + ); + } + + logger.info( + { + modelId: options.modelId, + archivePath, + }, + "Finalizing model artifacts", + ); + // A retained archive marks the model as not installed, so remove it before the model + // is moved into place. If removal fails, nothing at modelDir looks installed. + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true, maxRetries: 3 }); + await rename(extractedModelDir, modelDir); + } finally { + await rm(extractionDir, { recursive: true, force: true }); } logger.info({ modelDir }, "Model download completed"); diff --git a/packages/server/src/server/speech/providers/openai/config.test.ts b/packages/server/src/server/speech/providers/openai/config.test.ts index 55da430edc..1f3433a347 100644 --- a/packages/server/src/server/speech/providers/openai/config.test.ts +++ b/packages/server/src/server/speech/providers/openai/config.test.ts @@ -203,4 +203,40 @@ describe("resolveOpenAiSpeechConfig", () => { expect(resolved?.stt?.apiKey).toBe("stt-only-key"); expect(resolved?.tts).toBeUndefined(); }); + + test("sends the model and voice an OpenAI-compatible server serves, as written", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { + openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } }, + }, + features: { + voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } }, + }, + }); + + const resolved = resolveOpenAiSpeechConfig({ + env: {} as NodeJS.ProcessEnv, + persisted, + providers: ALL_OPENAI, + }); + + expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0"); + expect(resolved?.tts?.voice).toBe("Aiden"); + }); + + test("accepts OpenAI's own gpt-4o-mini-tts model", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { openai: { tts: { apiKey: "tts-key" } } }, + features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } }, + }); + + const resolved = resolveOpenAiSpeechConfig({ + env: {} as NodeJS.ProcessEnv, + persisted, + providers: ALL_OPENAI, + }); + + expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts"); + expect(resolved?.tts?.voice).toBe("alloy"); + }); }); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index ac8d2ffa3a..efb25bffaf 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -12,10 +12,6 @@ export interface OpenAiSpeechProviderConfig { tts?: Partial<TTSConfig> & { apiKey?: string }; } -const OpenAiTtsVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]); - -const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]); - const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]); const OptionalFiniteNumberSchema = NumberLikeSchema.pipe( @@ -44,14 +40,12 @@ const OpenAiSttOptionsSchema = z.object({ sttModel: OptionalTrimmedStringSchema, }); +// Voice and model are free-form so a self-hosted server can use its own ids; the +// OpenAI catalog is only the default. Both are sent as written, because the +// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0). const OpenAiTtsOptionsSchema = z.object({ - ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"), - ttsModel: z - .string() - .trim() - .toLowerCase() - .pipe(OpenAiTtsModelSchema) - .default(DEFAULT_OPENAI_TTS_MODEL), + ttsVoice: z.string().trim().default("alloy"), + ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL), }); function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean { diff --git a/packages/server/src/server/speech/providers/openai/tts.ts b/packages/server/src/server/speech/providers/openai/tts.ts index 6cc7e5c5bb..31f7b5b263 100644 --- a/packages/server/src/server/speech/providers/openai/tts.ts +++ b/packages/server/src/server/speech/providers/openai/tts.ts @@ -8,8 +8,8 @@ export type { SpeechStreamResult }; export interface TTSConfig { apiKey: string; baseUrl?: string; - model?: "tts-1" | "tts-1-hd"; - voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer"; + model?: "tts-1" | "tts-1-hd" | (string & {}); + voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" | (string & {}); responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm"; } diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index 428ecd27aa..c9c21b3e89 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -36,8 +36,7 @@ vi.mock("./providers/openai/runtime.js", () => ({ vi.mock("./providers/local/models.js", () => ({ ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - getLocalSpeechModelDir: vi.fn(() => ""), - listLocalSpeechModels: vi.fn(() => []), + listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), })); function createStubStt(id: string): SpeechToTextProvider { diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 6e56a9e955..932f87c077 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -1,14 +1,8 @@ -import { stat } from "node:fs/promises"; -import { join } from "node:path"; import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, -} from "./providers/local/models.js"; +import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -76,48 +70,6 @@ function resolveRequestedSpeechProviders( }; } -async function hasRequiredLocalModelFile(filePath: string): Promise<boolean> { - try { - const fileStat = await stat(filePath); - if (fileStat.isDirectory()) { - return true; - } - return fileStat.isFile() && fileStat.size > 0; - } catch { - return false; - } -} - -async function findMissingRequiredLocalModels(params: { - modelsDir: string | null; - requiredModelIds: LocalSpeechModelId[]; -}): Promise<LocalSpeechModelId[]> { - const { modelsDir, requiredModelIds } = params; - if (!modelsDir || requiredModelIds.length === 0) { - return []; - } - - const specsById = new Map(listLocalSpeechModels().map((model) => [model.id, model])); - const missing = new Set<LocalSpeechModelId>(); - - const checks = await Promise.all( - requiredModelIds.map(async (modelId) => { - const spec = specsById.get(modelId); - if (!spec) return { modelId, missing: true }; - const modelDir = getLocalSpeechModelDir(modelsDir, modelId); - const filePresence = await Promise.all( - spec.requiredFiles.map((relPath) => hasRequiredLocalModelFile(join(modelDir, relPath))), - ); - return { modelId, missing: !filePresence.every((present) => present) }; - }), - ); - for (const check of checks) { - if (check.missing) missing.add(check.modelId); - } - - return Array.from(missing); -} - function joinModelIds(modelIds: LocalSpeechModelId[]): string { if (modelIds.length === 0) { return "none"; @@ -493,10 +445,12 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise<void> => { - missingLocalModelIds = await findMissingRequiredLocalModels({ - modelsDir: localModelConfig?.modelsDir ?? null, - requiredModelIds: localModelConfig?.defaultModelIds ?? [], - }); + missingLocalModelIds = localModelConfig + ? await listMissingLocalSpeechModels({ + modelsDir: localModelConfig.modelsDir, + modelIds: localModelConfig.defaultModelIds, + }) + : []; }; const reconcileServices = async (): Promise<void> => { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 2a437f9bbf..1c7127d144 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -58,14 +58,18 @@ interface FakeAgentSessionOptions { sessionId?: string; memoryMarker?: string | null; closeSession?: () => Promise<void>; - onStartTurn?: (prompt: AgentPromptInput) => void; + onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; + beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>; + historyEvents?: readonly AgentStreamEvent[]; } export interface TestAgentClientOptions { - beforeCreateSession?: () => Promise<void>; + beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>; closeSession?: () => Promise<void>; - onStartTurn?: (prompt: AgentPromptInput) => void; + onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; + beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>; supportsMcpServers?: boolean; + historyEvents?: readonly AgentStreamEvent[]; } function createDeferred<T>(): Deferred<T> { @@ -337,7 +341,9 @@ class FakeAgentSession implements AgentSession { private activeForegroundTurnId: string | null = null; private readonly closeSession: (() => Promise<void>) | undefined; - private readonly onStartTurn: ((prompt: AgentPromptInput) => void) | undefined; + private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; + private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined; + private readonly historyEvents: readonly AgentStreamEvent[] | undefined; constructor(options: FakeAgentSessionOptions) { this.capabilities = { @@ -350,6 +356,8 @@ class FakeAgentSession implements AgentSession { this.memoryMarker = options.memoryMarker ?? null; this.closeSession = options.closeSession; this.onStartTurn = options.onStartTurn; + this.beforeTurnComplete = options.beforeTurnComplete; + this.historyEvents = options.historyEvents; this.historyPath = path.join( tmpdir(), "paseo-fake-provider-history", @@ -440,7 +448,7 @@ class FakeAgentSession implements AgentSession { const turnId = `fake-turn-${this.nextTurnOrdinal++}`; this.activeForegroundTurnId = turnId; - this.onStartTurn?.(prompt); + this.onStartTurn?.(prompt, this.config); void this.emitTurnEvents(prompt); @@ -799,6 +807,7 @@ class FakeAgentSession implements AgentSession { this.notifySubscribers(assistantChunkB); } + await this.beforeTurnComplete?.(prompt); const completed: AgentStreamEvent = { type: "turn_completed", provider: this.providerName, @@ -812,6 +821,10 @@ class FakeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator<AgentStreamEvent> { + if (this.historyEvents) { + yield* this.historyEvents; + return; + } let contents: string; try { contents = await readFile(this.historyPath, "utf8"); @@ -1213,13 +1226,15 @@ class FakeAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise<AgentSession> { - await this.options.beforeCreateSession?.(); + await this.options.beforeCreateSession?.(config); return new FakeAgentSession({ providerName: this.provider, config: { ...config }, supportsMcpServers: this.options.supportsMcpServers, closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, + beforeTurnComplete: this.options.beforeTurnComplete, + historyEvents: this.options.historyEvents, }); } @@ -1245,6 +1260,8 @@ class FakeAgentClient implements AgentClient { memoryMarker: typeof marker === "string" ? marker : null, closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, + beforeTurnComplete: this.options.beforeTurnComplete, + historyEvents: this.options.historyEvents, }); } diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 1c98686042..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..fda98d6cbf 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -190,6 +190,113 @@ index 1234567..abcdefg 100644 `; describe("parseDiff", () => { + it.each(["-- comment", "++ marker"])( + "preserves hunk content resembling patch headers: %s", + (content) => { + const files = parseDiff(`diff --git a/example.txt b/example.txt +index 1234567..abcdefg 100644 +--- a/example.txt ++++ b/example.txt +@@ -1 +1 @@ +-${content} before ++${content} after +`); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + path: "example.txt", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + }); + expect(files[0].hunks[0].lines).toEqual([ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: `${content} before` }, + { type: "add", content: `${content} after` }, + ]); + }, + ); + + it("decodes quoted Git patch paths before matching files", () => { + const files = parseDiff( + [ + 'diff --git "a/report \\"draft\\".txt" "b/report \\"draft\\".txt"', + "index 1234567..abcdefg 100644", + '--- "a/report \\"draft\\".txt"', + '+++ "b/report \\"draft\\".txt"', + "@@ -1 +1 @@", + "-before", + "+after", + "", + ].join("\n"), + ); + + expect(files).toEqual([ + { + path: 'report "draft".txt', + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], + }, + ]); + }); + + it.each([ + ["tab\\tname.txt", "tab\tname.txt"], + ["line\\nname.txt", "line\nname.txt"], + ["back\\\\slash.txt", "back\\slash.txt"], + ["bell\\aname.txt", "bell\u0007name.txt"], + ["vertical\\vname.txt", "vertical\u000bname.txt"], + ["\\346\\265\\213\\350\\257\\225.txt", "测试.txt"], + ['测试 \\"draft\\".txt', '测试 "draft".txt'], + ])("decodes C-quoted path %s without changing its diff lines", (encoded, path) => { + const files = parseDiff( + [ + 'diff --git "a/' + encoded + '" "b/' + encoded + '"', + "index 1234567..abcdefg 100644", + '--- "a/' + encoded + '"', + '+++ "b/' + encoded + '"', + "@@ -1 +1 @@", + "-before", + "+after", + "", + ].join("\n"), + ); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path, additions: 1, deletions: 1 }); + expect(files[0].hunks[0].lines).toEqual([ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ]); + }); + + it.each([ + ['"a/old \\"draft\\".txt" b/new with spaces.txt', "new with spaces.txt"], + ['a/old with spaces.txt "b/new \\"draft\\".txt"', 'new "draft".txt'], + ['"report \\"draft\\".txt" "report \\"draft\\".txt"', 'report "draft".txt'], + ])("reads quoted paths from header-only patches: %s", (header, path) => { + expect(parseDiff("diff --git " + header + "\nsimilarity index 100%\n")).toEqual([ + { path, isNew: false, isDeleted: false, additions: 0, deletions: 0, hunks: [] }, + ]); + }); + it("parses a simple diff with one hunk", () => { const files = parseDiff(SIMPLE_DIFF); @@ -597,6 +704,28 @@ describe("highlightDiffFromHunks", () => { }); describe("highlightDiffWithFileContent", () => { + it("renders an Astro diff with an empty expression and keeps surrounding highlighting", async () => { + const diff = ASTRO_DIFF.replace("{title}", "{title} {}"); + const file = parseDiff(diff)[0]; + const highlighted = await highlightDiffWithFileContent(file, ".", { + oldFileContent: + '---\nconst title = "Hello";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>', + newFileContent: + '---\nconst title = "Hello, Astro";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>', + }); + + expect(highlighted.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({ + text: '"Hello, Astro"', + style: "string", + }); + expect(highlighted.hunks[0].lines.map(({ content }) => content)).toEqual( + file.hunks[0].lines.map(({ content }) => content), + ); + expect( + highlightDiffFromHunks(file).hunks[0].lines.find((line) => line.type === "add")?.tokens, + ).toContainEqual({ text: '"Hello, Astro"', style: "string" }); + }); + it("leaves files with an oversized line unhighlighted", async () => { const oversizedLine = `const payload = "${"x".repeat(10_001)}";`; const [file] = parseDiff( diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index daa1ebbb3d..3c2df279e2 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight"; +import { unquoteGitPath } from "../../utils/git-quoted-path.js"; const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000; @@ -53,7 +54,7 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri return null; } - const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd(); + const path = unquoteGitPath(line.slice(prefix.length).replace(/\t.*$/, "")); return path === "/dev/null" ? null : path; } @@ -64,6 +65,17 @@ function extractPathFromDiffHeader(lines: string[]): string { return prefixedPathMatch[2]; } + const quotedPathMatch = + firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/); + if (quotedPathMatch) { + const oldPath = unquoteGitPath(quotedPathMatch[1]); + const newPath = unquoteGitPath(quotedPathMatch[2]); + if (usesDiffPathPrefixes(oldPath, newPath)) { + return newPath.slice(2); + } + return newPath; + } + const metadataPath = extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- "); if (metadataPath) { @@ -79,16 +91,6 @@ function extractPathFromDiffHeader(lines: string[]): string { return "unknown"; } -function isMetadataLine(line: string): boolean { - return ( - line.startsWith("index ") || - line.startsWith("--- ") || - line.startsWith("+++ ") || - line.startsWith("new file mode") || - line.startsWith("deleted file mode") - ); -} - function parseHunkHeader(line: string): DiffHunk | null { const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); if (!hunkMatch) return null; @@ -116,8 +118,6 @@ function parseSectionBody(lines: string[]): ParsedSectionBody { for (let i = 1; i < lines.length; i++) { const line = lines[i]; - if (isMetadataLine(line)) continue; - const newHunk = parseHunkHeader(line); if (newHunk) { if (currentHunk) hunks.push(currentHunk); diff --git a/packages/server/src/server/web-ui.ts b/packages/server/src/server/web-ui.ts index e7135a9eab..b59896c159 100644 --- a/packages/server/src/server/web-ui.ts +++ b/packages/server/src/server/web-ui.ts @@ -250,6 +250,22 @@ function serializeInlineScriptJson(value: unknown): string { .replace(/&/g, "\\u0026"); } +/** + * A Host header carries no port when the client used the scheme's default one, + * which is what every browser sends to a proxy terminating TLS on 443. The app + * parses `listen` with parseHostPort, which requires an explicit port and + * rejects the value outright, so the hint has to spell the port out. + */ +function hasExplicitPort(host: string): boolean { + if (host.startsWith("[")) { + // IPv6 literals are bracketed: [::1] has no port, [::1]:443 does. + const end = host.indexOf("]"); + return end !== -1 && /^:\d{1,5}$/.test(host.slice(end + 1)); + } + const colon = host.indexOf(":"); + return colon !== -1 && colon === host.lastIndexOf(":") && /^\d{1,5}$/.test(host.slice(colon + 1)); +} + function injectConnectionHint( html: string, req: Parameters<RequestHandler>[0], @@ -257,8 +273,16 @@ function injectConnectionHint( ): string { const host = typeof req.headers.host === "string" ? req.headers.host : ""; const useTls = req.protocol === "https"; + let listen = host; + if (host && !hasExplicitPort(host)) { + let defaultPort = 80; + if (useTls) { + defaultPort = 443; + } + listen = `${host}:${defaultPort}`; + } const hint = { - listen: host, + listen, useTls, label, }; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4dcdc680ab..5e93681a66 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -33,6 +33,7 @@ import { } from "./messages.js"; import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; +import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import type { HostnamesConfig } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js"; import { @@ -246,6 +247,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], + invalidateWorktrees: () => {}, getProjectSlug: async (cwd: string) => { const snapshot = createFallbackWorkspaceGitSnapshot(cwd); return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); @@ -821,6 +823,7 @@ export class VoiceAssistantWebSocketServer { const wss = new WebSocketServer({ server, path: "/ws", + maxPayload: MAX_WEBSOCKET_MESSAGE_BYTES, handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password), verifyClient: ({ req }, callback) => { this.verifyWsUpgrade( @@ -1793,6 +1796,7 @@ export class VoiceAssistantWebSocketServer { workspaceRequestReceipts: true, creationLifecycle: true, hubAgentRpc: true, + backgroundWorkspaces: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. @@ -1923,6 +1927,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/workspace-service-port-registry.test.ts b/packages/server/src/server/workspace-service-port-registry.test.ts index 3bf455ba57..b61bbb0f1d 100644 --- a/packages/server/src/server/workspace-service-port-registry.test.ts +++ b/packages/server/src/server/workspace-service-port-registry.test.ts @@ -33,7 +33,7 @@ describe("ensureWorkspaceServicePortPlan", () => { expect(allocatedServices).toEqual(["api", "web", "worker"]); }); - it("returns the existing plan without calling the allocator on later calls", async () => { + it("extends the existing plan with newly declared services without reallocating peers", async () => { let allocationCount = 0; const firstPlan = await ensureWorkspaceServicePortPlan({ @@ -54,9 +54,11 @@ describe("ensureWorkspaceServicePortPlan", () => { }, }); - expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries())); - expect(secondPlan.has("new-service")).toBe(false); - expect(allocationCount).toBe(2); + expect(Array.from(secondPlan.entries())).toEqual([ + ...firstPlan.entries(), + ["new-service", 4300], + ]); + expect(allocationCount).toBe(3); }); it("shares one first-plan build across concurrent callers", async () => { @@ -101,6 +103,103 @@ describe("ensureWorkspaceServicePortPlan", () => { expect(allocationCount).toBe(2); }); + it("extends a pending plan for a concurrent caller with another service", async () => { + const allocation = createDeferredPort(); + const workspaceId = "registry-concurrent-extension-workspace"; + const firstPlan = ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => allocation.promise, + }); + let allocations = 0; + const secondPlan = ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web" }], + allocatePort: async () => { + allocations += 1; + return 6101; + }, + }); + allocation.resolve(6100); + expect(Array.from((await firstPlan).entries())).toEqual([["backend-dev", 6100]]); + expect(Array.from((await secondPlan).entries())).toEqual([ + ["backend-dev", 6100], + ["web", 6101], + ]); + expect(allocations).toBe(1); + releaseWorkspaceServicePortPlan(workspaceId); + }); + + it("keeps the previous plan and reservations when an extension fails", async () => { + const workspaceId = "registry-failed-extension-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => 6200, + }); + let allocations = 0; + await expect( + ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web" }, { scriptName: "worker" }], + allocatePort: async () => { + allocations += 1; + if (allocations === 1) return 6201; + throw new Error("Allocation failed"); + }, + }), + ).rejects.toThrow("Allocation failed"); + const priorPlan = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => { + throw new Error("Existing port must survive"); + }, + }); + expect(Array.from(priorPlan.entries())).toEqual([["backend-dev", 6200]]); + const otherWorkspace = "registry-failed-extension-other-workspace"; + const reusedPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: otherWorkspace, + services: [{ scriptName: "web" }], + allocatePort: async ({ reservedPorts }) => { + expect(reservedPorts.has(6200)).toBe(true); + expect(reservedPorts.has(6201)).toBe(false); + return 6201; + }, + }); + expect(reusedPlan.get("web")).toBe(6201); + releaseWorkspaceServicePortPlan(workspaceId); + releaseWorkspaceServicePortPlan(otherWorkspace); + }); + + it("rejects a new explicit port that collides with an existing service", async () => { + const workspaceId = "registry-extension-explicit-collision-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => 6300, + }); + await expect( + ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6300 }], + allocatePort: async () => 6301, + }), + ).rejects.toThrow("Service 'web' has a duplicate port 6300"); + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6301 }], + allocatePort: async () => { + throw new Error("No allocation needed"); + }, + }); + expect(Array.from(plan.entries())).toEqual([ + ["backend-dev", 6300], + ["web", 6301], + ]); + releaseWorkspaceServicePortPlan(workspaceId); + }); + it("uses explicit configured ports without calling the allocator", async () => { let allocationCount = 0; @@ -312,6 +411,123 @@ describe("refreshWorkspaceServicePort", () => { ]); }); + it("preserves a concurrent service addition while refreshing a stopped service", async () => { + const workspaceId = "registry-refresh-and-extend-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }], + allocatePort: async () => 6400, + }); + const allocation = createDeferredPort(); + const refreshing = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "backend-dev" }, + allocatePort: async () => allocation.promise, + }); + const extending = ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6402 }], + allocatePort: async () => { + throw new Error("Explicit addition needs no allocation"); + }, + }); + allocation.resolve(6401); + expect(await refreshing).toBe(6401); + await extending; + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [], + allocatePort: async () => { + throw new Error("No allocation needed"); + }, + }); + expect(Array.from(plan.entries())).toEqual([ + ["backend-dev", 6401], + ["web", 6402], + ]); + releaseWorkspaceServicePortPlan(workspaceId); + }); + + it.each(["reused port", "allocation failure"])( + "cancels a pending refresh and its waiter after workspace release (%s)", + async (outcome) => { + const workspaceId = "registry-cancelled-refresh-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "api" }], + allocatePort: async () => 6500, + }); + const allocation = createDeferredPort(); + const refreshing = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "api" }, + allocatePort: async () => { + const port = await allocation.promise; + if (outcome === "allocation failure") throw new Error("Allocation failed"); + return port; + }, + }); + const waiting = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "web", port: 6501 }, + allocatePort: async () => 6501, + }); + const assertions = Promise.all([ + expect(refreshing).rejects.toThrow("Workspace service port plan was released"), + expect(waiting).rejects.toThrow("Workspace service port plan was released"), + ]); + releaseWorkspaceServicePortPlan(workspaceId); + allocation.resolve(6500); + await assertions; + const reusedPlan = await ensureWorkspaceServicePortPlan({ + workspaceId: "registry-after-cancelled-refresh-workspace", + services: [{ scriptName: "api" }], + allocatePort: async () => 6500, + }); + expect(reusedPlan.get("api")).toBe(6500); + releaseWorkspaceServicePortPlan("registry-after-cancelled-refresh-workspace"); + }, + ); + + it("lets a waiting service restart allocate after another service's restart fails", async () => { + const workspaceId = "registry-independent-refresh-failure-workspace"; + await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [{ scriptName: "api" }, { scriptName: "web" }], + allocatePort: createSequentialPortAllocator(6600), + }); + const allocation = createDeferredPort(); + const failedRefresh = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "api" }, + allocatePort: async () => { + await allocation.promise; + throw new Error("API port script failed"); + }, + }); + const failedAssertion = expect(failedRefresh).rejects.toThrow("API port script failed"); + const waitingRefresh = refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName: "web" }, + allocatePort: async () => 6602, + }); + allocation.resolve(0); + await failedAssertion; + expect(await waitingRefresh).toBe(6602); + const plan = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: [], + allocatePort: async () => { + throw new Error("No allocation needed"); + }, + }); + expect(Array.from(plan.entries())).toEqual([ + ["api", 6600], + ["web", 6602], + ]); + releaseWorkspaceServicePortPlan(workspaceId); + }); + it("uses an explicit configured port without calling the allocator", async () => { let allocationCount = 0; diff --git a/packages/server/src/server/workspace-service-port-registry.ts b/packages/server/src/server/workspace-service-port-registry.ts index be41a8d123..ea6cebfa35 100644 --- a/packages/server/src/server/workspace-service-port-registry.ts +++ b/packages/server/src/server/workspace-service-port-registry.ts @@ -24,6 +24,24 @@ interface PendingWorkspaceServicePortPlanToken { isReleased: boolean; } +class WorkspaceServicePortPlanReleasedError extends Error { + constructor(workspaceId: string) { + super(`Workspace service port plan was released while being created for '${workspaceId}'`); + } +} + +async function waitForWorkspaceServicePortPlanUpdate( + pendingPlan: Promise<Map<string, number>>, +): Promise<void> { + try { + await pendingPlan; + } catch (error) { + // Allocation failure belongs to the original request. A waiting request + // retries against the rolled-back plan; workspace archival cancels both. + if (error instanceof WorkspaceServicePortPlanReleasedError) throw error; + } +} + const workspaceServicePortPlans = new Map<string, Map<string, number>>(); const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>(); const pendingWorkspaceServicePortPlanTokens = new Map< @@ -37,25 +55,19 @@ const MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS = 10; export async function ensureWorkspaceServicePortPlan( options: EnsureWorkspaceServicePortPlanOptions, ): Promise<ReadonlyMap<string, number>> { - const existingPlan = workspaceServicePortPlans.get(options.workspaceId); - if (existingPlan) { + const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId); + if (pendingPlan) { + await waitForWorkspaceServicePortPlanUpdate(pendingPlan); + return ensureWorkspaceServicePortPlan(options); + } + + const existingPlan = + workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(); + if (options.services.every((service) => existingPlan.has(service.scriptName))) { return new Map(existingPlan); } - let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId); - if (!pendingPlan) { - const token: PendingWorkspaceServicePortPlanToken = { isReleased: false }; - pendingPlan = createPendingWorkspaceServicePortPlan({ - workspaceId: options.workspaceId, - services: options.services, - allocatePort: options.allocatePort, - token, - }); - pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan); - pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token); - } - - return new Map(await pendingPlan); + return new Map(await startWorkspaceServicePortPlanUpdate({ ...options, existingPlan })); } export function requirePlannedWorkspaceServicePort( @@ -82,27 +94,62 @@ export function releaseWorkspaceServicePortPlan(workspaceId: string): void { dynamicPortsByWorkspace.delete(workspaceId); } -async function createPendingWorkspaceServicePortPlan(options: { - workspaceId: string; - services: readonly WorkspaceServicePortDeclaration[]; - allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>; - token: PendingWorkspaceServicePortPlanToken; -}): Promise<Map<string, number>> { +interface WorkspaceServicePortPlanUpdateOptions extends EnsureWorkspaceServicePortPlanOptions { + existingPlan: ReadonlyMap<string, number>; + refreshScriptName?: string; +} + +function startWorkspaceServicePortPlanUpdate( + options: WorkspaceServicePortPlanUpdateOptions, +): Promise<Map<string, number>> { + const token: PendingWorkspaceServicePortPlanToken = { isReleased: false }; + const plan = createPendingWorkspaceServicePortPlan({ ...options, token }); + pendingWorkspaceServicePortPlans.set(options.workspaceId, plan); + pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token); + return plan; +} + +async function createPendingWorkspaceServicePortPlan( + options: WorkspaceServicePortPlanUpdateOptions & { token: PendingWorkspaceServicePortPlanToken }, +): Promise<Map<string, number>> { + const previousDynamicPorts = new Map(dynamicPortsByWorkspace.get(options.workspaceId)); + const retainedPlan = new Map(options.existingPlan); + if (options.refreshScriptName !== undefined) retainedPlan.delete(options.refreshScriptName); try { - const plan = await buildWorkspaceServicePortPlan({ + const updatedPlan = await buildWorkspaceServicePortPlan({ workspaceId: options.workspaceId, services: options.services, allocatePort: options.allocatePort, + existingPlan: retainedPlan, }); + const plan = new Map(options.existingPlan); + for (const [scriptName, port] of updatedPlan) plan.set(scriptName, port); if (options.token.isReleased) { - throw new Error( - `Workspace service port plan was released while being created for '${options.workspaceId}'`, - ); + throw new WorkspaceServicePortPlanReleasedError(options.workspaceId); + } + for (const [scriptName, port] of previousDynamicPorts) { + if (plan.get(scriptName) !== port) { + releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port }); + } } workspaceServicePortPlans.set(options.workspaceId, plan); return plan; } catch (error) { - releaseWorkspaceServicePortPlan(options.workspaceId); + const dynamicPorts = dynamicPortsByWorkspace.get(options.workspaceId); + if (dynamicPorts) { + for (const [scriptName, port] of dynamicPorts) { + if (options.token.isReleased || previousDynamicPorts.get(scriptName) !== port) { + releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port }); + } + } + } + if (!options.token.isReleased) { + for (const [scriptName, port] of previousDynamicPorts) { + reserveDynamicPort({ workspaceId: options.workspaceId, scriptName, port }); + } + } + if (options.token.isReleased) + throw new WorkspaceServicePortPlanReleasedError(options.workspaceId); throw error; } finally { pendingWorkspaceServicePortPlans.delete(options.workspaceId); @@ -111,12 +158,16 @@ async function createPendingWorkspaceServicePortPlan(options: { } async function buildWorkspaceServicePortPlan(options: { + existingPlan: ReadonlyMap<string, number>; workspaceId: string; services: readonly WorkspaceServicePortDeclaration[]; allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>; }): Promise<Map<string, number>> { - const explicitPortOwners = new Map<number, string>(); + const explicitPortOwners = new Map<number, string>( + Array.from(options.existingPlan, ([scriptName, port]) => [port, scriptName]), + ); for (const service of options.services) { + if (options.existingPlan.has(service.scriptName)) continue; if (service.port === undefined) continue; if (explicitPortOwners.has(service.port)) { throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`); @@ -124,8 +175,9 @@ async function buildWorkspaceServicePortPlan(options: { explicitPortOwners.set(service.port, service.scriptName); } - const plan = new Map<string, number>(); + const plan = new Map(options.existingPlan); for (const service of options.services) { + if (plan.has(service.scriptName)) continue; if (service.port !== undefined) { plan.set(service.scriptName, service.port); continue; @@ -148,32 +200,20 @@ async function buildWorkspaceServicePortPlan(options: { export async function refreshWorkspaceServicePort( options: RefreshWorkspaceServicePortOptions, ): Promise<number> { - const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(); + const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId); + if (pendingPlan) { + await waitForWorkspaceServicePortPlanUpdate(pendingPlan); + return refreshWorkspaceServicePort(options); + } - const reservedPorts = new Set(plan.values()); - const previousPort = plan.get(options.service.scriptName); - if (previousPort !== undefined) { - reservedPorts.delete(previousPort); - } - const oldDynamicPort = dynamicPortsByWorkspace - .get(options.workspaceId) - ?.get(options.service.scriptName); - const port = await resolveServicePort({ - service: options.service, + const plan = await startWorkspaceServicePortPlanUpdate({ workspaceId: options.workspaceId, + services: [options.service], allocatePort: options.allocatePort, - reservedPorts, + existingPlan: workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(), + refreshScriptName: options.service.scriptName, }); - if (oldDynamicPort !== undefined && oldDynamicPort !== port) { - releaseDynamicPort({ - workspaceId: options.workspaceId, - scriptName: options.service.scriptName, - port: oldDynamicPort, - }); - } - plan.set(options.service.scriptName, port); - workspaceServicePortPlans.set(options.workspaceId, plan); - return port; + return requirePlannedWorkspaceServicePort(plan, options.service.scriptName); } async function resolveServicePort(options: { diff --git a/packages/server/src/server/worktree-bootstrap.posix.test.ts b/packages/server/src/server/worktree-bootstrap.posix.test.ts index a0a796c83d..41de520d09 100644 --- a/packages/server/src/server/worktree-bootstrap.posix.test.ts +++ b/packages/server/src/server/worktree-bootstrap.posix.test.ts @@ -422,12 +422,12 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { api: { type: "service", command: - "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('api-env.json.tmp', 'api-env.json'); setTimeout(()=>{}, 30000)\"", }, web: { type: "service", command: - "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('web-env.json.tmp', 'web-env.json'); setTimeout(()=>{}, 30000)\"", }, }, }), diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index c878ef1387..5e5ac1762e 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -837,6 +837,54 @@ describe("runAsyncWorktreeBootstrap", () => { }); }); + it("starts a service added to paseo.json without restarting the daemon", async () => { + const backend = { + type: "service" as const, + command: "uv run uvicorn app.main:app --reload --port $PASEO_PORT", + }; + commitPaseoScripts({ "backend-dev": backend }); + + const serviceProxy = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const options = { + repoRoot: repoDir, + workspaceId: repoDir, + projectSlug: "repo", + branchName: "feature-added-service", + daemonPort: 6767, + serviceProxy, + runtimeStore, + terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), + }; + const backendResult = await spawnWorkspaceScript({ ...options, scriptName: "backend-dev" }); + + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + scripts: { + "backend-dev": backend, + web: { type: "service", command: "pnpm run dev -- --port $PASEO_PORT" }, + }, + }), + ); + const webResult = await spawnWorkspaceScript({ ...options, scriptName: "web" }); + + expect(webResult.port).toEqual(expect.any(Number)); + expect(webResult.port).not.toBe(backendResult.port); + expect(terminalRecords[1]?.sentInputs).toEqual(["pnpm run dev -- --port $PASEO_PORT\r"]); + expect(createTerminalCalls[1]?.env?.PASEO_PORT).toBe(String(webResult.port)); + expect(createTerminalCalls[1]?.env?.PASEO_SERVICE_BACKEND_DEV_PORT).toBe( + String(backendResult.port), + ); + expect(serviceProxy.listRoutes()).toHaveLength(2); + expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "backend-dev" })).toMatchObject({ + lifecycle: "running", + terminalId: backendResult.terminalId, + }); + }); + it("spawns services with public aliases and public service URLs", async () => { commitPaseoScripts( { diff --git a/packages/server/src/server/worktree-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..751fb1b33a 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { buildTerminalEnvironment, + assertTerminalCommandCanStart, createTerminal, ensureNodePtySpawnHelperExecutableForCurrentPlatform, resolveDefaultTerminalShell, @@ -24,7 +25,9 @@ import { } from "node:fs"; import { spawnSync } from "node:child_process"; import * as pty from "node-pty"; -import { join } from "node:path"; +import xterm from "@xterm/headless"; +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; +import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; import { stripVTControlCharacters } from "node:util"; @@ -50,6 +53,10 @@ function getLines(state: ReturnType<TerminalSession["getState"]>): string[] { return state.grid.map(rowToText); } +function rowHasChar(row: TerminalRow, char: string): boolean { + return row.some((cell) => cell.char === char); +} + // Wait for terminal state to match expected lines async function waitForLines( session: TerminalSession, @@ -523,6 +530,44 @@ describe("createTerminal", () => { expect(withoutFlags.scrollbackWrapped).toBeUndefined(); }); + it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => { + // Each Hangul syllable is a wide char: xterm stores it as a 2-cell pair + // whose second cell has no chars. Capturing that continuation as " " made + // every restored syllable three columns wide (paseo#3434). + const text = "통합 구현은 전체 테스트를 통과했습니다"; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 80, + rows: 10, + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`, + ], + }), + ); + + const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "다")); + + // Replay the snapshot the way the client does on tab switch and read the + // row back the way a selection copy would. + const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true }); + try { + await new Promise<void>((resolve) => + restored.write(renderTerminalSnapshotToAnsi(state), resolve), + ); + expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text); + } finally { + restored.dispose(); + } + + // The continuation cell must stay empty so the restore path can skip it. + expect(state.grid[0][0].char).toBe("통"); + expect(state.grid[0][1].char).toBe(""); + }); + it("captures exit diagnostics from the terminal buffer", async () => { const session = trackSession( await createTerminal({ @@ -593,6 +638,151 @@ describe("createTerminal", () => { expect(exitInfo.lastOutputLines.length).toBeGreaterThan(0); expect(exitInfo.lastOutputLines[exitInfo.lastOutputLines.length - 1]).toBe("line-2999"); }); + + it.runIf(isPlatform("linux", "darwin"))( + "rejects a profile command that is not installed instead of opening a terminal", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Codex", + command: "paseo-qa-absent-codex", + args: [], + }), + ).rejects.toThrow("paseo-qa-absent-codex: command not found"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "opens a command found only on the PATH carried by the env option", + async () => { + // execve resolves the command against the child's PATH, which carries the + // per-cwd registered environment. A version-manager shim lives there and + // not necessarily on the daemon's own PATH. + const binDir = mkdtempSync(join(tmpdir(), "terminal-envpath-bin-")); + temporaryDirs.push(binDir); + const toolPath = join(binDir, "paseo-qa-envpath-tool"); + writeFileSync(toolPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(toolPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Shim", + command: "paseo-qa-envpath-tool", + args: [], + env: { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` }, + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "reports command not found when the command is on neither PATH", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Codex", + command: "paseo-qa-absent-codex", + args: [], + env: { PATH: `/paseo-qa-empty${delimiter}${process.env.PATH ?? ""}` }, + }), + ).rejects.toThrow("paseo-qa-absent-codex: command not found"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "opens a relative command path resolved against the terminal cwd", + async () => { + // execve resolves a path-ish command against the child's cwd, never the + // daemon's own working directory. + const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-cmd-")); + temporaryDirs.push(projectDir); + mkdirSync(join(projectDir, "scripts")); + const scriptPath = join(projectDir, "scripts", "dev.sh"); + writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(projectDir), + name: "Dev script", + command: "./scripts/dev.sh", + args: [], + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))("opens an absolute command path", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "terminal-absolute-cmd-")); + temporaryDirs.push(projectDir); + const scriptPath = join(projectDir, "run.sh"); + writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Absolute", + command: scriptPath, + args: [], + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }); + + it("leaves the command check to the terminal itself on Windows", async () => { + // Windows resolves inside resolveTerminalSpawnCommand, which has its own + // shim handling and its own fallback for an unresolved command. + await expect( + assertTerminalCommandCanStart({ + command: "paseo-qa-absent-codex", + cwd: realpathSync(tmpdir()), + env: { PATH: "/paseo-qa-empty" }, + platform: "win32", + }), + ).resolves.toBeUndefined(); + }); + + it.runIf(isPlatform("linux", "darwin"))( + "still opens a terminal for an installed command that exits non-zero", + async () => { + // A profile running a failing test suite must keep opening a tab with its + // output. Only a command that cannot start at all is reported as an error, + // and "exited non-zero" does not distinguish the two: a missing binary and + // `false` both surface as exit code 1 from node-pty. + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Failing suite", + command: "false", + args: [], + }), + ); + + const exitInfo = await new Promise<{ exitCode: number | null }>((resolve) => { + session.onExit((info) => resolve({ exitCode: info.exitCode })); + }); + + expect(exitInfo).toEqual({ exitCode: 1 }); + }, + ); }); describe.skipIf(isPlatform("win32"))("send input", () => { @@ -1344,3 +1534,43 @@ describe.runIf(isPlatform("win32"))(".cmd shim argv round-trip on Windows", () = expect(await roundTrip("first\nsecond")).toEqual(["first second"]); }); }); + +it.runIf(isPlatform("linux", "darwin"))( + "opens a profile command from a PATH entry relative to the terminal cwd", + async () => { + const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-path-")); + mkdirSync(join(projectDir, "bin")); + const script = join(projectDir, "bin", "paseo-relative-path-tool"); + writeFileSync(script, "#!/bin/sh\nprintf 'paseo-launch-ok\\n'\nexec /bin/sleep 30\n"); + chmodSync(script, 0o755); + let session: TerminalSession | undefined; + try { + session = await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(projectDir), + command: "paseo-relative-path-tool", + env: { PATH: "bin" }, + }); + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(session.cwd).toBe(realpathSync(projectDir)); + expect(session.getExitInfo()).toBeNull(); + } finally { + await session?.killAndWait({ gracefulTimeoutMs: 1000, forceTimeoutMs: 500 }); + rmSync(projectDir, { recursive: true, force: true }); + } + }, +); + +it.runIf(isPlatform("linux", "darwin"))( + "checks the exact command rather than accepting a trimmed executable name", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + command: " node ", + }), + ).rejects.toThrow(" node : command not found"); + }, +); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b9d1b74017..24f243b6ea 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -1,14 +1,25 @@ import * as pty from "node-pty"; import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; -import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { + accessSync, + chmodSync, + constants, + existsSync, + mkdirSync, + readFileSync, + statSync, +} from "node:fs"; import { tmpdir, userInfo } from "node:os"; import { basename, delimiter, dirname, extname, join, resolve as resolvePath } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { writePrivateFileAtomicSync } from "../server/private-files.js"; -import { findExecutable } from "../executable-resolution/executable-resolution.js"; +import { + findExecutable, + findExecutableWithoutProbing, +} from "../executable-resolution/executable-resolution.js"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js"; @@ -33,6 +44,20 @@ export interface TerminalExitInfo { lastOutputLines: string[]; } +// The field is assigned explicitly rather than declared as a constructor +// parameter property: the terminal worker loads this module under +// --experimental-strip-types, which erases types but cannot emit the +// assignment a parameter property implies. +export class TerminalCommandNotFoundError extends Error { + readonly command: string; + + constructor(command: string) { + super(`${command}: command not found`); + this.name = "TerminalCommandNotFoundError"; + this.command = command; + } +} + export interface TerminalCommandFinishedInfo { exitCode: number | null; } @@ -580,7 +605,9 @@ function extractCell(terminal: TerminalType, row: number, col: number): Terminal const bg = bgMode !== 0 ? cell.getBgColor() : undefined; return { - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, @@ -639,7 +666,9 @@ function extractScrollback( const fg = fgMode !== 0 ? cell.getFgColor() : undefined; const bg = bgMode !== 0 ? cell.getBgColor() : undefined; rowCells.push({ - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, @@ -882,6 +911,62 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] { return lines.slice(-limit); } +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) { + return false; + } + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +export interface AssertTerminalCommandCanStartInput { + command: string | undefined; + // The terminal's own cwd and environment, not the daemon's: execve in the + // child resolves against those, so anything else produces false negatives. + cwd: string; + env: Record<string, string>; + platform?: NodeJS.Platform; +} + +// node-pty spawns directly through execve on POSIX, with no shell, so a command +// that does not exist fails inside the spawn helper and surfaces as exit code 1 +// with no output -- indistinguishable from `false` or any program that simply +// failed. Resolving it up front is the only signal that separates "cannot start" +// from "ran and exited non-zero", and it lets the create report the failure +// instead of handing back a terminal that dies immediately. Windows keeps +// resolving inside resolveTerminalSpawnCommand, which has its own shim handling +// and its own fallback for an unresolved command. +export async function assertTerminalCommandCanStart( + input: AssertTerminalCommandCanStartInput, +): Promise<void> { + const { command, cwd, env } = input; + const platform = input.platform ?? process.platform; + if (command === undefined || platform === "win32") { + return; + } + + // A path-ish command is resolved by execve against the child's cwd, never + // through PATH and never against the daemon's working directory. + if (command.includes("/")) { + if (isExecutableFile(resolvePath(cwd, command))) { + return; + } + throw new TerminalCommandNotFoundError(command); + } + + const resolved = await findExecutableWithoutProbing(command, { + path: env[getPathEnvKey(env)], + cwd, + }); + if (!resolved) { + throw new TerminalCommandNotFoundError(command); + } +} + export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> { const { cwd, @@ -941,19 +1026,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te const { command: spawnCommand, args: spawnArgs } = command ? await resolveTerminalSpawnCommand(command, args) : { command: resolvedShell, args: [] as string[] }; + const terminalEnv = buildTerminalEnvironment({ + shell: spawnCommand, + env: { + ...env, + ...activityEnv, + PASEO_WORKSPACE_ID: workspaceId, + }, + }); + + await assertTerminalCommandCanStart({ command, cwd, env: terminalEnv }); + const ptyProcess = pty.spawn(spawnCommand, spawnArgs, { name: "xterm-256color", cols, rows, cwd, - env: buildTerminalEnvironment({ - shell: spawnCommand, - env: { - ...env, - ...activityEnv, - PASEO_WORKSPACE_ID: workspaceId, - }, - }), + env: terminalEnv, }); function emitTitleChange(nextTitle: string | undefined): void { diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 2dc999c0c3..e11c9eca9f 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as session.send({ type: "input", data: `${command}\r` }); - await waitForCondition(() => existsSync(markerPath), 10000); - - expect(readFileSync(markerPath, "utf8")).toBe("shell-ok"); + await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok"); }); it("lists subdirectory terminals when querying the workspace root", async () => { @@ -970,3 +968,139 @@ it("removes a killed worker terminal from terminalExit without duplicate snapsho }, ]); }); + +it("does not resurrect a terminal that exited before its create response was handled", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) }); + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + + // The worker delivers all three messages in one tick, so the awaiting + // createTerminal continuation resumes only after the exit was processed. + const terminal = { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: null, + }; + const state = createTerminalState(); + const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] }; + worker.emitWorkerMessage({ type: "terminalCreated", terminal, state }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: { terminal, state }, + }); + worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo }); + + const session = await created; + + expect(manager.getTerminal("terminal-a")).toBeUndefined(); + expect(await manager.getTerminals("/workspace")).toEqual([]); + expect(session.getExitInfo()).toEqual(exitInfo); + expect(snapshots).toEqual([ + { cwd: "/workspace", terminalIds: ["terminal-a"] }, + { cwd: "/workspace", terminalIds: [] }, + ]); +}); + +it("replays the exit to a listener attached after a create that lost the race", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + + const terminal = { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: null, + }; + const state = createTerminalState(); + const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] }; + worker.emitWorkerMessage({ type: "terminalCreated", terminal, state }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: { terminal, state }, + }); + worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo }); + + const session = await created; + + // The session controller subscribes only after createTerminal resolves; that + // subscription is what turns into a terminal_exit for the client. + const observedExits: Array<{ exitCode: number | null }> = []; + session.onExit((info) => { + observedExits.push({ exitCode: info.exitCode }); + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(observedExits).toEqual([{ exitCode: 0 }]); +}); + +it("rejects createTerminal and registers nothing when the worker cannot start the command", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) }); + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + command: "paseo-qa-absent-codex", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: false, + error: "paseo-qa-absent-codex: command not found", + }); + + await expect(created).rejects.toThrow("paseo-qa-absent-codex: command not found"); + expect(manager.getTerminal("terminal-a")).toBeUndefined(); + expect(await manager.getTerminals("/workspace")).toEqual([]); + expect(snapshots).toEqual([]); +}); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 0f0243d7a8..ca84cfe622 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -156,6 +156,9 @@ export function createWorkerTerminalManager( const recordsById = new Map<string, WorkerTerminalRecord>(); const terminalIdsByCwd = new Map<string, Set<string>>(); const terminalActivityTokenById = new Map<string, string>(); + // Creation owns a reference to the session independently of the live-terminal + // index. Only terminalCreated registers it; terminalExit removes it permanently. + const pendingCreationsById = new Map<string, { session: TerminalSession | null }>(); const terminalsChangedListeners = new Set<TerminalsChangedListener>(); const terminalActivityListeners = new Set<TerminalActivityListener>(); const terminalWorkspaceContributionChangedListeners = @@ -542,10 +545,14 @@ export function createWorkerTerminalManager( function handleWorkerEvent(message: TerminalWorkerToParentMessage): void { switch (message.type) { case "terminalCreated": { - registerRecord({ + const session = registerRecord({ info: asRequiredWorkerTerminalInfo(message.terminal), state: message.state, }); + const pendingCreation = pendingCreationsById.get(message.terminal.id); + if (pendingCreation) { + pendingCreation.session = session; + } emitTerminalsChanged({ cwd: message.terminal.cwd, terminals: listTerminalItemsForCwd(message.terminal.cwd), @@ -686,12 +693,10 @@ export function createWorkerTerminalManager( const activityToken = createActivityToken(); const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null; terminalActivityTokenById.set(terminalId, activityToken); - let result: { - terminal: RequiredWorkerTerminalInfo; - state: TerminalState; - }; + const creation = { session: null as TerminalSession | null }; + pendingCreationsById.set(terminalId, creation); try { - result = (await sendRequest({ + await sendRequest({ type: "createTerminal", options: { ...options, @@ -699,16 +704,17 @@ export function createWorkerTerminalManager( activityToken, activityUrl: terminalActivityUrl, }, - })) as { - terminal: RequiredWorkerTerminalInfo; - state: TerminalState; - }; + }); + if (!creation.session) { + throw new Error("Terminal worker completed creation without a terminalCreated event"); + } + return creation.session; } catch (error) { terminalActivityTokenById.delete(terminalId); throw error; + } finally { + pendingCreationsById.delete(terminalId); } - const session = registerRecord({ info: result.terminal, state: result.state }); - return session; }, registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void { diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index 5a006ae0eb..d614e32f9b 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -25,7 +25,10 @@ function git(args: string[], cwd?: string): void { } function commit(repoDir: string, message: string): void { - git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir); + git( + ["-c", "commit.gpgsign=false", "commit", "--author=Test User <test@test.com>", "-m", message], + repoDir, + ); } function commitFile(repoDir: string, name: string, content: string, message: string): void { @@ -92,6 +95,30 @@ function addBareRemote(repoDir: string, tempDir: string): string { } describe("listCheckoutCommits", () => { + it("keeps fixture authors independent of command-scoped Git configuration", async () => { + const overrides = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "Other User", + }; + const previous = Object.fromEntries( + Object.keys(overrides).map((key) => [key, process.env[key]]), + ); + let repoDir: string; + try { + Object.assign(process.env, overrides); + ({ repoDir } = initRepoOnMain()); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + expect(commits[0]?.authorName).toBe("Test User"); + }); + it("lists recent commits newest-first with on-remote flags and file stats", async () => { const { repoDir, tempDir } = initRepoOnMain(); git(["checkout", "-b", "feature"], repoDir); diff --git a/packages/server/src/utils/checkout-git.patch-format.test.ts b/packages/server/src/utils/checkout-git.patch-format.test.ts new file mode 100644 index 0000000000..61e864a401 --- /dev/null +++ b/packages/server/src/utils/checkout-git.patch-format.test.ts @@ -0,0 +1,416 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getCheckoutDiff, + getCheckoutShortstat, + getCommitFileDiff, + listCheckoutCommits, +} from "./checkout-git.js"; + +const repos: string[] = []; + +const EDITED_TEXT_DIFF = { + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], +}; + +afterEach(() => { + vi.unstubAllEnvs(); + for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true }); +}); + +it.skipIf(process.platform === "win32")( + "returns a quoted filename and its uncommitted diff using its actual path", + async () => { + const cwd = createChangedRepo("core.quotepath=false"); + const name = 'report "draft".txt'; + writeFileSync(join(cwd, "file.txt"), "before\n"); + writeFileSync(join(cwd, name), "before\n"); + git(cwd, "add", "--", name); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add quoted filename"); + writeFileSync(join(cwd, name), "after\n"); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured).toHaveLength(1); + expect(result.structured?.[0]).toMatchObject({ + path: name, + status: "ok", + ...EDITED_TEXT_DIFF, + }); + }, +); + +describe.skipIf(process.platform === "win32")("quoted paths from real Git", () => { + const names = [ + "normal.txt", + "with spaces.txt", + 'report "draft".txt', + "back\\slash.txt", + "tab\tname.txt", + "line\nname.txt", + " leading and trailing .txt ", + 'report "old => draft".txt', + ]; + + function createQuotedPathsRepo(): string { + const cwd = createChangedRepo("core.quotepath=false"); + writeFileSync(join(cwd, "file.txt"), "before\n"); + for (const name of names) writeFileSync(join(cwd, name), "before\n"); + git(cwd, "add", "--", ...names); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add filenames"); + git(cwd, "checkout", "-b", "feature"); + for (const name of names) writeFileSync(join(cwd, name), "after\n"); + return cwd; + } + + it("preserves tracked and untracked paths, diff lines and summary counts", async () => { + const cwd = createQuotedPathsRepo(); + const untrackedNames = names.map((name) => "untracked-" + name); + for (const name of untrackedNames) writeFileSync(join(cwd, name), "new file\n"); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured?.map((file) => file.path).sort()).toEqual( + [...names, ...untrackedNames].sort(), + ); + for (const name of names) { + expect(result.structured?.find((file) => file.path === name)).toMatchObject({ + path: name, + status: "ok", + ...EDITED_TEXT_DIFF, + }); + } + for (const name of untrackedNames) { + expect(result.structured?.find((file) => file.path === name)).toMatchObject({ + path: name, + status: "ok", + isNew: true, + isDeleted: false, + additions: 1, + deletions: 0, + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -0,0 +1 @@" }, + { type: "add", content: "new file" }, + ], + }, + ], + }); + } + expect(await getCheckoutShortstat(cwd)).toEqual({ + additions: names.length + untrackedNames.length, + deletions: names.length, + }); + }); + + it("opens base and commit diffs using the actual paths from the commit list", async () => { + const cwd = createQuotedPathsRepo(); + git(cwd, "add", "--", ...names); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit filenames"); + const sha = git(cwd, "rev-parse", "HEAD").trim(); + + const base = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + expect(base.structured?.map((file) => file.path).sort()).toEqual([...names].sort()); + for (const name of names) { + expect(base.structured?.find((file) => file.path === name)).toMatchObject({ + path: name, + status: "ok", + ...EDITED_TEXT_DIFF, + }); + } + + const history = await listCheckoutCommits({ cwd }); + const commit = history.commits[0]; + expect(commit.sha).toBe(sha); + expect(commit.files).toEqual( + [...names].sort().map((name) => ({ + path: name, + additions: 1, + deletions: 1, + status: "modified", + })), + ); + for (const file of commit.files) { + expect(await getCommitFileDiff({ cwd, sha, path: file.path })).toMatchObject({ + path: file.path, + ...EDITED_TEXT_DIFF, + }); + } + }); + + it("preserves quoted rename destinations and binary paths in diff and commit lists", async () => { + const cwd = createChangedRepo("core.quotepath=false"); + const oldPath = 'old "draft".txt'; + const newPath = 'new "draft".txt'; + const binaryPath = "image\tdata.bin"; + writeFileSync(join(cwd, "file.txt"), "before\n"); + writeFileSync(join(cwd, oldPath), "unchanged\n"); + writeFileSync(join(cwd, binaryPath), Buffer.from([0, 1, 2])); + git(cwd, "add", "--", oldPath, binaryPath); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add rename and binary files"); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "mv", "--", oldPath, newPath); + writeFileSync(join(cwd, binaryPath), Buffer.from([0, 3, 4])); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + expect( + result.structured?.map(({ path, oldPath: sourcePath, status }) => ({ + path, + oldPath: sourcePath, + status, + })), + ).toEqual([ + { path: binaryPath, oldPath: undefined, status: "binary" }, + { + path: newPath, + oldPath, + status: "ok", + }, + ]); + expect(result.structured?.[0]).toEqual({ + path: binaryPath, + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + status: "binary", + }); + + git(cwd, "add", "--", binaryPath); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename file and edit binary"); + const history = await listCheckoutCommits({ cwd }); + expect(history.commits[0].files).toEqual([ + { path: binaryPath, additions: 0, deletions: 0, status: "modified" }, + { path: newPath, additions: 0, deletions: 0, status: "renamed" }, + ]); + }); +}); + +it.each(["diff.mnemonicPrefix=true", "color.diff=always", "diff.noprefix=true"])( + "preserves batched paths, spaces and literal prefix directories with %s", + async (config) => { + const cwd = createChangedRepo(config); + mkdirSync(join(cwd, "a")); + for (const name of ["a/literal.txt", "with spaces.txt"]) { + writeFileSync(join(cwd, name), "before\n"); + } + git(cwd, "add", "a/literal.txt", "with spaces.txt"); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add files"); + for (const name of ["a/literal.txt", "with spaces.txt"]) { + writeFileSync(join(cwd, name), "after\n"); + } + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured?.map((file) => file.path)).toEqual([ + "a/literal.txt", + "file.txt", + "with spaces.txt", + ]); + for (const file of result.structured ?? []) { + expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + } + expect(git(cwd, "config", "--get", config.split("=")[0]).trim()).toBe(config.split("=")[1]); + }, +); + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); +} + +function createChangedRepo(config: string): string { + const cwd = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-patch-format-"))); + repos.push(cwd); + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "file.txt"), "before\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + git(cwd, "config", ...config.split("=")); + writeFileSync(join(cwd, "file.txt"), "after\n"); + return cwd; +} + +function createExternalDiffCommand() { + const directory = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-external-diff-"))); + repos.push(directory); + const script = join(directory, "external-diff.cjs"); + const marker = join(directory, "invoked"); + writeFileSync( + script, + 'require("node:fs").writeFileSync(process.argv[2], "invoked");\n' + + 'process.stdout.write("CUSTOM EXTERNAL DIFF\\n");\n', + ); + const command = [process.execPath, script, marker] + .map((argument) => `'${argument.replaceAll("'", "'\\''")}'`) + .join(" "); + return { command, marker }; +} + +function createExternalDiffRepo() { + vi.stubEnv("GIT_EXTERNAL_DIFF", undefined); + const cwd = createChangedRepo("color.diff=always"); + const { command, marker } = createExternalDiffCommand(); + git(cwd, "config", "diff.external", command); + expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n"); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + return { cwd, command, marker }; +} + +function expectStandardPatch(result: Awaited<ReturnType<typeof getCheckoutDiff>>) { + expect(result.structured).toMatchObject([ + { + path: "file.txt", + additions: 1, + deletions: 1, + status: "ok", + hunks: [ + { + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], + }, + ]); +} + +it("returns Git patch hunks when a repository configures an external diff command", async () => { + const { cwd, command, marker } = createExternalDiffRepo(); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command); +}); + +it("returns Git patch hunks against the base branch with an external diff command", async () => { + const { cwd, command, marker } = createExternalDiffRepo(); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + + const result = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command); +}); + +it("returns Git patch hunks when GIT_EXTERNAL_DIFF configures the external command", async () => { + const cwd = createChangedRepo("color.diff=always"); + const { command, marker } = createExternalDiffCommand(); + vi.stubEnv("GIT_EXTERNAL_DIFF", command); + expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n"); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(process.env.GIT_EXTERNAL_DIFF).toBe(command); +}); + +describe.each(["diff.mnemonicPrefix=true", "color.diff=always"])( + "patch format with %s", + (config) => { + it("returns displayable uncommitted diff lines with their file counts", async () => { + const cwd = createChangedRepo(config); + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured).toHaveLength(1); + expect(result.structured?.[0]).toMatchObject({ + path: "file.txt", + additions: 1, + deletions: 1, + }); + expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + }); + + it("returns displayable committed changes against the base branch", async () => { + const cwd = createChangedRepo(config); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const result = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expect(result.structured?.[0]).toMatchObject({ + path: "file.txt", + additions: 1, + deletions: 1, + }); + expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]), + ); + }); + + it("returns displayable lines when opening an individual commit file", async () => { + const cwd = createChangedRepo(config); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const file = await getCommitFileDiff({ + cwd, + sha: git(cwd, "rev-parse", "HEAD").trim(), + path: "file.txt", + }); + + expect(file).toMatchObject({ path: "file.txt", additions: 1, deletions: 1 }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]), + ); + }); + }, +); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index e5cb02d46e..e361c41921 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -523,6 +523,7 @@ describe("checkout git utilities", () => { it("honors commit signing configuration", async () => { execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir }); + execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir }); execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "signed\n"); @@ -538,10 +539,129 @@ describe("checkout git utilities", () => { }); expect(diff.structured).toContainEqual( - expect.objectContaining({ path: "renamed.txt", oldPath: "file.txt" }), + expect.objectContaining({ + path: "renamed.txt", + oldPath: "file.txt", + additions: 0, + deletions: 0, + hunks: [], + }), ); }); + it.each([ + { mode: "uncommitted", oldPath: "to-rename.txt", path: "renamed.txt" }, + { mode: "base", oldPath: "to-rename.txt", path: "renamed.txt" }, + { mode: "uncommitted", oldPath: "old [file].txt", path: "new [file].txt" }, + ] as const)( + "shows only the edited line of a rename ($mode, $path)", + async ({ mode, oldPath, path }) => { + const original = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`); + commitFile(repoDir, oldPath, original.join("\n") + "\n", "file to rename"); + if (mode === "base") { + execFileSync("git", ["checkout", "-b", "feature/rename"], { cwd: repoDir }); + } + execFileSync("git", ["mv", oldPath, path], { cwd: repoDir }); + const edited = [...original]; + edited[5] = "edited line 6"; + writeFileSync(join(repoDir, path), edited.join("\n") + "\n"); + execFileSync("git", ["add", path], { cwd: repoDir }); + + expect( + execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(), + ).toMatch(/R\d+\t/); + if (mode === "base") { + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], { + cwd: repoDir, + }); + } + + const diff = await getCheckoutDiff(repoDir, { + mode, + ...(mode === "base" ? { baseRef: "main" } : {}), + includeStructured: true, + }); + + expect(diff.structured).toEqual([ + expect.objectContaining({ + path, + oldPath, + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + status: "ok", + }), + ]); + expect(diff.diff).toContain(`rename from ${oldPath}`); + expect(diff.diff).toContain(`rename to ${path}`); + expect(diff.diff).toContain("-line 6\n+edited line 6"); + expect(diff.diff).not.toContain("new file mode"); + }, + ); + + it("includes a changed copy source only once in the diff", async () => { + const original = + Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n"; + commitFile(repoDir, "source.txt", original, "copy source"); + execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir }); + writeFileSync(join(repoDir, "copy.txt"), original); + writeFileSync(join(repoDir, "source.txt"), original.replace("line 6", "edited line 6")); + execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir }); + expect( + execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(), + ).toContain("C100\tsource.txt\tcopy.txt"); + + const diff = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + expect(diff.structured).toEqual([ + expect.objectContaining({ + path: "copy.txt", + oldPath: "source.txt", + additions: 0, + deletions: 0, + }), + expect.objectContaining({ path: "source.txt", additions: 1, deletions: 1 }), + ]); + expect(diff.diff.match(/^diff --git a\/source.txt b\/source.txt$/gm)).toHaveLength(1); + expect(diff.diff).toContain("copy from source.txt"); + expect(diff.diff).toContain("copy to copy.txt"); + }); + + it("keeps a small copy patch when its edited source exceeds the patch limit", async () => { + const original = + Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n"; + commitFile(repoDir, "source.txt", original, "copy source"); + execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir }); + writeFileSync(join(repoDir, "copy.txt"), original); + writeFileSync(join(repoDir, "source.txt"), original + "added source line\n".repeat(80_000)); + execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir }); + expect( + execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(), + ).toContain("C100\tsource.txt\tcopy.txt"); + + const diff = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + expect(diff.structured).toEqual([ + expect.objectContaining({ + path: "copy.txt", + oldPath: "source.txt", + additions: 0, + deletions: 0, + hunks: [], + status: "ok", + }), + expect.objectContaining({ path: "source.txt", status: "too_large", hunks: [] }), + ]); + expect(diff.diff).toContain("copy from source.txt"); + expect(diff.diff).toContain("copy to copy.txt"); + expect(diff.diff).not.toContain("+added source line"); + }); + it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => { setupRemoteTrackingMain(repoDir, tempDir); @@ -1507,8 +1627,12 @@ const x = 1; expect(diff.diff).toContain("# generated.js: diff too large omitted"); expect(diff.diff).toContain(`-export const value = "old";`); expect(diff.diff).toContain(`+export const value = "new";`); - expect(commands).toContain("diff --numstat HEAD"); - expect(commands).toContain("diff HEAD -- :(literal)generated.js :(literal)small.ts"); + expect(commands).toContain( + "diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ -z --numstat HEAD", + ); + expect(commands).toContain( + "diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts", + ); expect(metrics.maxConcurrent).toBeLessThanOrEqual(8); }); @@ -3576,6 +3700,7 @@ const x = 1; cwd: repoDir, }); execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir }); + execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir }); execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir }); await expect(mergeToBase(repoDir, { baseRef: "main", mode: "squash" })).rejects.toThrow( diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index e1246a90a5..bda89506b5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,3 +1,4 @@ +import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; @@ -31,6 +32,7 @@ import { import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand, type RunGitCommand } from "./run-git-command.js"; import { readGitFileContents } from "./git-file-contents.js"; +import { unquoteGitPath } from "./git-quoted-path.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { branchNameFromRef, @@ -514,22 +516,18 @@ async function listCheckoutFileChanges( const { stdout: nameStatusOut } = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, - extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)], + extra: ["-z", "--name-status", ...getCheckoutDiffRefArgs(refs)], }), { cwd, envOverlay: READ_ONLY_GIT_ENV }, ); - for (const line of nameStatusOut - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)) { - // `--name-status` uses TAB separators, which preserves filenames with spaces. - const tabParts = line.split("\t"); - const rawStatus = (tabParts[0] ?? "").trim(); + const nameStatusFields = nameStatusOut.split("\0"); + for (let index = 0; index < nameStatusFields.length; ) { + const rawStatus = nameStatusFields[index++]; if (!rawStatus) continue; if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) { - const oldPath = tabParts[1]; - const newPath = tabParts[2]; + const oldPath = nameStatusFields[index++]; + const newPath = nameStatusFields[index++]; if (newPath) { changes.push({ path: newPath, @@ -542,7 +540,7 @@ async function listCheckoutFileChanges( continue; } - const path = tabParts[1]; + const path = nameStatusFields[index++]; if (!path) continue; const code = rawStatus[0]; changes.push({ @@ -555,16 +553,13 @@ async function listCheckoutFileChanges( if (refs.includeUntracked) { const { stdout: untrackedOut } = await runGitCommand( - ["ls-files", "--others", "--exclude-standard"], + ["ls-files", "-z", "--others", "--exclude-standard"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }, ); - for (const file of untrackedOut - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)) { + for (const file of untrackedOut.split("\0").filter(Boolean)) { changes.push({ path: file, status: "U", @@ -622,22 +617,34 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string type FileStat = { additions: number; deletions: number; isBinary: boolean } | null; function normalizeNumstatPath(pathField: string): string { + if (/^"(?:\\.|[^"\\])*"$/.test(pathField)) { + return unquoteGitPath(pathField); + } + const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/); if (braceRenameMatch) { const [, prefix, , renamed, suffix] = braceRenameMatch; - return `${prefix}${renamed}${suffix}`; + return unquoteGitPath(`${prefix}${renamed}${suffix}`); } const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/); if (inlineRenameMatch) { - return inlineRenameMatch[2] ?? pathField; + return unquoteGitPath(inlineRenameMatch[2] ?? pathField); } - return pathField; + return unquoteGitPath(pathField); } +// Parsed patches need a stable wire format regardless of terminal presentation settings. +const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"]; + function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] { - return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra]; + return [ + "diff", + ...GIT_PATCH_FORMAT_ARGS, + ...(args.ignoreWhitespace ? ["-w"] : []), + ...args.extra, + ]; } const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB @@ -660,7 +667,7 @@ async function getTrackedNumstatByPath( const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, - extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)], + extra: ["-z", "--numstat", ...getCheckoutDiffRefArgs(refs)], }), { cwd, @@ -671,21 +678,21 @@ async function getTrackedNumstatByPath( ); const stats = new Map<string, FileStat>(); - const lines = result.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - for (const line of lines) { - const parts = line.split("\t"); + const records = result.stdout.split("\0"); + for (let index = 0; index < records.length; index += 1) { + const parts = records[index].split("\t"); if (parts.length < 3) { continue; } const additionsField = parts[0] ?? ""; const deletionsField = parts[1] ?? ""; - const rawPath = parts.slice(2).join("\t"); - const path = normalizeNumstatPath(rawPath); + let path = parts.slice(2).join("\t"); + if (!path) { + // A rename has an empty path here, followed by separate old/new NUL fields. + path = records[index + 2]; + index += 2; + } if (!path) { continue; @@ -713,12 +720,19 @@ async function getTrackedDiffTextForPath(input: { cwd: string; refsForDiff: CheckoutDiffRefs; path: string; + oldPath?: string; ignoreWhitespace: boolean; }): Promise<{ path: string; text: string; truncated: boolean }> { + const paths = input.oldPath ? [input.oldPath, input.path] : [input.path]; const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace: input.ignoreWhitespace, - extra: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", `:(literal)${input.path}`], + extra: [ + ...(input.oldPath ? ["--diff-filter=RC"] : []), + ...getCheckoutDiffRefArgs(input.refsForDiff), + "--", + ...paths.map((path) => `:(literal)${path}`), + ], }), { cwd: input.cwd, @@ -727,9 +741,22 @@ async function getTrackedDiffTextForPath(input: { }, ); + // A source path can participate in another rename/copy in the same request. + // Return only the requested destination; the filter keeps source edits out of its budget. + let text = result.stdout; + if (input.oldPath && !result.truncated) { + text = result.stdout + .split(/(?=^diff --git )/m) + .filter((section) => { + const hunkStart = section.indexOf("\n@@ "); + const header = hunkStart < 0 ? section : section.slice(0, hunkStart); + return parseDiff(header)[0]?.path === input.path; + }) + .join(""); + } return { path: input.path, - text: result.stdout, + text, truncated: result.truncated, }; } @@ -1159,13 +1186,16 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] { async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> { try { - const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - const entries = parseWorktreeList(stdout); const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`; - return entries.find((entry) => entry.branchRef === ref)?.path ?? null; + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname)%00%(worktreepath)%00", ref], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + for (const record of stdout.split("\0\n")) { + const [reference, worktreePath] = record.split("\0"); + if (reference === ref) return worktreePath || null; + } + return null; } catch { return null; } @@ -1560,11 +1590,15 @@ export async function resolveRepositoryDefaultBranch( ); return localName; } catch { + await getRunGitCommand(context)(["show-ref", "--verify", "--quiet", ref], { + cwd: repoRoot, + envOverlay: READ_ONLY_GIT_ENV, + }); return remoteShort; } } } catch { - // ignore + // A stale origin/HEAD must fall through to the available local branches. } const { stdout } = await getRunGitCommand(context)(["branch", "--format=%(refname:short)"], { @@ -2117,13 +2151,13 @@ export async function getCheckoutSnapshotFacts( const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB -const RELAY_MAX_FRAME_BYTES = 32 * 1024 * 1024; const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024; // Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is // binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for // the surrounding WebSocket JSON envelope after inverting that exact wire expansion. export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES = - maxBase64EncryptedPlaintextByteLength(RELAY_MAX_FRAME_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; + maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - + CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; interface StructuredDiffAccumulator { files: ParsedDiffFile[]; @@ -2388,7 +2422,7 @@ function parseRawStatusLine(line: string, statuses: Map<string, CheckoutCommitFi if (!path) { return; } - statuses.set(path, status); + statuses.set(unquoteGitPath(path), status); } // A `--numstat` line: `<adds>\t<dels>\t<path>` (renames use `old => new`, binary @@ -2630,10 +2664,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 +2677,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 @@ -2701,16 +2736,13 @@ async function countUntrackedAdditions( ): Promise<number> { try { const { stdout } = await getRunGitCommand(context)( - ["ls-files", "--others", "--exclude-standard"], + ["ls-files", "-z", "--others", "--exclude-standard"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }, ); - const files = stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); + const files = stdout.split("\0").filter(Boolean); let additions = 0; for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) { @@ -3302,26 +3334,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, }), ), @@ -3518,13 +3553,13 @@ export async function commitAll(cwd: string, message: string): Promise<void> { } export async function discardChanges(cwd: string, pathspecs: string[]): Promise<void> { - await requireGitRepo(cwd); + const worktreeRoot = await requireGitWorktreeRoot(cwd); if (pathspecs.length === 0) { return; } try { await runGitCommand(["--literal-pathspecs", "reset", "-q", "HEAD", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } catch { @@ -3532,7 +3567,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< await runGitCommand( ["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }, ); @@ -3544,7 +3579,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< const status = await runGitCommand( ["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }, ); @@ -3570,13 +3605,13 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< } if (tracked.length > 0) { await runGitCommand(["--literal-pathspecs", "checkout", "-q", "--", ...tracked], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } if (untracked.length > 0) { await runGitCommand(["--literal-pathspecs", "clean", "-fd", "-q", "--", ...untracked], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 68a2693562..711a721e48 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -697,6 +697,105 @@ describe("absolute directory-path configuration", () => { }); }); +describe("home search with excluded discovery paths", () => { + let homeDir: string; + let libraryDir: string; + + beforeEach(() => { + homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-excluded-"))); + libraryDir = path.join(homeDir, "Library"); + mkdirSync(path.join(libraryDir, "sub", "match-me"), { recursive: true }); + mkdirSync(path.join(homeDir, "projects", "other"), { recursive: true }); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + function searchHome(query: string) { + return searchDirectoryEntries({ + root: homeDir, + query, + pathFormat: "absolute", + includeDirectories: true, + includeFiles: false, + pathQueryPolicy: "rooted", + rootAliases: ["~"], + blankQueryBehavior: "none", + confidentResultScanThreshold: 5_000, + excludedDiscoveryPaths: [libraryDir], + }).then((entries) => entries.map((entry) => entry.path)); + } + + it("does not discover entries inside an excluded path", async () => { + await expect(searchHome("match")).resolves.toEqual([]); + await expect(searchHome("~")).resolves.toEqual([path.join(homeDir, "projects")]); + }); + + it("excludes by path, so a nested directory with the same name is still discovered", async () => { + const nested = path.join(homeDir, "projects", "game", "Library", "match-me"); + mkdirSync(nested, { recursive: true }); + + await expect(searchHome("match")).resolves.toEqual([nested]); + }); + + // POSIX-only: creates and follows a symlink fixture. + it.skipIf(isWindows)("does not discover an excluded path through a symlink", async () => { + symlinkSync(path.join(libraryDir, "sub"), path.join(homeDir, "link"), "dir"); + + await expect(searchHome("match")).resolves.toEqual([]); + }); + + it("resolves an exact typed path inside an excluded path", async () => { + await expect(searchHome("~/Library/sub/match-me")).resolves.toEqual([ + path.join(libraryDir, "sub", "match-me"), + ]); + }); + + it("lists one level when browsing inside an excluded path", async () => { + await expect(searchHome("~/Library/")).resolves.toEqual([ + libraryDir, + path.join(libraryDir, "sub"), + ]); + await expect(searchHome("~/Library/su")).resolves.toEqual([path.join(libraryDir, "sub")]); + }); +}); + +describe("rooted queries with a typed parent", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-typed-parent-"))); + mkdirSync(path.join(homeDir, "Developer", "pasta"), { recursive: true }); + mkdirSync(path.join(homeDir, "Archive", "Developer", "pasta"), { recursive: true }); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("walks only under the typed parent when it exists", async () => { + await expect( + searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pas" }), + ).resolves.toEqual([path.join(homeDir, "Developer", "pasta")]); + }); + + it("matches the whole tree when the typed parent does not exist", async () => { + await expect(searchAbsoluteDirectoryPaths({ homeDir, query: "~/Devel/pas" })).resolves.toEqual([ + path.join(homeDir, "Developer", "pasta"), + path.join(homeDir, "Archive", "Developer", "pasta"), + ]); + }); + + it("returns the typed directory first", async () => { + mkdirSync(path.join(homeDir, "Developer", "pasta-extra")); + + const results = await searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pasta" }); + + expect(results[0]).toBe(path.join(homeDir, "Developer", "pasta")); + }); +}); + describe("relative typed-entry configuration", () => { let tempRoot: string; let workspaceDir: string; diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 049b1011ac..e0443475bc 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -33,6 +33,9 @@ export interface SearchDirectoryEntriesOptions { maxEntriesScanned?: number; confidentResultScanThreshold?: number; respectGitIgnore?: boolean; + // Absolute paths that discovery never walks into. A path the caller types explicitly still + // resolves, and browsing inside one lists a single level at a time. + excludedDiscoveryPaths?: readonly string[]; } interface QueryPlan { @@ -55,6 +58,11 @@ interface RawChildEntry { kind: DirectorySuggestionKind | "symlink"; } +interface WalkStart { + visiblePath: string; + resolvedPath: string; +} + interface TraversedEntry extends ChildEntry { visiblePath: string; depth: number; @@ -138,6 +146,7 @@ export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ ".agents", ".claude", ".codex", + ".cursor", ".github", ".opencode", ".paseo", @@ -174,29 +183,64 @@ export async function searchDirectoryEntries( const gitIgnoredPaths = options.respectGitIgnore ? await loadGitIgnoredPaths(root) : new Set<string>(); - const input = buildSearchInput(options, root, gitIgnoredPaths); + const excludedDiscoveryPaths = await resolveExistingPaths(options.excludedDiscoveryPaths ?? []); + const input = buildSearchInput(options, root, gitIgnoredPaths, excludedDiscoveryPaths); if (!input) return []; const exact = - input.plan.browseExactPath || (input.matchMode === "suffix" && input.plan.isPathQuery) + input.plan.browseExactPath || + (input.plan.isPathQuery && (input.matchMode === "suffix" || resolvesTypedPath(input))) ? await findExactEntry(input) : null; if (exact && input.limit === 1) return [exact]; - const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery; - const browsesAbsoluteParent = input.plan.browseExactPath === true; - const ranked = - browsesRoot || browsesAbsoluteParent ? await searchChildren(input) : await searchTree(input); + const ranked = await searchCandidates(input); const results = sortAndFormat(ranked, input.root, input.pathFormat).slice(0, input.limit); return exact ? [exact, ...results.filter((entry) => !sameEntry(entry, exact))].slice(0, input.limit) : results; } +async function searchCandidates(input: SearchInput): Promise<RankedEntry[]> { + const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery; + const browsesAbsoluteParent = input.plan.browseExactPath === true; + if (browsesRoot || browsesAbsoluteParent) return searchChildren(input); + if (!resolvesTypedPath(input) || !input.plan.parentPart) return searchTree(input); + + // A typed parent such as `~/Developer/pas` anchors the walk under `~/Developer`. A parent that + // does not exist falls back to matching the typed path against the whole tree. + const typedParent = await resolveTypedParent(input); + if (!typedParent) return searchTree(input); + if (isInsideExcludedPath(typedParent.resolvedPath, input)) return searchChildren(input); + return searchTree(input, typedParent); +} + +// Only a rooted query under the rooted policy names a location. Under the slashes policy a query +// with a slash is still a fragment that may match anywhere in the tree. +function resolvesTypedPath(input: SearchInput): boolean { + return input.pathQueryPolicy === "rooted" && input.plan.isPathQuery; +} + +async function resolveTypedParent(input: SearchInput): Promise<WalkStart | null> { + const visiblePath = path.resolve(input.root, input.plan.parentPart); + const resolvedPath = await realpath(visiblePath).catch(() => null); + if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null; + const info = await stat(resolvedPath).catch(() => null); + return info?.isDirectory() ? { visiblePath, resolvedPath } : null; +} + +async function resolveExistingPaths(paths: readonly string[]): Promise<Set<string>> { + const resolved = await Promise.all( + paths.map((candidate) => realpath(path.resolve(candidate)).catch(() => null)), + ); + return new Set(resolved.filter((candidate): candidate is string => candidate !== null)); +} + function buildSearchInput( options: SearchDirectoryEntriesOptions, root: string, gitIgnoredPaths: Set<string>, + excludedDiscoveryPaths: Set<string>, ): SearchInput | null { const includeDirectories = options.includeDirectories ?? true; const includeFiles = options.includeFiles ?? false; @@ -218,6 +262,7 @@ function buildSearchInput( includeDirectories, includeFiles, matchMode: options.matchMode ?? "fuzzy", + pathQueryPolicy: options.pathQueryPolicy ?? "slashes", pathFormat: options.pathFormat, hiddenDirectoryNames: new Set(options.traversableHiddenDirectoryNames ?? []), limit: normalizeLimit(options.limit), @@ -225,6 +270,7 @@ function buildSearchInput( maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED, confidentResultScanThreshold: options.confidentResultScanThreshold, gitIgnoredPaths, + excludedDiscoveryPaths, }; } @@ -252,6 +298,7 @@ interface SearchInput { includeDirectories: boolean; includeFiles: boolean; matchMode: DirectorySuggestionMatchMode; + pathQueryPolicy: PathQueryPolicy; pathFormat: DirectorySuggestionPathFormat; hiddenDirectoryNames: Set<string>; limit: number; @@ -259,6 +306,7 @@ interface SearchInput { maxEntriesScanned: number; confidentResultScanThreshold: number | undefined; gitIgnoredPaths: Set<string>; + excludedDiscoveryPaths: Set<string>; } async function searchChildren(input: SearchInput): Promise<RankedEntry[]> { @@ -266,9 +314,13 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> { const parent = await realpath(visibleParent).catch(() => null); if (!parent || !isPathInsideRoot(input.root, parent)) return []; if (isGitIgnoredPath(parent, input)) return []; + // Browsing inside an excluded path is explicit: the caller typed it, so list what is there. + const discoveryInput = isInsideExcludedPath(parent, input) + ? { ...input, excludedDiscoveryPaths: new Set<string>() } + : input; const entries = await readChildren(parent); return entries.flatMap((entry) => { - if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input)) + if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput)) return []; const candidate: TraversedEntry = { ...entry, @@ -279,17 +331,20 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> { }); } -async function searchTree(input: SearchInput): Promise<RankedEntry[]> { +async function searchTree( + input: SearchInput, + start: WalkStart = { visiblePath: input.root, resolvedPath: input.root }, +): Promise<RankedEntry[]> { if (!(input.maxEntriesScanned > 0)) return []; - const roots = (await readChildren(input.root)).filter((entry) => + const roots = (await readChildren(start.resolvedPath)).filter((entry) => staysInsideRoot(entry, input.root), ); - const visited = new Set<string>([input.root]); + const visited = new Set<string>([start.resolvedPath]); const branches = roots.flatMap((entry) => shouldDiscover(entry, input) ? [ walkBranch( - { ...entry, visiblePath: path.join(input.root, entry.name), depth: 1 }, + { ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 }, input, visited, ), @@ -370,6 +425,7 @@ function staysInsideRoot(entry: ChildEntry, root: string): boolean { function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { if (isNewlyGitIgnored(entry, input)) return false; + if (isNewlyExcluded(entry, input)) return false; if (entry.kind === "file") { return input.includeFiles && !entry.name.startsWith("."); } @@ -386,6 +442,21 @@ function isNewlyGitIgnored(entry: ChildEntry, input: SearchInput): boolean { return input.gitIgnoredPaths.has(entry.resolvedPath); } +// Same reasoning as Git-ignored paths: only a symlink can land inside an excluded path without +// first passing through it by name. +function isNewlyExcluded(entry: ChildEntry, input: SearchInput): boolean { + if (input.excludedDiscoveryPaths.size === 0) return false; + if (entry.viaSymlink) return isInsideExcludedPath(entry.resolvedPath, input); + return input.excludedDiscoveryPaths.has(entry.resolvedPath); +} + +function isInsideExcludedPath(absolutePath: string, input: SearchInput): boolean { + for (const excludedPath of input.excludedDiscoveryPaths) { + if (isPathInsideRoot(excludedPath, absolutePath)) return true; + } + return false; +} + function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean { let candidate = absolutePath; while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) { diff --git a/packages/server/src/utils/git-quoted-path.ts b/packages/server/src/utils/git-quoted-path.ts new file mode 100644 index 0000000000..cb5a9d45eb --- /dev/null +++ b/packages/server/src/utils/git-quoted-path.ts @@ -0,0 +1,34 @@ +const GIT_PATH_ESCAPE_BYTES: Record<string, number> = { + a: 7, + b: 8, + t: 9, + n: 10, + v: 11, + f: 12, + r: 13, + '"': 34, + "\\": 92, +}; + +// core.quotepath=false still quotes control characters, double quotes and backslashes. +export function unquoteGitPath(value: string): string { + if (!value.startsWith('"') || !value.endsWith('"')) return value; + + const encoded = value.slice(1, -1); + const parts: Buffer[] = []; + let offset = 0; + for (const match of encoded.matchAll(/\\([0-7]{1,3}|[abfnrtv"\\])/g)) { + parts.push(Buffer.from(encoded.slice(offset, match.index))); + const escape = match[1]; + let byte: number; + if (/^[0-7]/.test(escape)) { + byte = Number.parseInt(escape, 8); + } else { + byte = GIT_PATH_ESCAPE_BYTES[escape]; + } + parts.push(Buffer.from([byte])); + offset = match.index + match[0].length; + } + parts.push(Buffer.from(encoded.slice(offset))); + return Buffer.concat(parts).toString("utf8"); +} diff --git a/packages/server/src/utils/git-rev-parse-path.test.ts b/packages/server/src/utils/git-rev-parse-path.test.ts new file mode 100644 index 0000000000..7614b7dec8 --- /dev/null +++ b/packages/server/src/utils/git-rev-parse-path.test.ts @@ -0,0 +1,26 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; + +describe("Git path output", () => { + it.each(["\n", "\r\n", ""])("preserves path whitespace with delimiter %j", (delimiter) => { + expect(parseGitRevParsePath(`/repo ${delimiter}`)).toBe("/repo "); + expect(parseGitRevParsePath(` relative path ${delimiter}`)).toBe(" relative path "); + }); + + it.each([ + "", + "\n", + "\r\n", + "--path-format=absolute\n", + "--path-format=absolute\n/repo\n", + "/repo\n/another\n", + "/repo\n\n", + ])("rejects invalid output %j", (output) => { + expect(parseGitRevParsePath(output)).toBeNull(); + }); + + it("resolves a relative path without stripping its trailing space", () => { + expect(resolveGitRevParsePath("/base", "repo \n")).toBe(resolve("/base", "repo ")); + }); +}); diff --git a/packages/server/src/utils/git-rev-parse-path.ts b/packages/server/src/utils/git-rev-parse-path.ts index b799815056..2f027d5020 100644 --- a/packages/server/src/utils/git-rev-parse-path.ts +++ b/packages/server/src/utils/git-rev-parse-path.ts @@ -1,18 +1,9 @@ import { resolve } from "node:path"; export function parseGitRevParsePath(stdout: string): string | null { - const trimmed = stdout.trim(); - if (!trimmed) { - return null; - } - - const lines = trimmed.split(/\r?\n/); - if (lines.length !== 1) { - return null; - } - - const path = lines[0]?.trim() ?? ""; - if (!path || path.startsWith("--")) { + // Remove Git's record delimiter, not whitespace belonging to the path. + const path = stdout.replace(/\r?\n$/, ""); + if (!path || /[\r\n]/.test(path) || path.startsWith("--")) { return null; } diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts index f72f5d82b2..bb279d6c2f 100644 --- a/packages/server/src/utils/path.test.ts +++ b/packages/server/src/utils/path.test.ts @@ -42,6 +42,35 @@ describe("path equivalence", () => { ); }); + test.each([ + { root: "/repo", candidate: "/repo/..notes.txt", inside: true }, + { root: "/repo", candidate: "/repo/..drafts/notes.md", inside: true }, + { root: "/repo", candidate: "/repo/...drafts/notes.md", inside: true }, + { root: "/repo", candidate: "/repo/docs/..notes.txt", inside: true }, + { root: "/repo", candidate: "/repo", inside: true }, + { root: "/repo", candidate: "/repo/../sibling/notes.md", inside: false }, + { root: "/repo", candidate: "/repo/..drafts/../../outside.txt", inside: false }, + { root: "/repo", candidate: "/repo-other/notes.md", inside: false }, + { root: "C:\\Repo", candidate: "c:/repo/..notes.txt", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\..drafts\\notes.md", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\...drafts\\notes.md", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\..\\sibling\\notes.md", inside: false }, + { root: "C:\\Repo", candidate: "C:\\Repo-other\\notes.md", inside: false }, + { root: "C:\\Repo", candidate: "D:\\Repo\\..notes.txt", inside: false }, + { + root: String.raw`\\server\share\repo`, + candidate: String.raw`\\server\share\repo\..drafts\notes.md`, + inside: true, + }, + { + root: String.raw`\\?\C:\Repo`, + candidate: "C:\\Repo\\..notes.txt", + inside: true, + }, + ])("checks named path containment for $candidate", ({ root, candidate, inside }) => { + expect(isPathInsideRoot(root, candidate)).toBe(inside); + }); + test("preserves the casing of Windows relative suffixes", () => { expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD"); expect( diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index 577e48efb4..a0ec908fbb 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -114,10 +114,11 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows); const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate); - if ( - comparableRelative !== "" && - (comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative)) - ) { + const isOutsideRoot = + comparableRelative === ".." || + comparableRelative.startsWith(`..${platformPath.sep}`) || + platformPath.isAbsolute(comparableRelative); + if (isOutsideRoot) { return null; } diff --git a/packages/server/src/utils/spawn.launch-regression.test.ts b/packages/server/src/utils/spawn.launch-regression.test.ts index 847214c624..4943e68990 100644 --- a/packages/server/src/utils/spawn.launch-regression.test.ts +++ b/packages/server/src/utils/spawn.launch-regression.test.ts @@ -126,10 +126,15 @@ async function runFixture(params: { command: string; args: string[]; shell?: boolean; + env?: NodeJS.ProcessEnv; }): Promise<SpawnResult> { const child = spawnProcess(params.command, params.args, { env: { ...process.env, + ...params.env, + // Terminal color settings must not add Node warnings to the argv fixture's stderr. + NO_COLOR: undefined, + FORCE_COLOR: undefined, PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]), }, stdio: ["ignore", "pipe", "pipe"], @@ -216,6 +221,8 @@ async function findAndLaunch(fixture: LaunchFixture): Promise<{ const child = spawnProcess(found, fixture.args, { env: { ...process.env, + NO_COLOR: undefined, + FORCE_COLOR: undefined, PASEO_EXPECTED_ARGV_JSON: fixture.expectedArgvJson, PASEO_ARGV_SLICE_FROM: String(fixture.sliceFrom), }, @@ -331,18 +338,25 @@ describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => { }); describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => { - test("direct launch with a space-containing executable works on this platform", async () => { - const fixture = makeFixture(); + test.each([ + { colors: "inherited", env: {} }, + { colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } }, + ])( + "direct launch with a space-containing executable works with $colors colors", + async ({ env }) => { + const fixture = makeFixture(); - const result = await runFixture({ - command: fixture.fakeDaemonNode, - args: [fixture.assertScript, ...fixture.expectedArgs], - shell: false, - }); + const result = await runFixture({ + command: fixture.fakeDaemonNode, + args: [fixture.assertScript, ...fixture.expectedArgs], + shell: false, + env, + }); - expect(result.error).toBeNull(); - expect(result.code).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout.trim()).toBe("ARGV_OK"); - }); + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }, + ); }); diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index 8fac115f00..b26104c63e 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,15 +1,6 @@ -import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; -import { extname } from "node:path"; -import { promisify } from "node:util"; - +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { spawnProcess as spawnCli, execCommand as execCli } from "@getpaseo/plugin/server"; import { createExternalCommandProcessEnv, type ProcessEnvRecord } from "../server/paseo-env.js"; -import { - isWindowsCommandScript, - quoteWindowsArgument, - quoteWindowsCommand, -} from "./windows-command.js"; - -const execFileAsync = promisify(execFile); interface ExternalEnvOptions { baseEnv?: ProcessEnvRecord; @@ -35,23 +26,6 @@ interface ExecCommandResult { stderr: string; } -function hasPathSeparator(value: string): boolean { - return value.includes("/") || value.includes("\\"); -} - -function shouldUseWindowsShell( - command: string, - requestedShell?: boolean | string, -): boolean | string { - if (isWindowsCommandScript(command)) { - return true; - } - if (requestedShell !== undefined) { - return requestedShell; - } - return process.platform === "win32" && !hasPathSeparator(command) && !extname(command); -} - export function spawnProcess( command: string, args: string[], @@ -59,12 +33,6 @@ export function spawnProcess( ): ChildProcess { const { baseEnv, env, envOverlay, ...spawnOptions } = options ?? {}; const resolvedBaseEnv = env ?? baseEnv ?? process.env; - const isWindows = process.platform === "win32"; - const shell = shouldUseWindowsShell(command, spawnOptions.shell); - - const shouldQuoteForShell = isWindows && shell !== false; - const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; - const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; const childEnv = options?.envMode === "internal" ? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv) @@ -74,12 +42,10 @@ export function spawnProcess( ...(envOverlay ? [envOverlay] : []), ); - return spawn(resolvedCommand, resolvedArgs, { + return spawnCli(command, args, { ...spawnOptions, env: childEnv, - shell, signal: options?.signal, - windowsHide: true, }); } @@ -90,11 +56,6 @@ export async function execCommand( ): Promise<ExecCommandResult> { const { baseEnv, env, envOverlay } = options ?? {}; const resolvedBaseEnv = env ?? baseEnv ?? process.env; - const isWindows = process.platform === "win32"; - const shell = shouldUseWindowsShell(command, options?.shell); - const shouldQuoteForShell = isWindows && shell !== false; - const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; - const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; const childEnv = options?.envMode === "internal" ? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv) @@ -104,14 +65,14 @@ export async function execCommand( ...(envOverlay ? [envOverlay] : []), ); - return execFileAsync(resolvedCommand, resolvedArgs, { + return execCli(command, args, { cwd: options?.cwd, env: childEnv, + shell: options?.shell, encoding: options?.encoding ?? "utf8", + signal: options?.signal, killSignal: options?.killSignal, timeout: options?.timeout, maxBuffer: options?.maxBuffer, - shell, - windowsHide: true, }) as Promise<ExecCommandResult>; } diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 5673c1412a..2b9181bb77 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -265,6 +265,38 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); + it.each([false, true])( + "shows committed changes for an existing branch (already checked out: %s)", + async (alreadyCheckedOut) => { + execFileSync("git", ["checkout", "-b", "feature/existing"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "feature change\n"); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { + cwd: repoDir, + }); + if (!alreadyCheckedOut) { + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + } + + const result = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "existing-feature", + source: { kind: "checkout-branch", branchName: "feature/existing" }, + runSetup: false, + paseoHome, + }); + const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); + const diff = await getCheckoutDiff(result.worktreePath, { mode: "base" }, { paseoHome }); + expect(diff.diff).toContain("+feature change"); + expect(status).toMatchObject({ + isGit: true, + baseRef: "main", + aheadBehind: { ahead: 1, behind: 0 }, + }); + expect(result.comparisonBaseRef).toBe("main"); + }, + ); + it("checks out an existing local branch that is not checked out elsewhere", async () => { execFileSync("git", ["branch", "dev"], { cwd: repoDir }); @@ -284,15 +316,65 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { .trim(); expect(currentBranch).toBe("dev"); + // baseRefName must be the repo's default branch, not the checked-out branch + // itself — otherwise the diff panel compares the branch against itself and + // always shows no changes. const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, - baseRefName: "dev", + baseRefName: "main", changeRequestLookupTarget: { headRef: "dev", localBranchName: "dev" }, }); }); + it("shows committed changes when origin HEAD points to a deleted default branch", async () => { + execFileSync("git", ["branch", "-m", "main", "master"], { cwd: repoDir }); + execFileSync( + "git", + ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"], + { cwd: repoDir }, + ); + execFileSync("git", ["checkout", "-b", "feature/stale-default"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "feature change\n"); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "master"], { cwd: repoDir }); + const created = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "stale-default", + source: { kind: "checkout-branch", branchName: "feature/stale-default" }, + runSetup: false, + paseoHome, + }); + const diff = await getCheckoutDiff(created.worktreePath, { mode: "base" }, { paseoHome }); + expect(diff.diff).toContain("+feature change"); + expect(created.comparisonBaseRef).toBe("master"); + }); + + it("falls back to the currently checked out branch when no default branch can be resolved", async () => { + // No origin remote and no local main/master — resolveRepositoryDefaultBranch + // can't find a default branch, so the fix must fall back to whatever is + // currently checked out in the main repo (here: "develop"). + execFileSync("git", ["branch", "-m", "main", "develop"], { cwd: repoDir }); + execFileSync("git", ["branch", "feature-x"], { cwd: repoDir }); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "feature-x-worktree", + source: { kind: "checkout-branch", branchName: "feature-x" }, + runSetup: true, + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(true); + const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" }); + }); + it("checks out an existing local branch whose name contains uppercase letters and dots", async () => { execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir }); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 479d4cfeab..9b0cba2643 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -10,6 +10,7 @@ import { type WorktreeConfig, } from "./worktree"; import { execFileSync } from "child_process"; +import { rm } from "node:fs/promises"; import { mkdtempSync, mkdirSync, @@ -89,8 +90,8 @@ describe("paseo worktree manager", () => { }); }); - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); it("treats a worktree as paseo-owned even when its .git admin is missing", async () => { 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/README.md b/packages/website/README.md new file mode 100644 index 0000000000..5a938ca2f4 --- /dev/null +++ b/packages/website/README.md @@ -0,0 +1,22 @@ +# Paseo website + +## Plugin install counts + +Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or +`?intent=install`, using `/api/plugins/resolve/<owner>/<slug>` or +`/plugins/<owner>/<slug>.json`. Ordinary resolves do not count. These numbers +measure install intent, not successful artifact installation or npm downloads. + +The worker requires Cloudflare's `CF-Connecting-IP` header to count a report. +It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key +with a one hour TTL, never the raw IP. An existing key skips the count; repeats +do not extend the hour. Missing or empty IP headers do not count. + +Accepted reports increment the existing all-time total and UTC daily buckets in +`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day +and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped +to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts) +reads that endpoint into the published plugin details and index; the website +reads windowed counts from KV through its five-minute cache. Existing counts +remain intact. The KV guard is advisory: concurrent requests and KV propagation +are not serialized. diff --git a/packages/website/e2e/overview.fixture.json b/packages/website/e2e/overview.fixture.json new file mode 100644 index 0000000000..2553fe83c6 --- /dev/null +++ b/packages/website/e2e/overview.fixture.json @@ -0,0 +1,32 @@ +{ + "id": "security/overview", + "name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">", + "description": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">", + "artifact": { + "kind": "npm", + "package": "@alhassanaraouf/paseo-base2tone-theme", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz", + "integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg==" + }, + "license": "MIT", + "repository": { + "url": "javascript:alert(1)" + }, + "categories": ["themes"], + "author": { + "github": "security", + "name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">" + }, + "media": [ + "data:image/svg+xml,<svg onload=\"globalThis.__overviewExecuted=1\"/>", + "http://example.com/image.png" + ], + "submittedAt": "2026-09-24T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-24T17:12:05.467Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "## Overview security\n\n</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">", + "icon": "data:image/png;base64,AAAA" +} diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index d2066bca61..6eb9f7d816 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -1,4 +1,6 @@ -import { expect, test, type Page } from "playwright/test"; +import securityPlugin from "./overview.fixture.json" with { type: "json" }; +import { expect, test, type Page, type Locator } from "playwright/test"; +import registry from "./registry.fixture.json" with { type: "json" }; import { CATEGORIES } from "../src/plugins/categories"; async function openPlugins(page: Page) { @@ -6,6 +8,58 @@ async function openPlugins(page: Page) { await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); } +function pluginCards(page: Page, section: string): Locator { + return page + .getByRole("region", { name: section }) + .getByRole("link") + .filter({ hasNotText: /^See all$/ }); +} + +test("scrolls mobile plugin carousels without widening the page", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openPlugins(page); + await expectPageWithinViewport(page); + await scrollPluginCarousel(page, "Featured"); + await scrollPluginCarousel(page, "What’s new"); + await expectPageWithinViewport(page); +}); + +// Graphite's fixture description ends in raw README HTML with an unbreakable srcset URL. +test("keeps browse grids within a phone's width when a description has an unbreakable word", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/plugins/all"); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expectPageWithinViewport(page); +}); + +async function expectPageWithinViewport(page: Page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + await page.evaluate(() => document.documentElement.clientWidth), + ); + await page.evaluate(() => window.scrollTo({ left: 1000, behavior: "instant" })); + expect(await page.evaluate(() => window.scrollX)).toBe(0); +} + +async function scrollPluginCarousel(page: Page, section: string) { + const cards = pluginCards(page, section); + // The cards share a non-semantic scrolling container. + const strip = cards.first().locator(".."); + await strip.evaluate((element) => + element.scrollIntoView({ block: "center", behavior: "instant" }), + ); + expect(await strip.evaluate((element) => element.scrollWidth)).toBeGreaterThan( + await strip.evaluate((element) => element.clientWidth), + ); + await expect(cards.last()).not.toBeInViewport(); + await strip.evaluate((element) => + element.scrollTo({ left: element.scrollWidth, behavior: "instant" }), + ); + await expect(cards.last()).toBeInViewport({ ratio: 1 }); + await expect(cards.first()).not.toBeInViewport(); +} + test("browses from the directory into a category, a plugin, and its author", async ({ page, context, @@ -32,18 +86,23 @@ test("browses from the directory into a category, a plugin, and its author", asy await page.getByRole("link", { name: /Fresh Worktrees/ }).click(); await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/); await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1); - await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1); + await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toHaveCount(1); await expect( - page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), ).toBeVisible(); await page.getByRole("button", { name: "Copy to clipboard" }).click(); await expect .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied)) - .toBe("paseo plugin install omercnet/fresh-worktrees"); + .toBe("paseo plugin add omercnet/fresh-worktrees"); await expect(page.getByRole("link", { name: "Git", exact: true })).toHaveAttribute( "href", "/plugins/category/git", ); + // The source is a folder in a monorepo; issues are filed on the repository. + await expect(page.getByRole("link", { name: "Report a bug" })).toHaveAttribute( + "href", + "https://github.com/omercnet/paseo-plugins/issues/new", + ); await page.getByRole("link", { name: "Omer Cohen" }).click(); await expect(page).toHaveURL(/\/plugins\/omercnet$/); @@ -52,7 +111,9 @@ test("browses from the directory into a category, a plugin, and its author", asy await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0); }); -test("ranks plugins on browse pages by installs in a window or by newest", async ({ page }) => { +test("ranks plugins on browse pages by installs in a window, by newest, or by latest update", async ({ + page, +}) => { await page.goto("/plugins/all"); await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); await expect(page.getByRole("link", { name: "Most installed" })).toHaveAttribute( @@ -72,13 +133,28 @@ test("ranks plugins on browse pages by installs in a window or by newest", async await expect(page.getByRole("main").getByRole("link", { name: /Base2Tone/ })).toContainText( "ago", ); + + await page.getByRole("link", { name: "Recently updated" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(/^All plugins\s*2$/); + await expect(page.getByRole("main").getByRole("link", { name: /Defer|OMP/ })).toHaveText([ + /Defer/, + /OMP/, + ]); +}); + +test("shows each plugin's author with their avatar on browse cards", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /Defer/ }); + await expect(card).toContainText("Tom Gringauz"); + await expect(card.locator('img[src="https://github.com/tomgrin10.png?size=80"]')).toHaveCount(1); }); test("filters by search and clears to all plugins", async ({ page }) => { await page.goto("/"); await openPlugins(page); - await searchPlugins(page, "graphite"); + await submitSearch(page, "graphite"); await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); await expect( page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), @@ -96,7 +172,7 @@ test("filters by search and clears to all plugins", async ({ page }) => { test("keeps the directory's ranking window when searching", async ({ page }) => { await page.goto("/plugins?window=month"); - await searchPlugins(page, "graphite"); + await submitSearch(page, "graphite"); await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( "aria-current", @@ -104,13 +180,97 @@ test("keeps the directory's ranking window when searching", async ({ page }) => ); }); -test("replaces history while typing a search", 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" }); + const clear = page.getByRole("button", { name: "Clear search" }); + await expect(clear).toHaveCount(0); + await searchPlugins(page, "graphite"); await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + await clear.click(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(searchbox).toHaveValue(""); + await expect(searchbox).toBeFocused(); + await expect(clear).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); +}); + +test("searches from the directory on submit, and Back returns to the directory", async ({ + page, +}) => { + await page.goto("/"); + await openPlugins(page); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins$/); + expect(await historyLength(page)).toBe(entries); + + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + expect(await historyLength(page)).toBe(entries + 1); + await page.goBack(); - await expect(page).toHaveURL(/\/$/); + await expect(page).toHaveURL(/\/plugins$/); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +}); + +test("filters browse results as you type in one history entry", async ({ page }) => { + await page.goto("/"); + await page.goto("/plugins/all"); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + expect(await historyLength(page)).toBe(entries + 1); + + await page.goBack(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue(""); + + await page.goForward(); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); + + await page.reload(); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); }); test("keeps old category links working", async ({ page }) => { @@ -149,9 +309,9 @@ test.describe("search engine visits without JavaScript", () => { expect(response?.status()).toBe(200); expect(response?.headers()["cache-control"]).toBe("private, no-store"); expect(response?.headers()["x-robots-tag"]).toBeUndefined(); - await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); + await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toBeVisible(); await expect( - page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), ).toBeVisible(); await expectPageMetadata( page, @@ -240,10 +400,37 @@ test.describe("search engine visits without JavaScript", () => { }); }); +/** Holds the page's scripts so typing lands before hydration; the returned function loads them. */ +async function holdScripts(page: Page): Promise<() => Promise<void>> { + let release!: () => void; + const held = new Promise<void>((resolve) => (release = resolve)); + await page.route(/\.js($|\?)/, async (route) => { + await held; + await route.continue(); + }); + return async () => { + release(); + await page.waitForLoadState("load"); + }; +} + async function searchPlugins(page: Page, term: string) { await page.getByRole("searchbox", { name: "Search plugins" }).fill(term); } +async function submitSearch(page: Page, term: string) { + await searchPlugins(page, term); + await page.keyboard.press("Enter"); +} + +async function typeSearch(page: Page, term: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).pressSequentially(term); +} + +async function historyLength(page: Page): Promise<number> { + return page.evaluate(() => window.history.length); +} + async function expectPageMetadata(page: Page, title: string, path: string) { await expect(page).toHaveTitle(title); await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( @@ -268,19 +455,18 @@ async function expectPageMetadata(page: Page, title: string, path: string) { ); } -test("keeps the directory unlinked until the coordinated announcement", async ({ page }) => { +test("links the directory from the site navigation", async ({ page }) => { await page.goto("/"); await expect( - page.getByRole("banner").getByRole("link", { name: "Plugins", exact: true }), - ).toHaveCount(0); + page.getByRole("navigation").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveAttribute("href", "/plugins"); await expect( page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), - ).toHaveCount(0); - await expect(page.getByRole("link", { name: "Community plugins" })).toHaveAttribute( + ).toHaveAttribute("href", "/plugins"); + await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute( "href", - "https://paseo.cafe", + "/plugins", ); - await expect(page.locator('a[href="/plugins"]')).toHaveCount(0); const response = await page.goto("/plugins"); expect(response?.status()).toBe(200); await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); @@ -290,6 +476,159 @@ test("keeps the directory unlinked until the coordinated announcement", async ({ test.describe("registry fixture layout", () => { test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture"); + test("uses density-aware card thumbnails and keeps detail screenshots original", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!; + const source = plugin.media[0]; + await openPlugins(page); + await expectThumbnailCard( + page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }), + source, + plugin.id, + ); + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /Base2Tone/ }); + await expectThumbnailCard(card, source, plugin.id); + await card.click(); + await expect( + page.getByRole("img", { name: "Base2Tone screenshot 1", exact: true }), + ).toHaveAttribute("src", source); + }); + + test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!; + const [firstImage, video] = plugin.media; + await page.goto(`/plugins/${plugin.id}`); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + await expect(tiles).toHaveCount(3); + const layout = await tiles.evaluateAll(galleryTiles); + expect(layout.map((tile) => tile.href)).toEqual(plugin.media); + expect(new Set(layout.map((tile) => tile.size)).size).toBe(1); + const preview = tiles.nth(1).locator("video"); + await expect(preview).toHaveAttribute("preload", "metadata"); + await expect(preview).not.toHaveAttribute("controls"); + await expect(preview).not.toHaveAttribute("autoplay"); + expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true); + + const viewer = page.getByRole("dialog"); + await tiles.nth(1).click(); + const playing = viewer.getByLabel("Dracula video 2"); + await expect(playing).toHaveAttribute("src", video); + await expect(playing).toHaveAttribute("controls", ""); + await expect.poll(() => playing.evaluate(isPaused)).toBe(false); + // The fixture video is 320x240, so it has to scale up to fill the viewer. + await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + await expect(page.locator("dialog video")).toHaveCount(0); + + await tiles.first().click(); + await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute( + "src", + firstImage, + ); + await viewer.getByRole("button", { name: "Close" }).click(); + await expect(viewer).toBeHidden(); + expect(page.context().pages()).toHaveLength(1); + + const newTab = page.context().waitForEvent("page"); + await tiles.first().click({ modifiers: ["ControlOrMeta"] }); + await expect(await newTab).toHaveURL(firstImage); + await expect(viewer).toBeHidden(); + }); + + test("peeks the next gallery tile at the strip's edge, and fills the row with two", async ({ + page, + }) => { + // Wide screens show two tiles and part of the third; phones show one and part of the second. + for (const [width, expected] of [ + [1280, [1, 1, "peeks"]], + [375, [1, "peeks", 0]], + ] as const) { + await page.setViewportSize({ width, height: 720 }); + await page.goto("/plugins/omercnet/dracula"); + const three = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const shares = await three.evaluateAll(visibleShares); + expect(shares.map((share) => (share > 0.1 && share < 0.5 ? "peeks" : share))).toEqual( + expected, + ); + } + + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/plugins/gpambrozio/herald"); + const two = page.getByRole("link", { name: /^Herald screenshot \d$/ }); + expect(await two.evaluateAll(visibleShares)).toEqual([1, 1]); + }); + + test("steps through the viewer with arrow keys and buttons, wrapping at the ends", async ({ + page, + }) => { + await page.goto("/plugins/omercnet/dracula"); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const viewer = page.getByRole("dialog"); + const shows = (name: string) => + expect( + viewer.getByRole("img", { name, exact: true }).or(viewer.getByLabel(name, { exact: true })), + ).toBeVisible(); + + await tiles.nth(1).click(); + await expect.poll(() => viewer.getByLabel("Dracula video 2").evaluate(isPaused)).toBe(false); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 3"); + await expect(page.locator("dialog video")).toHaveCount(0); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 1"); + await viewer.getByRole("img").click(); + await page.keyboard.press("ArrowLeft"); + await shows("Dracula screenshot 3"); + + await viewer.getByRole("button", { name: "Previous" }).click(); + await shows("Dracula video 2"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 3"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 1"); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + }); + + test("keeps a video that fails to load visible in the viewer", async ({ page }) => { + const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!; + await page.route(plugin.media[0], (route) => route.abort()); + await page.goto(`/plugins/${plugin.id}`); + await page.getByRole("link", { name: "launchd Jobs video 1" }).click(); + const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1"); + await expect(video).toBeVisible(); + await expect(video).toHaveCSS("opacity", "1"); + await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0); + }); + + test("shows the plugin tile on cards when its media has no image", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ }); + // The only image is the author's avatar. + await expect(card.locator("img, video")).toHaveCount(1); + await expect(card.locator('img[src^="https://github.com/gpambrozio.png"]')).toHaveCount(1); + await card.click(); + await expect( + page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"), + ).toHaveAttribute( + "src", + registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0], + ); + }); + + test("features listed plugins above What's new in the registry's order", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); + const featured = page.getByRole("region", { name: "Featured" }); + await expect(featured).toContainText("A selection of hand picked plugins"); + await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]); + }); + test("lists the nine categories in order with counts, and the newest plugins first", async ({ page, }) => { @@ -299,8 +638,145 @@ test.describe("registry fixture layout", () => { CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)), ); await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0"); - await expect( - page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }), - ).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]); + await expect(pluginCards(page, "What’s new")).toHaveText([ + /Base2Tone/, + /Sayr/, + /PromptKit/, + /Defer/, + ]); + }); + + test("lists only plugins updated since they were listed under Recently updated", async ({ + page, + }) => { + await openPlugins(page); + await expect(pluginCards(page, "Recently updated")).toHaveText([/Defer/, /OMP/]); + await page + .getByRole("region", { name: "Recently updated" }) + .getByRole("link", { name: "See all" }) + .click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/); }); }); + +function galleryTiles(links: Element[]) { + return links.map((link) => ({ + href: link.getAttribute("href"), + size: `${link.clientWidth}x${link.clientHeight}`, + })); +} + +/** How much of each tile's width the gallery strip shows, rounded to hundredths. */ +function visibleShares(links: Element[]) { + const strip = links[0].parentElement!.getBoundingClientRect(); + return links.map((link) => { + const tile = link.getBoundingClientRect(); + const shown = Math.min(tile.right, strip.right) - Math.max(tile.left, strip.left); + return Math.round((Math.max(shown, 0) / tile.width) * 100) / 100; + }); +} + +/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */ +function viewerFill(media: HTMLElement) { + const box = media.getBoundingClientRect(); + return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85)); +} + +function isPaused(video: HTMLVideoElement) { + return video.paused; +} + +async function expectThumbnailCard(card: Locator, source: string, id: string) { + // Card screenshots are decorative and hidden from the accessibility tree. + const image = card.locator("img").first(); + const path = (width: number) => + `/plugins/thumb/${width}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`; + await expect(image).toHaveAttribute("src", path(592)); + await expect(image).toHaveAttribute("srcset", `${path(592)} 1x, ${path(1184)} 2x`); + await expect(image).toHaveAttribute("loading", "lazy"); + await expect(image).toHaveAttribute("decoding", "async"); + await image.scrollIntoViewIfNeeded(); + await expect + .poll( + () => + image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0), + { timeout: 15_000 }, + ) + .toBe(true); + const box = await image.evaluate((element) => { + const rect = element.parentElement!.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(box.width).toBeGreaterThan(0); + expect(box.width / box.height).toBeCloseTo(1.6, 2); +} + +test("keeps author content inert through SSR and hydration", async ({ + page, + request, +}, testInfo) => { + await verifyPluginOverviewResponse(request); + await openUntrustedPlugin(page); + await expectPluginOverviewSafe(page); + await page.reload(); + await expectPluginOverviewSafe(page); + await testInfo.attach("plugin-overview", { + body: await page.screenshot(), + contentType: "image/png", + }); +}); + +async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) { + const response = await request.get("/plugins/security/overview"); + expect(response.status()).toBe(200); + const html = await response.text(); + expect(html).not.toContain("<script>globalThis.__overviewExecuted=1</script>"); + expect(html).toContain("\\x3C/script>"); +} + +async function openUntrustedPlugin(page: Page) { + await page.goto("/plugins/security/overview"); + await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible(); +} + +async function expectPluginOverviewSafe(page: Page) { + expect( + await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted), + ).toBeUndefined(); + // Security assertions inspect every emitted attribute, including inaccessible injected elements. + const violations = await page.locator(".docs-prose").evaluate((root) => { + const bad: string[] = []; + for (const element of root.querySelectorAll("*")) { + if ( + ["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes( + element.tagName, + ) + ) + bad.push(element.tagName); + for (const attr of element.attributes) { + if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name); + if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://")) + bad.push(attr.value); + } + if ( + element.tagName === "A" && + (element.getAttribute("rel") !== "noopener noreferrer nofollow" || + element.getAttribute("target") !== "_blank") + ) + bad.push("unsafe anchor"); + } + return bad; + }); + expect(violations).toEqual([]); + await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Report a bug" })).toHaveCount(0); + await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name); + await expect( + page.getByRole("link", { name: securityPlugin.author.name, exact: true }), + ).toHaveAttribute("href", "/plugins/security"); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + securityPlugin.description, + ); +} diff --git a/packages/website/e2e/registry-server.mjs b/packages/website/e2e/registry-server.mjs index 211fee9f38..228f3dd859 100644 --- a/packages/website/e2e/registry-server.mjs +++ b/packages/website/e2e/registry-server.mjs @@ -1,6 +1,15 @@ import { createServer } from "node:http"; import registry from "./registry.fixture.json" with { type: "json" }; +import securityPlugin from "./overview.fixture.json" with { type: "json" }; + +import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" }; +securityPlugin.readme += + "\n\n" + + overviewCorpus + .map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```") + .join("\n\n"); + // Serve the published registry contract so SSR and browser navigation use the same data. createServer((request, response) => { response.setHeader("Content-Type", "application/json"); @@ -8,7 +17,9 @@ createServer((request, response) => { response.end(JSON.stringify(registry)); return; } - const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`); + const plugin = [securityPlugin, ...registry.plugins].find( + (entry) => request.url === `/plugins/${entry.id}.json`, + ); response.statusCode = plugin ? 200 : 404; response.end(JSON.stringify(plugin ?? { error: "Plugin not found" })); }).listen(8188, "127.0.0.1"); diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json index 6b75599d3d..4ad4eabf1b 100644 --- a/packages/website/e2e/registry.fixture.json +++ b/packages/website/e2e/registry.fixture.json @@ -5,6 +5,12 @@ "url": "https://plugins.paseo.sh" }, "generatedAt": "2026-10-04T15:55:47.882Z", + "featured": [ + "omercnet/dracula", + "gpambrozio/herald", + "acme/not-published", + "gpambrozio/launchd-jobs" + ], "categories": [ { "slug": "daemon-management", @@ -74,13 +80,13 @@ "name": "alhassanaraouf", "github": "alhassanaraouf" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" ], "submittedAt": "2026-09-24T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-24T17:12:05.467Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-24T00:00:00.000Z", + "publishedAt": "2026-09-24T00:00:00.000Z", + "updatedAt": "2026-09-24T00:00:00.000Z", "installs": 0, "readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md." }, @@ -106,14 +112,14 @@ "name": "Tom Lundy", "github": "dorasto" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png", "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T10:39:28.935Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", "installs": 0, "readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks" }, @@ -139,14 +145,14 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T17:46:20.500Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions" }, @@ -172,14 +178,14 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T19:03:31.943Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel" }, @@ -205,13 +211,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ - "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png" + "media": [ + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T17:56:41.061Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel" }, @@ -237,13 +243,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T18:03:14.979Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter" }, @@ -269,11 +275,11 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T22:25:58.192Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it" }, @@ -298,14 +304,14 @@ "name": "hungcuong9125", "github": "hungcuong9125" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png", "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-30T02:57:56.473Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", "installs": 0, "readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer." }, @@ -331,14 +337,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:35.639Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster." }, @@ -364,14 +370,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:29.149Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace." }, @@ -397,14 +403,15 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png", + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:18.185Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo." }, @@ -430,14 +437,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:06.720Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-18T00:00:00.000Z", + "publishedAt": "2026-09-18T00:00:00.000Z", + "updatedAt": "2026-09-18T00:00:00.000Z", "installs": 0, "readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees" }, @@ -463,14 +470,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:36.267Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-10-03T09:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-10-03T09:00:00.000Z", "installs": 0, "readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling." }, @@ -496,11 +503,11 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:04.327Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces." }, @@ -526,14 +533,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:38:48.289Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-18T00:00:00.000Z", + "publishedAt": "2026-09-18T00:00:00.000Z", + "updatedAt": "2026-09-18T00:00:00.000Z", "installs": 0, "readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client." }, @@ -559,11 +566,11 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:15.719Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host." }, @@ -588,20 +595,20 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T09:59:16.553Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-10-03T15:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-10-03T15:00:00.000Z", "installs": 0, "readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset." }, { "id": "tomgrin10/graphite", "name": "Graphite", - "description": "Workspace-native Graphite stack status and PR action radar for Paseo.", + "description": "Workspace-native Graphite stack status and PR action radar for Paseo. <p align=\"center\"> <picture> <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/xpufx/paseo/main/plugins/slash/docs/screenshots/slash-logo-for-dark-bg.png\"> <source media=\"(prefers-color-scheme: light)\" srcset", "artifact": { "kind": "npm", "package": "paseo-graphite", @@ -619,11 +626,11 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-23T10:36:50.112Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", "installs": 0, "readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo." } diff --git a/packages/website/package.json b/packages/website/package.json index a2159f2062..1677b3c0fc 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.11.0-beta.3", + "version": "0.11.2", "private": true, "type": "module", "scripts": { @@ -38,6 +38,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.1", + "parse5": "^7.3.0", "typescript": "^5.9.3", "vite": "^7.3.3", "vite-tsconfig-paths": "^5.1.4", diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index c1cfbb4b6c..0ba1cdbc7f 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -1,188 +1,270 @@ { - "$ref": "#/definitions/PaseoConfigV1", - "definitions": { - "PaseoConfigV1": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "type": "number", + "const": 1 + }, + "daemon": { "type": "object", "properties": { - "$schema": { + "listen": { "type": "string" }, - "version": { - "type": "number", - "const": 1 + "hostnames": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, - "daemon": { + "allowedHosts": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "trustedProxies": { + "description": "Express trusted proxy setting for X-Forwarded-* headers. Defaults to [\"loopback\"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.", + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mcp": { "type": "object", "properties": { + "enabled": { + "type": "boolean" + }, + "injectIntoAgents": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "browserTools": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "git": { + "type": "object", + "properties": { + "maxProcessesPerSecond": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxProcessConcurrency": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "autoArchiveAfterMerge": { + "type": "boolean" + }, + "enableTerminalAgentHooks": { + "type": "boolean" + }, + "appendSystemPrompt": { + "type": "string" + }, + "terminalProfiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "type": "string" + } + }, + "required": ["id", "name", "command"], + "additionalProperties": {} + } + }, + "agentProfiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "color": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modeId": { + "type": "string" + }, + "thinkingOptionId": { + "type": "string" + }, + "featureValues": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "notes": { + "type": "string" + } + }, + "required": ["id", "name", "provider"], + "additionalProperties": {} + } + }, + "cors": { + "type": "object", + "properties": { + "allowedOrigins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "relay": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + }, + "publicEndpoint": { + "type": "string" + }, + "useTls": { + "type": "boolean" + }, + "publicUseTls": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "serviceProxy": { + "type": "object", + "properties": { + "enabled": { + "description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + "type": "boolean" + }, "listen": { + "description": "Optional service-only listener address. Presence enables the standalone service proxy listener.", "type": "string" }, - "hostnames": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "allowedHosts": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "trustedProxies": { - "description": "Express trusted proxy setting for X-Forwarded-* headers. Defaults to [\"loopback\"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.", - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "mcp": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "injectIntoAgents": { - "type": "boolean" - } - }, - "additionalProperties": true - }, - "git": { - "type": "object", - "properties": { - "maxProcessesPerSecond": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "maxProcessConcurrency": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false - }, - "autoArchiveAfterMerge": { - "type": "boolean" - }, - "enableTerminalAgentHooks": { - "type": "boolean" - }, - "appendSystemPrompt": { - "type": "string" - }, - "cors": { - "type": "object", - "properties": { - "allowedOrigins": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "relay": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "endpoint": { - "type": "string" - }, - "publicEndpoint": { - "type": "string" - }, - "useTls": { - "type": "boolean" - }, - "publicUseTls": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "serviceProxy": { - "type": "object", - "properties": { - "enabled": { - "description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", - "type": "boolean" - }, - "listen": { - "description": "Optional service-only listener address. Presence enables the standalone service proxy listener.", - "type": "string" - }, - "publicBaseUrl": { - "description": "Optional public base URL for service host aliases.", - "type": "string", - "format": "uri" - } - }, - "additionalProperties": false - }, - "auth": { - "type": "object", - "properties": { - "password": { - "type": "string", - "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" - } - }, - "additionalProperties": false + "publicBaseUrl": { + "description": "Optional public base URL for service host aliases.", + "type": "string", + "format": "uri" } }, "additionalProperties": false }, - "app": { + "auth": { "type": "object", "properties": { - "baseUrl": { - "type": "string" + "password": { + "type": "string", + "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" } }, "additionalProperties": false - }, - "worktrees": { + } + }, + "additionalProperties": false + }, + "app": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + } + }, + "additionalProperties": false + }, + "providers": { + "type": "object", + "properties": { + "openai": { "type": "object", "properties": { - "root": { + "apiKey": { "type": "string", "minLength": 1 - } - }, - "additionalProperties": false - }, - "providers": { - "type": "object", - "properties": { - "openai": { + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "stt": { "type": "object", "properties": { "apiKey": { @@ -192,42 +274,18 @@ "baseUrl": { "type": "string", "minLength": 1 - }, - "stt": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false } }, "additionalProperties": false }, - "local": { + "tts": { "type": "object", "properties": { - "modelsDir": { + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { "type": "string", "minLength": 1 } @@ -237,256 +295,466 @@ }, "additionalProperties": false }, - "agents": { + "local": { "type": "object", "properties": { - "providers": { - "type": "object", - "additionalProperties": { + "modelsDir": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "pluginRegistries": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "authorization": { + "type": "string", + "minLength": 1 + } + }, + "required": ["authorization"] + } + }, + "pluginsEnabled": { + "type": "boolean" + }, + "plugins": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "directory" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "enabled": { + "type": "boolean" + } + }, + "required": ["source", "path"], + "additionalProperties": false + } + ] + } + }, + "worktrees": { + "type": "object", + "properties": { + "root": { + "type": "string", + "minLength": 1 + }, + "servicePorts": { + "type": "object", + "properties": { + "range": { + "type": "string", + "pattern": "^(\\d{1,5})-(\\d{1,5})$" + }, + "portScript": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agents": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "extends": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "command": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "env": { "type": "object", - "properties": { - "extends": { - "type": "string" - }, - "label": { - "type": "string" - }, - "description": { - "type": "string" - }, - "command": { - "type": "array", - "items": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "minLength": 1 }, - "minItems": 1 - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "models": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "label": { - "type": "string", - "minLength": 1 - }, - "description": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "thinkingOptions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "description": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - } - }, - "required": ["id", "label"], - "additionalProperties": false - } - } - }, - "required": ["id", "label"], - "additionalProperties": false - } - }, - "additionalModels": { - "type": "array", - "items": { - "$ref": "#/definitions/PaseoConfigV1/properties/agents/properties/providers/additionalProperties/properties/models/items" - } - }, - "disallowedTools": { - "type": "array", - "items": { - "type": "string" - } - }, - "paseoTools": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "disabledTools": { - "type": "array", - "items": { - "type": "string" - } - } + "label": { + "type": "string", + "minLength": 1 }, - "additionalProperties": false + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "thinkingOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": ["id", "label"] + } + } }, + "required": ["id", "label"] + } + }, + "additionalModels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "thinkingOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": ["id", "label"] + } + } + }, + "required": ["id", "label"] + } + }, + "disallowedTools": { + "type": "array", + "items": { + "type": "string" + } + }, + "paseoTools": { + "type": "object", + "properties": { "enabled": { "type": "boolean" }, - "order": { - "type": "number" + "disabledTools": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "enabled": { + "type": "boolean" + }, + "order": { + "type": "number" + } + } + } + }, + "catalogRefreshTimeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647 + }, + "metadataGeneration": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "thinkingOptionId": { + "type": "string", + "minLength": 1 } }, + "required": ["provider"], "additionalProperties": false } - }, - "catalogRefreshTimeoutMs": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647 } }, "additionalProperties": false }, - "features": { + "skills": { "type": "object", "properties": { - "dictation": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "stt": { + "selection": { + "oneOf": [ + { "type": "object", "properties": { - "provider": { - "allOf": [ - { - "type": "string" - }, - { - "type": "string", - "enum": ["openai", "local"] - } - ] - }, - "model": { + "mode": { "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - }, - "confidenceThreshold": { - "type": "number" + "const": "all" } }, + "required": ["mode"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "custom" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "skills"], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "features": { + "type": "object", + "properties": { + "dictation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" } }, "additionalProperties": false + } + }, + "additionalProperties": false + }, + "voiceMode": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "voiceMode": { + "llm": { "type": "object", "properties": { - "enabled": { - "type": "boolean" + "provider": { + "type": "string" }, - "llm": { - "type": "object", - "properties": { - "provider": { - "type": "string" - }, - "model": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "stt": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "turnDetection": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "voice": { - "type": "string", - "enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] - }, - "speakerId": { - "type": "integer" - }, - "speed": { - "type": "number" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "webUi": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "distDir": { + "model": { "type": "string", "minLength": 1 } }, "additionalProperties": false + }, + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "turnDetection": { + "type": "object", + "properties": { + "provider": { + "type": "string" + } + }, + "additionalProperties": false + }, + "tts": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "voice": { + "type": "string", + "minLength": 1 + }, + "speakerId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "speed": { + "type": "number" + } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "log": { + "webUi": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "distDir": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "log": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error", "fatal"] + }, + "format": { + "type": "string", + "enum": ["pretty", "json"] + }, + "console": { "type": "object", "properties": { "level": { @@ -496,42 +764,32 @@ "format": { "type": "string", "enum": ["pretty", "json"] + } + }, + "additionalProperties": false + }, + "file": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error", "fatal"] }, - "console": { + "path": { + "type": "string", + "minLength": 1 + }, + "rotate": { "type": "object", "properties": { - "level": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" - }, - "format": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/format" - } - }, - "additionalProperties": false - }, - "file": { - "type": "object", - "properties": { - "level": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" - }, - "path": { + "maxSize": { "type": "string", "minLength": 1 }, - "rotate": { - "type": "object", - "properties": { - "maxSize": { - "type": "string", - "minLength": 1 - }, - "maxFiles": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false + "maxFiles": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "additionalProperties": false @@ -543,5 +801,6 @@ "additionalProperties": false } }, - "$schema": "http://json-schema.org/draft-07/schema#" + "additionalProperties": false, + "title": "PaseoConfigV1" } diff --git a/packages/website/src/components/alternative-page.tsx b/packages/website/src/components/alternative-page.tsx index ec583fefaf..3322f869e1 100644 --- a/packages/website/src/components/alternative-page.tsx +++ b/packages/website/src/components/alternative-page.tsx @@ -32,7 +32,9 @@ function AlternativePageContent({ slug }: { slug: string }) { return ( <SiteShell width="default"> - <DocsMarkdown>{page.content}</DocsMarkdown> + <div className="comparison-prose"> + <DocsMarkdown>{page.content}</DocsMarkdown> + </div> </SiteShell> ); } diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index a1fcc18ef3..a5b573a1d6 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -4,13 +4,12 @@ import { ArrowRight, Blocks, Bot, - BookOpen, Braces, - Coffee, Compass, ExternalLink, Gem, GitFork, + LayoutPanelLeft, Laptop, Lock, Merge, @@ -19,6 +18,7 @@ import { Smartphone, Terminal, Users, + Workflow, type LucideIcon, } from "lucide-react"; import { @@ -477,7 +477,7 @@ function TurnkeySection() { <div className="space-y-0.5"> <h3 className="text-xl font-medium text-white/90">Desktop app</h3> <p className="max-w-lg text-sm leading-relaxed text-white/50"> - The one click experience, download the app and it just works + The one click experience, download the app and it just works, no server required </p> </div> </div> @@ -487,8 +487,8 @@ function TurnkeySection() { <div className="grid gap-4 md:grid-cols-3"> <TurnkeyExtensionCard icon={Smartphone} - title="Mobile and web" - description="Connect to the same workspaces from any client" + title="Mobile" + description="Full client and native, not a WebView" ctaHref="/download" ctaLabel="Download" /> @@ -793,92 +793,105 @@ function SdkAutomationExample() { function ExtensibleSection() { return ( - <FeatureSection title="Make it yours" description="Extend Paseo to work just the way you want"> - <div className="grid gap-4 md:grid-cols-2"> - <ExtensibleCard - icon={Puzzle} - title="Plugins" - description="Plugins can add server-side functionality and modify the client with custom components. They work across all clients, including mobile" - links={PLUGIN_CARD_LINKS} - /> - <ExtensibleCard - icon={GitFork} - title="Fork the repo" - description="Paseo is licensed under Apache 2.0. You can inspect the implementation, fork the project, and adapt it to your workflow or organization" - links={FORK_CARD_LINKS} - /> + <FeatureSection + title="Extend it with plugins" + description="Add new panels and functionality that work in desktop, web and mobile" + > + <div className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]"> + <div className="flex flex-col gap-6 border-b border-white/10 p-6 sm:flex-row sm:items-center sm:justify-between md:p-8"> + <div className="flex items-center gap-4"> + <div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground"> + <Puzzle className="h-6 w-6" strokeWidth={1.5} /> + </div> + <h3 className="text-xl font-medium text-white/90">Plugins</h3> + </div> + <div className="flex shrink-0 items-center gap-3"> + <a + href={PLUGINS_BROWSE.href} + {...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} + className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90" + > + Browse plugins + <ArrowRight className="h-3.5 w-3.5" /> + </a> + <a + href="/docs/plugins" + className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10" + > + Plugin docs + </a> + </div> + </div> + + <div className="p-6 md:p-8"> + <div className="grid gap-4 md:grid-cols-3"> + {PLUGIN_CAPABILITIES.map((capability) => ( + <PluginCapabilityCard key={capability.title} {...capability} /> + ))} + </div> + </div> + + <div className="flex flex-col gap-3 border-t border-white/10 px-6 py-5 sm:flex-row sm:items-center sm:justify-between md:px-8"> + <p className="text-sm text-white/45">Paseo is Apache 2.0.</p> + <a + href="https://github.com/getpaseo/paseo" + target="_blank" + rel="noopener noreferrer" + className="inline-flex shrink-0 items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground" + > + <GitFork className="h-4 w-4" strokeWidth={1.5} /> + Fork the repo + <ExternalLink className="h-3.5 w-3.5 opacity-70" /> + </a> + </div> </div> </FeatureSection> ); } -interface ExtensibleCardLink { - href: string; - label: string; - icon: React.ComponentType<{ className?: string }>; - external?: boolean; - /** Accent links stand out without hover, for destinations worth noticing. */ - accent?: boolean; -} +const PLUGINS_BROWSE = PLUGINS_LINKED + ? { href: "/plugins", external: false } + : { href: "https://paseo.cafe", external: true }; -const PLUGIN_CARD_LINKS: ReadonlyArray<ExtensibleCardLink> = [ - { href: "/docs/plugins", label: "Plugin documentation", icon: BookOpen }, - PLUGINS_LINKED - ? { href: "/plugins", label: "Community plugins", icon: Blocks, accent: true } - : { - href: "https://paseo.cafe", - label: "Community plugins", - icon: Coffee, - external: true, - accent: true, - }, -]; - -const FORK_CARD_LINKS: ReadonlyArray<ExtensibleCardLink> = [ +const PLUGIN_CAPABILITIES: ReadonlyArray<{ + icon: LucideIcon; + title: string; + description: string; +}> = [ { - href: "https://github.com/getpaseo/paseo", - label: "View the repository", - icon: GitHubIcon, - external: true, + icon: LayoutPanelLeft, + title: "UI", + description: "Screens, workspace panels, sidebar items, slash commands, and themes", + }, + { + icon: Workflow, + title: "Agent lifecycle", + description: + "Change configuration, environment, and MCP servers. Answer permissions. Follow up when a turn ends", + }, + { + icon: Bot, + title: "Providers", + description: "Add a coding agent as a provider, used the same way as the built-in ones", }, ]; -function ExtensibleCard({ +function PluginCapabilityCard({ icon: Icon, title, description, - links, }: { icon: LucideIcon; title: string; description: string; - links: ReadonlyArray<ExtensibleCardLink>; }) { return ( - <div className="flex min-h-64 flex-col rounded-xl border border-white/10 bg-white/[0.025] p-6"> - <div className="mb-8 text-muted-foreground"> - <Icon className="h-6 w-6" strokeWidth={1.5} /> + <div className="flex flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5"> + <div className="mb-5 text-muted-foreground"> + <Icon className="h-5 w-5" strokeWidth={1.5} /> </div> - <h3 className="text-lg font-medium text-white/85">{title}</h3> + <h3 className="font-medium text-white/85">{title}</h3> <p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p> - <div className="mt-auto flex flex-col items-start gap-3 pt-6"> - {links.map((link) => ( - <a - key={link.href} - href={link.href} - {...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} - className={ - link.accent - ? "inline-flex items-center gap-2 text-sm text-emerald-300/85 transition-colors hover:text-emerald-200" - : "inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground" - } - > - <link.icon className="h-4 w-4" /> - {link.label} - {link.external ? <ExternalLink className="h-3.5 w-3.5 opacity-70" /> : null} - </a> - ))} - </div> </div> ); } @@ -902,8 +915,7 @@ const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: { icon: Merge, title: "Unified", - description: - "Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, and more, all used the same way.", + description: "Every coding agent, used the same way. Switch providers any time.", }, { icon: Blocks, @@ -923,17 +935,17 @@ function PhilosophySection() { title="Philosophy" description="What Paseo stands for, and what every feature is built on" > - <div className="grid gap-4 md:grid-cols-2"> + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {PRINCIPLES.map((principle) => ( <div key={principle.title} - className="rounded-xl border border-white/10 bg-white/[0.025] p-6" + className="rounded-xl border border-white/10 bg-white/[0.025] p-5" > - <div className="mb-8 text-extra-muted-foreground"> - <principle.icon className="h-6 w-6" strokeWidth={1.5} /> + <div className="flex items-center gap-3"> + <principle.icon className="h-5 w-5 text-extra-muted-foreground" strokeWidth={1.5} /> + <h3 className="font-medium text-white/85">{principle.title}</h3> </div> - <h3 className="text-lg font-medium text-white/85">{principle.title}</h3> - <p className="mt-2 text-sm leading-relaxed text-white/45">{principle.description}</p> + <p className="mt-3 text-sm leading-relaxed text-white/45">{principle.description}</p> </div> ))} </div> diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index 62daa6ebd0..198a60c9ce 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -16,10 +16,10 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { const widthClasses = WIDTH_CLASSES[width]; const alternatives = getAlternativePages(); return ( - <footer className={`${widthClasses} mx-auto`}> + <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/site-shell.tsx b/packages/website/src/components/site-shell.tsx index 662f9f50f9..2c0a38f795 100644 --- a/packages/website/src/components/site-shell.tsx +++ b/packages/website/src/components/site-shell.tsx @@ -18,7 +18,7 @@ export function SiteShell({ children, width }: SiteShellProps) { return ( <div className="min-h-screen bg-background"> <main className={mainClasses}> - <div className="mb-12"> + <div className="mb-20"> <SiteHeader /> </div> {children} diff --git a/packages/website/src/components/sponsorship.tsx b/packages/website/src/components/sponsorship.tsx index 449742bffb..05a04362de 100644 --- a/packages/website/src/components/sponsorship.tsx +++ b/packages/website/src/components/sponsorship.tsx @@ -53,9 +53,15 @@ const MAINTAINER_LINK = ( export function FounderNote() { return ( <div className="max-w-2xl space-y-5 leading-relaxed text-white/70"> + <p>Paseo is an independent project used by tens of thousands of developers daily.</p> <p> - I build Paseo on my own. There are no investors, no board and no company behind it, and I - have turned down funding offers to keep it that way. + It is maintained by one person, full time, and shaped by the community. There are no + investors, no board and no company behind it. I have turned down funding offers to keep it + that way. + </p> + <p> + The community helps Paseo grow through feedback, testing, bug reports, code contributions + and plugins. Your sponsorship helps me keep improving and maintaining it. </p> <p> A tool that sits between you and your code, your keys and your machines has to stay neutral, @@ -67,7 +73,7 @@ export function FounderNote() { <a href="/hub" className="underline hover:text-white/90"> Paseo Hub </a> - , an optional hosted service. Your support is what lets me work on Paseo full time. + , an optional hosted service. Your support is what lets me keep working on it. </p> <p className="text-white/50">{MAINTAINER_LINK}, maintainer</p> </div> @@ -79,7 +85,6 @@ interface BackingOption { name: string; icon: React.ComponentType<{ className?: string }>; external: boolean; - primary?: boolean; } const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [ @@ -88,7 +93,6 @@ const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [ name: "GitHub Sponsors", icon: GitHubIcon, external: true, - primary: true, }, { href: OPEN_COLLECTIVE_URL, @@ -127,11 +131,7 @@ export function BackingOptions() { key={option.href} href={option.href} {...(option.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} - className={`flex items-center gap-4 rounded-xl border p-4 transition-colors ${ - option.primary - ? "border-white/25 bg-white/[0.06] hover:border-white/40 hover:bg-white/[0.08]" - : "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]" - }`} + className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] p-4 transition-colors hover:border-white/20 hover:bg-white/[0.05]" > <option.icon className="h-6 w-6 shrink-0 text-white/60" /> <p className="flex items-center gap-1.5 font-medium text-white"> @@ -237,18 +237,19 @@ export function SponsorSection() { <SectionHeading as="h2" title="Sponsor Paseo" /> <div className="space-y-10"> <div className="max-w-2xl space-y-5 leading-relaxed text-white/70"> + <p>Paseo is an independent project used by tens of thousands of developers daily.</p> <p> - I build Paseo on my own, with no investors and no company behind it, and I have turned - down funding offers to keep it that way. A tool that sits between you and your code, - your keys and your machines has to stay neutral, and funding creates pressure to - monetize. + It is maintained by one person, full time, and shaped by the community. There are no + investors and no company behind it. I have turned down funding to keep it that way. A + tool that sits between you and your code, your keys and your machines has to stay + neutral, and funding creates pressure to monetize. </p> <p> Paseo is self-funded through sponsorship and{" "} <a href="/hub" className="underline hover:text-white/90"> Paseo Hub </a> - , and your support is what lets me work on it full time. + . Your support is what lets me keep working on it. </p> <p className="text-white/50">{MAINTAINER_LINK}, maintainer</p> </div> diff --git a/packages/website/src/content/alternatives/claude-desktop.md b/packages/website/src/content/alternatives/claude-desktop.md index 0d607daea7..13a403301c 100644 --- a/packages/website/src/content/alternatives/claude-desktop.md +++ b/packages/website/src/content/alternatives/claude-desktop.md @@ -9,7 +9,7 @@ order: 55 Claude Desktop is Anthropic's app for Claude Chat, Cowork, and Claude Code on macOS, Windows, and Linux. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -45,7 +45,7 @@ Both Claude Desktop and Paseo are available on macOS, Windows, and Linux. ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Claude has iOS and Android apps. Dispatch can start local Claude Code work through an active Claude Desktop host or start a cloud session on Anthropic's infrastructure. @@ -84,7 +84,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase" Both tools support parallel coding sessions, including Git worktrees. -Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions. +Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions. ## Voice @@ -94,25 +94,31 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel ## Comparison -| | Paseo | Claude Desktop | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ | -| License | Open source (Apache-2.0) | Not published as open source | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile coding workflow | Native Paseo workspace on iOS and Android | Dispatch and Cowork in Claude mobile | -| Coding agents | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code | -| Product account required | No | Claude account | -| Cloud agent | Cloud waitlist | Claude Cowork and remote sessions | -| Required cloud connection | No | Claude account and services | -| Machines you control | Laptop, workstation, VM, server, or home lab | Local machine or SSH host | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | No | -| Split panes and tabs | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser / preview | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | PR monitoring and merge workflows | -| CLI | Run, `--host`, ls, send, schedule, loop | Claude Code CLI | -| Remote transport | Direct connection or end-to-end encrypted relay | Anthropic Remote and cloud services | -| Application plugins | Server code and native client components | No | -| Self-hosted control plane | Daemon, web client, and relay | No | +| | Paseo | Claude Desktop | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| License | Open source (Apache-2.0) | Not published as open source | +| Account required | No | Claude account | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Claude app (Dispatch and Cowork) | +| CLI | Yes (everything the app does) | Yes (Claude Code CLI) | +| Remote machines | Yes (install the daemon anywhere) | Yes (SSH host, Anthropic-managed cloud sessions) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Anthropic Remote | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | Yes | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | Yes (file editor) | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | PR monitoring and merge workflows | +| In-app browser | Yes (element picker, agent browser tools) | Yes (live app preview) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | Yes (scheduled tasks) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Yes (in Claude's mobile and app surfaces) | +| Telemetry | None | - | See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Paseo vs Conductor](/alternatives/conductor). diff --git a/packages/website/src/content/alternatives/codex-app.md b/packages/website/src/content/alternatives/codex-app.md index 36a3b6935b..7f985af7f8 100644 --- a/packages/website/src/content/alternatives/codex-app.md +++ b/packages/website/src/content/alternatives/codex-app.md @@ -9,7 +9,7 @@ order: 54 OpenAI provides Codex through the ChatGPT desktop app on macOS, Windows, and Linux, with local, worktree, cloud, and remote workflows. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -45,7 +45,7 @@ OpenAI's desktop app and Paseo are available on macOS, Windows, and Linux. ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. ChatGPT Remote can start and continue Codex chats, approve actions, and review outputs from iOS and Android through a connected desktop host. @@ -55,7 +55,7 @@ Both tools support Git worktrees for parallel work. Codex App creates Codex-managed worktrees under `$CODEX_HOME/worktrees` and supports local environment setup scripts and project actions through `.codex` configuration. -Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web.fix-auth.my-app.localhost` so parallel services don't fight for ports. +Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web--fix-auth--my-app.localhost` so parallel services don't fight for ports. ## GitHub and review @@ -88,23 +88,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe ## Comparison -| | Paseo | Codex App | -| ------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------- | -| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile coding workflow | Native Paseo workspace on iOS and Android | ChatGPT Remote on iOS and Android | -| Providers | Codex, Claude Code, OpenCode, Pi + 30+ via ACP catalog + custom | Codex | -| Product account required | No | OpenAI account | -| Required Paseo cloud connection | No | Not applicable | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | No | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Commit, push, PR | -| CLI | Run, `--host`, ls, send, schedule, loop | Codex CLI | -| Remote transport | Direct connection or end-to-end encrypted relay | OpenAI Remote | -| Application plugins | Server code and native client components | No | -| Voice | Dictation and realtime voice | Dictation | -| Self-hosted control plane | Daemon, web client, and relay | No | +| | Paseo | Codex App | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary | +| Account required | No | OpenAI account | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | ChatGPT app (Remote) | +| CLI | Yes (everything the app does) | Yes (Codex CLI) | +| Remote machines | Yes (install the daemon anywhere) | Yes (connected host, OpenAI-managed cloud) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | OpenAI Remote | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Codex | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | Commit, push, PR | +| In-app browser | Yes (element picker, agent browser tools) | Yes | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Dictation | +| Telemetry | None | - | See also: [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [all supported providers](/agents). diff --git a/packages/website/src/content/alternatives/conductor.md b/packages/website/src/content/alternatives/conductor.md index 9c0a30af34..553d4e4d28 100644 --- a/packages/website/src/content/alternatives/conductor.md +++ b/packages/website/src/content/alternatives/conductor.md @@ -9,7 +9,7 @@ order: 50 Conductor is a proprietary macOS app for running Claude Code, Codex, Cursor, and OpenCode in parallel Git worktrees and managed cloud workspaces. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -67,11 +67,11 @@ Conductor lists its API as a Pro feature but does not document a user-facing CLI Both tools isolate parallel agents in git worktrees. -Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions. +Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions. ## Mobile -Paseo ships native iOS and Android apps today. Conductor lists its mobile app as coming soon under the Pro plan. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Conductor lists its mobile app as coming soon under the Pro plan. ## Voice @@ -79,21 +79,31 @@ Paseo supports local speech-to-text and text-to-speech. Conductor does not curre ## Comparison -| | Paseo | Conductor | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ | -| License | Open source (Apache-2.0) | Closed source | -| Platforms | macOS, Linux, Windows | macOS only | -| Native mobile | iOS, Android | Coming soon under Pro | -| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex, Cursor, OpenCode | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | — | -| Split panes and tabs | Yes | — | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | — | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes | -| CLI | Run, `--host`, ls, send, schedule, loop | — | -| Application plugins | Server code and native client components | No | -| Local voice | Yes | Not documented | -| Self-hosted daemon | Yes | — | +| | Paseo | Conductor | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------- | +| License | Open source (Apache-2.0) | Closed source | +| Account required | No | Yes | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS only) | +| Mobile app | Yes (native, full parity with desktop) | Coming soon under Pro | +| CLI | Yes (everything the app does) | API under Pro | +| Remote machines | Yes (install the daemon anywhere) | No (managed cloud workspaces under Pro) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | No | +| SSH access | Yes | No | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex, Cursor, OpenCode | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | - | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub | +| In-app browser | Yes (element picker, agent browser tools) | - | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | - | +| Telemetry | None | - | See also: [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/content/alternatives/happy-coder.md b/packages/website/src/content/alternatives/happy-coder.md index b92a3f651e..0c5bbd1b32 100644 --- a/packages/website/src/content/alternatives/happy-coder.md +++ b/packages/website/src/content/alternatives/happy-coder.md @@ -9,7 +9,7 @@ order: 53 Happy Coder connects Claude Code and Codex sessions across its macOS, web, iOS, and Android apps. It wraps the agent CLI on your laptop and syncs sessions over an end-to-end encrypted relay. Open source under MIT. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -37,7 +37,7 @@ Paseo's app handles commit, push, opening PRs, watching checks and reviews, and ## Mobile -Both tools ship native iOS and Android apps. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Happy Coder also ships native iOS and Android apps. ## Providers @@ -53,7 +53,7 @@ Happy Coder does not document an application extension API for adding both serve ## Worktrees and services -Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for the same port. +Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for the same port. Happy Coder can start an agent in a selected machine path, including an existing Git worktree. It does not currently create or manage the worktree lifecycle. @@ -79,21 +79,31 @@ Both products support voice interaction. Paseo can run speech-to-text and text-t ## Comparison -| | Paseo | Happy Coder | -| ---------------------------- | --------------------------------------------------------------------------------------- | --------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop app | macOS, Linux, Windows | macOS | -| Native mobile | iOS, Android | iOS, Android | -| Architecture | Daemon owns agent lifecycle | Wraps the agent CLI | -| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex | -| Split workspace | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser / preview | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | — | -| Managed Git worktrees | Yes | Existing worktree paths | -| Per-worktree dev server URLs | Yes | — | -| CLI | Run, `--host`, ls, send, schedule, loop | Launch and control sessions | -| Application plugins | Server code and native client components | No | -| Voice | Local or configured cloud speech | Yes | +| | Paseo | Happy Coder | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS) | +| Mobile app | Yes (native, full parity with desktop) | Yes (native) | +| CLI | Yes (everything the app does) | Yes (launch and control sessions) | +| Remote machines | Yes (install the daemon anywhere) | - | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (end-to-end encrypted) | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex | +| Parallel agents | Yes (isolated worktrees, across machines) | Existing worktree paths, no worktree management | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (wraps the agent CLI) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | - | +| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Yes | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/openchamber.md b/packages/website/src/content/alternatives/openchamber.md index f71c02ed46..bec7765588 100644 --- a/packages/website/src/content/alternatives/openchamber.md +++ b/packages/website/src/content/alternatives/openchamber.md @@ -51,26 +51,35 @@ OpenChamber's CLI runs and manages its server, remote access, and updates. Its A ## Mobile and voice -Both tools provide iOS and Android clients and support dictation and spoken replies. Paseo uses a React Native mobile interface. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView. +Both tools provide iOS and Android clients and support dictation and spoken replies. The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView. ## Comparison -| | Paseo | OpenChamber | -| ---------------------------- | --------------------------------------------------------------------------------------- | -------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile implementation | React Native | Capacitor WebView | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode | -| Application plugins | Server code and native client components | No | -| Split panes and tabs | Yes | Workspace views | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Issue, PR, checks, review, merge | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Preview and port detection | -| Automation | CLI, SDK, MCP | Server CLI, Agent Control Tool | -| Schedules | Yes | Yes | -| Voice | Local dictation and realtime voice | Dictation and spoken replies | -| Self-hosted daemon | Yes | Yes | +| | Paseo | OpenChamber | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Yes (Capacitor WebView) | +| CLI | Yes (everything the app does) | Yes (runs and manages the server) | +| Remote machines | Yes (install the daemon anywhere) | Yes (server on another machine) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees, multi-run comparisons) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (Agent Control Tool, OpenCode agents only) | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub (issue, PR, checks, review, merge) | +| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Preview and port detection | +| Schedules and heartbeats | Yes | Yes (schedules) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Dictation and spoken replies | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/content/alternatives/opencode-desktop.md b/packages/website/src/content/alternatives/opencode-desktop.md index 5321dd3ae7..d80d969aa5 100644 --- a/packages/website/src/content/alternatives/opencode-desktop.md +++ b/packages/website/src/content/alternatives/opencode-desktop.md @@ -9,7 +9,7 @@ order: 56 OpenCode Desktop is the desktop app for OpenCode. It is available in beta for macOS, Windows, and Linux. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -43,7 +43,7 @@ Paseo ships on macOS, Linux, and Windows. OpenCode provides beta desktop builds ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. OpenCode Desktop is a desktop app. OpenCode also has web and share-link workflows, but not a native mobile app. @@ -78,7 +78,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase" ## Worktrees and services -Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for ports. +Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for ports. OpenCode supports multi-session work on the same project. If you want worktree isolation around OpenCode sessions, Paseo can provide that by launching OpenCode inside Paseo workspaces. @@ -94,23 +94,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe ## Comparison -| | Paseo | OpenCode Desktop | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Native mobile | iOS, Android | No | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode | -| Multi-model support | Through supported agent harnesses | Through OpenCode providers | -| Git worktrees | Yes | No built-in worktree manager | -| Per-worktree dev server URLs | Yes | No | -| Split panes and tabs | Yes | Desktop sessions | -| In-app terminal | Yes | OpenCode terminal workflow | -| In-app browser | Yes | No | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | GitHub integration | -| CLI | Run, `--host`, ls, send, schedule, loop | OpenCode CLI | -| MCP server for orchestration | Yes | MCP support inside OpenCode | -| Application plugins | Server code and native client components | No | -| Local voice | Yes | No | -| Self-hosted daemon | Yes | OpenCode server / local runtime | +| | Paseo | OpenCode Desktop | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (beta, macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | No | +| CLI | Yes (everything the app does) | Yes (OpenCode CLI) | +| Remote machines | Yes (install the daemon anywhere) | OpenCode server | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode (many model providers) | +| Parallel agents | Yes (isolated worktrees, across machines) | Multiple sessions, no built-in worktree manager | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | MCP support inside OpenCode | +| Editor | Yes | - | +| Terminals | Yes | OpenCode terminal workflow | +| Diff review | Yes (comments go to the agent) | - | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab integrations | +| In-app browser | Yes (element picker, agent browser tools) | No | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | No | +| Telemetry | None | - | See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/orca.md b/packages/website/src/content/alternatives/orca.md index d47ce3c218..c939d49645 100644 --- a/packages/website/src/content/alternatives/orca.md +++ b/packages/website/src/content/alternatives/orca.md @@ -1,6 +1,6 @@ --- -title: Orca ADE Alternative With Agent Chat, a No-Account Relay, and Plugins -description: Paseo is an Orca alternative for developers who want a chat-first interface, a daemon they can reach from any client, and plugins that run on the server and every client. +title: Orca Alternative for Agentic Development +description: Paseo is an open source agentic development environment with parallel worktrees, an editor, terminals, diffs, pull requests, and a built-in browser. nav: Orca order: 57 --- @@ -9,7 +9,7 @@ order: 57 Orca is an MIT-licensed desktop app for running terminal coding agents in parallel Git worktrees. It includes a mobile companion app, SSH worktrees, remote Orca servers, an embedded browser, and a CLI. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -17,13 +17,13 @@ Paseo is an app for orchestrating coding agents, with native clients on desktop, Orca is built around terminals. Each agent runs in a terminal pane, and an experimental Chat UI can show supported agents as a chat transcript. Orca ships a broad set of built-in tools around those terminals. -Paseo is built around agent chat. It keeps a small core for starting agents, following their work, giving direction, and reviewing results, and adds everything else through plugins that run on the daemon and every client. +Paseo is built around agent chat. The app ships what every developer uses: editor, terminals, diffs, pull requests, and a browser. Features only some teams need, like issue boards or cloud VMs, are plugins you install, so nothing you do not use is in the way. Orca is made by Stably AI, a venture-funded company (Y Combinator). Paseo is independent and open source. ## Architecture and remote machines -The Paseo daemon runs as its own process. Desktop, web, mobile, and CLI clients connect to it directly, over SSH, or through the optional end-to-end encrypted relay. Run the daemon on your laptop, a VM, a home server, or in Docker, and connect to any of them from any client. See [connectivity](/docs/connectivity). +The Paseo desktop app starts and manages a local daemon automatically. That daemon owns the agents and workspaces, so other clients can connect to the same development environment. Desktop, web, mobile, and CLI clients connect to it directly, over SSH, or through the optional end-to-end encrypted relay. Run the daemon on your laptop, a VM, a home server, or in Docker, and connect to any of them from any client. See [connectivity](/docs/connectivity). Orca runs inside its desktop app by default and reaches other machines in two ways. SSH worktrees keep the Orca runtime on your laptop and run selected worktrees and terminals on a remote host. Remote Orca Servers, in beta, run Orca desktop or `orca serve` on another machine and let clients pair to it. Orca's documentation recommends Tailscale or another private network for Remote Orca Servers. @@ -31,7 +31,7 @@ Orca runs inside its desktop app by default and reaches other machines in two wa Both tools have iOS and Android apps. -Paseo ships native apps on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. They are on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale. Orca's mobile companion is in beta, on the App Store for iOS and as an APK download for Android. It can reply to agents, open sessions as a terminal or Chat UI, commit changes, and create workspaces on the paired desktop. Orca Relay, also in beta, connects the phone when it is not on the same network as the desktop and requires signing in to an Orca account. Without Orca Relay, the phone needs a LAN or Tailscale path to the desktop. @@ -51,7 +51,7 @@ Paseo runs Claude Code, Codex, OpenCode, Pi, Antigravity, and Muse Code through [Paseo plugins](/docs/plugins) extend Paseo itself. A plugin can add workspace panels, sidebar items, Command Center items, slash commands, composer pills, settings screens, themes, attachment sources, and agent providers. Its server code runs beside the daemon, and its client code runs in every connected client: desktop, browser, iOS, and Android. -Community plugins are listed at [paseo.cafe](https://paseo.cafe). +Community plugins are listed at [paseo.sh/plugins](/plugins). Orca has an experimental plugin system that is off by default. Orca plugins can add desktop panels rendered in a sandboxed frame, commands, keybindings, language packs, VM recipes, and agent profiles, and can be installed from git marketplaces. Plugin workers run on the desktop computer, and Orca does not document plugin surfaces in its mobile app. @@ -59,13 +59,13 @@ Orca has an experimental plugin system that is off by default. Orca plugins can Orca includes many tools in the core app: a Monaco code editor, Design Mode for sending page elements from its browser to an agent, computer use, Linear and Jira boards, agent account switching with usage tracking, and per-workspace cloud VM recipes. If you want these in one app without extensions, Orca provides them. -Paseo keeps a smaller core. A capability that serves a specialized workflow is meant to be a plugin, so the default app stays short to learn and each plugin can be maintained on its own schedule. +Paseo includes an editor, terminals, diff review with comments that go to the agent, pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg, a browser with an element picker and agent browser tools, plan usage, schedules, and heartbeats. Boards and cloud VMs are what a plugin adds. ## Workspaces and review Both tools provide Git worktrees, split panes, terminals, diff review, an in-app browser, pull-request workflows, and scheduled agent runs. -Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. Orca adds diff annotations that you send back to the agent, port forwarding for SSH worktrees, and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub. +Paseo has diff review with comments that go to the agent and opens pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg. It also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports. Orca has port forwarding for SSH worktrees and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub. ## Automation @@ -89,24 +89,31 @@ Orca does not require an account for local use. Orca Relay and shared artifacts ## Comparison -| | Paseo | Orca | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Funding | Independent | Venture-funded (Y Combinator) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Native mobile | iOS, Android | iOS, Android APK (beta) | -| Main interface | Agent chat | Terminal, with experimental Chat UI | -| Remote machines | Daemon on any machine, direct, SSH, or relay | SSH worktrees, Remote Orca Server | -| Relay | Optional, end-to-end encrypted, no account | Beta, Orca account required | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Any CLI agent in a terminal | -| Application plugins | Server code and native client components on every client | Experimental, desktop only | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes, with Design Mode | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes, plus GitLab, Bitbucket, and more | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Port forwarding for SSH worktrees | -| Automation | CLI, SDK, MCP | CLI, automations | -| Voice | Local dictation and realtime voice | Dictation | -| Telemetry | None | Anonymous usage data, opt-out | +| | Paseo | Orca | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | No for local use, Orca account for relay | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Yes (companion app, beta) | +| CLI | Yes (everything the app does) | Yes (worktrees, browser, automations) | +| Remote machines | Yes (install the daemon anywhere) | Yes (SSH worktrees, Remote Orca Server in beta) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (beta, Orca account required) | +| Direct network access | Yes (LAN, Tailscale, VPN) | Yes (LAN, Tailscale) | +| SSH access | Yes | Yes (SSH worktrees) | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Any CLI agent in a terminal, 30+ listed | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (main interface, experimental Chat UI) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (agents call the Orca CLI through installed skills) | +| Editor | Yes | Yes (Monaco) | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab, Bitbucket, Azure DevOps, Gitea | +| In-app browser | Yes (element picker, agent browser tools) | Yes (Design Mode) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port forwarding for SSH worktrees | +| Schedules and heartbeats | Yes | Yes (scheduled agent runs) | +| Plan usage | Yes | Yes (usage tracking, account switching) | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | Experimental, desktop only | +| Voice | Yes (local dictation, realtime voice) | Dictation | +| Telemetry | None | Anonymous usage data, opt-out | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/superset.md b/packages/website/src/content/alternatives/superset.md index 3dc33113ce..30877bc79e 100644 --- a/packages/website/src/content/alternatives/superset.md +++ b/packages/website/src/content/alternatives/superset.md @@ -51,7 +51,7 @@ Both tools provide Git worktrees, split panes, terminals, diff review, an in-app Paseo gives supported providers a structured chat interface with modes, slash commands, tool calls, and file attachments. Superset can run any CLI agent in terminal panes and adds lifecycle status for supported agents. -Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. +Paseo also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports. ## Automation @@ -65,23 +65,31 @@ Superset has a free individual tier. Team features, remote access, and integrati ## Comparison -| | Paseo | Superset | -| ---------------------------- | ---------------------------------------- | -------------------------------------- | -| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) | -| Desktop platforms | macOS, Linux, Windows | macOS, experimental Linux | -| Native mobile | iOS, Android | Coming soon | -| Account required | No | Yes, with GitHub sign-in | -| Agent harnesses | Native, ACP, and custom CLI | CLI agents | -| Application plugins | Server code and native client components | No | -| Split panes and tabs | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Port detection | -| Automation | CLI, SDK, MCP | CLI, SDK, MCP | -| Schedules | Yes | Yes | -| Local voice | Dictation and realtime voice | No documented voice support | -| Self-hosted daemon | Yes | Host server | +| | Paseo | Superset | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) | +| Account required | No | Yes, with GitHub sign-in | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, experimental Linux) | +| Mobile app | Yes (native, full parity with desktop) | Coming soon | +| CLI | Yes (everything the app does) | Yes (plus SDK and MCP server) | +| Remote machines | Yes (install the daemon anywhere) | Yes (host server, remote access through Superset cloud on paid plans) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Superset cloud services (paid plans) | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | CLI agents, custom terminal agents | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (lifecycle status for supported agents) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (CLI, SDK, and MCP server create workspaces and launch agents) | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub | +| In-app browser | Yes (element picker, agent browser tools) | Yes | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port detection | +| Schedules and heartbeats | Yes | Yes (schedules) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | - | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/llms.ts b/packages/website/src/llms.ts index 20e414f9dd..32a1f6dcac 100644 --- a/packages/website/src/llms.ts +++ b/packages/website/src/llms.ts @@ -6,13 +6,15 @@ const SITE_URL = "https://paseo.sh"; const PRODUCT_PREAMBLE = `# Paseo -> Mobile and desktop app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. +> Open source agentic development environment for desktop, mobile, web, and CLI. -Paseo is an open source application that lets you run AI coding agents on your own machine and drive them from your phone, desktop, browser, or terminal. Your code stays local — Paseo connects directly to your real development environment instead of running agents in someone else's cloud. +Paseo is a full agentic development environment. For agent-driven development, it replaces your IDE: run coding agents, edit files, review diffs and pull requests, run tests in terminals, and check your app in the built-in desktop browser. Arrange agents, files, terminals, and browser tabs in split panes within a workspace. -A self-hosted daemon manages agent lifecycle, exposes a WebSocket API, and ships with an MCP server so other agents can talk to it. Native apps for iOS, Android, macOS, Windows, Linux, and the web let you launch sessions, watch them work, review diffs, and ship from anywhere. A Docker-style CLI ("paseo run", "paseo ls", "paseo logs", "paseo wait") gives you scripting access. An end-to-end encrypted relay lets the mobile app reach your daemon over the public internet without exposing it. +Run many agents in parallel, each in its own git worktree and branch. Choose a provider and model for each task, inspect the results, and decide what to merge. Agents can also create worktrees, launch other agents, and communicate with them through Paseo's orchestration tools. -Paseo supports every major coding agent: Claude Code, Codex, GitHub Copilot, OpenCode, Antigravity, Muse Code, Cursor, Gemini, Cline, Goose, Amp, Aider, and 30+ others. Each agent runs as its own process; Paseo handles I/O, persistence, git worktree isolation, schedules, and skills. +Agents run on your own machines with your existing tools, configuration, and credentials. Work from desktop, the full native iOS and Android app, a web browser, or the CLI. The desktop app starts its local daemon automatically. For remote work, run the daemon on another machine and connect directly or through the optional end-to-end encrypted relay. + +Paseo supports Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Muse Code, and additional ACP-compatible agents. The CLI, TypeScript SDK, and MCP tools expose agent and workspace operations for automation. Plugins extend providers, workflows, and the app's interface. Distribution: native apps for Mac, Windows, Linux, iOS, and Android; web app; Homebrew; npm. Source: Apache-2.0 at https://github.com/getpaseo/paseo. Marketing site: https://paseo.sh. `; diff --git a/packages/website/src/plugins/author-link.tsx b/packages/website/src/plugins/author-link.tsx index 769fc9cbf5..3a2be803f3 100644 --- a/packages/website/src/plugins/author-link.tsx +++ b/packages/website/src/plugins/author-link.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useMemo } from "react"; import { type Author, authorAvatarUrl } from "./registry"; const SIZE_CLASS = { + xs: "h-4 w-4 text-[8px]", sm: "h-5 w-5 text-[10px]", lg: "h-14 w-14 text-xl", } as const; diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 66782154b6..38b6393662 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -3,8 +3,10 @@ import { SiteShell } from "~/components/site-shell"; import { ContributeLinks } from "./contribute-links"; import type { InstallCounts } from "./installs"; import { type BrowseQuery, browseHref } from "./links"; +import { InstallCount } from "./install-count"; import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card"; import { PluginSearch } from "./plugin-search"; +import { PluginSection } from "./section"; import { addedAgo, CATEGORIES, @@ -14,7 +16,9 @@ import { mostInstalled, newestFirst, type Plugin, + recentlyUpdated, searchPlugins, + updatedAgo, } from "./registry"; import { WindowSwitch } from "./window-switch"; @@ -31,10 +35,8 @@ const TAB_ON = `${TAB_BASE} border-white text-white`; const TAB_OFF = `${TAB_BASE} border-transparent text-muted-foreground hover:text-foreground`; const CLEAR_CLASS = "text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground"; -const CONTRIBUTE_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,17 +49,17 @@ export function BrowsePage({ query: BrowseQuery; }) { const category = query.category ? getCategory(query.category) : null; - const matches = query.q ? searchPlugins(plugins, query.q) : plugins; - const scoped = category ? getPluginsInCategory(matches, category.slug) : matches; - const results = - query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window); + const ordered = sortPlugins(plugins, installs, query); + // Search ranks by relevance; the tab's order breaks ties. + const matches = query.q ? searchPlugins(ordered, query.q) : ordered; + const results = category ? getPluginsInCategory(matches, category.slug) : matches; const title = category?.label ?? "All plugins"; const clearHref = browseHref({ ...query, q: undefined }); return ( <SiteShell width="wide"> <div className="lg:flex lg:gap-12"> <aside className="mb-8 lg:mb-0 lg:w-48 lg:flex-shrink-0"> - <PluginSearch scope={query} className="mb-4 lg:mb-6" /> + <PluginSearch scope={query} live className="mb-4 lg:mb-6" /> <CategoryNav plugins={matches} query={query} /> </aside> <div className="min-w-0 flex-1"> @@ -65,7 +67,7 @@ export function BrowsePage({ <h1 className="text-3xl font-medium tracking-tight"> {query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title} <span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground"> - {scoped.length} + {results.length} </span> </h1> {query.q && ( @@ -74,42 +76,69 @@ export function BrowsePage({ </a> )} </div> - <SortTabs query={query} /> - {results.length === 0 && query.q && ( - <div className="mt-8 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center"> - <p className="text-sm text-muted-foreground">No plugins match.</p> - <a - href={clearHref} - className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground" - > - Clear filters - </a> - </div> - )} - {results.length === 0 && !query.q && ( - <p className="mt-8 text-sm text-muted-foreground">No plugins in this category yet.</p> - )} - {results.length > 0 && ( - <div className={`mt-8 ${PLUGIN_GRID_CLASS}`}> - {results.map((plugin) => - query.sort === "new" ? ( - <PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} /> - ) : ( - <PluginCard - key={plugin.id} - plugin={plugin} - installs={installs[plugin.id]?.[query.window] ?? 0} - /> - ), - )} - </div> - )} + <PluginSection> + <SortTabs query={query} /> + {results.length === 0 && query.q && ( + <div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center"> + <p className="text-sm text-muted-foreground">No plugins match.</p> + <a + href={clearHref} + className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground" + > + Clear filters + </a> + </div> + )} + {results.length === 0 && !query.q && ( + <p className="text-sm text-muted-foreground"> + {query.sort === "updated" + ? "No plugins have been updated since they were listed." + : "No plugins in this category yet."} + </p> + )} + {results.length > 0 && ( + <div className={PLUGIN_GRID_CLASS}> + {results.map((plugin) => ( + <PluginCard key={plugin.id} plugin={plugin}> + <CardDetail plugin={plugin} installs={installs} now={now} query={query} /> + </PluginCard> + ))} + </div> + )} + </PluginSection> </div> </div> </SiteShell> ); } +function sortPlugins( + plugins: Plugin[], + installs: Record<string, InstallCounts>, + query: BrowseQuery, +): Plugin[] { + if (query.sort === "new") return newestFirst(plugins); + if (query.sort === "updated") return recentlyUpdated(plugins); + return mostInstalled(plugins, installs, query.window); +} + +/** What the card shows beside the author: the date the tab sorts by, or the install count. */ +function CardDetail({ + plugin, + installs, + now, + query, +}: { + plugin: Plugin; + installs: Record<string, InstallCounts>; + now: string; + query: BrowseQuery; +}) { + if (query.sort === "new") return addedAgo(plugin, now); + if (query.sort === "updated") return updatedAgo(plugin, now); + return <InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />; +} + interface NavEntry { href: string; label: string; @@ -156,7 +185,7 @@ function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery ))} <div className="my-3 h-px bg-white/10" /> <div className="flex flex-col gap-1.5 px-2.5"> - <ContributeLinks className={CONTRIBUTE_CLASS} /> + <ContributeLinks /> </div> </nav> <nav aria-label="Categories" className="-mx-6 flex gap-2 overflow-x-auto px-6 pb-1 lg:hidden"> @@ -188,7 +217,7 @@ function SortTabs({ query }: { query: BrowseQuery }) { <WindowSwitch current={query.window} hrefs={windowHrefs} /> ); return ( - <> + <div> <div className="flex items-baseline justify-between gap-6 border-b border-white/10"> <div className="flex gap-6"> <a @@ -205,10 +234,17 @@ function SortTabs({ query }: { query: BrowseQuery }) { > Newest </a> + <a + href={browseHref({ ...query, sort: "updated" })} + aria-current={query.sort === "updated" ? "page" : undefined} + className={query.sort === "updated" ? TAB_ON : TAB_OFF} + > + Recently updated + </a> </div> {windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>} </div> {windowSwitch && <div className="mt-4 sm:hidden">{windowSwitch}</div>} - </> + </div> ); } diff --git a/packages/website/src/plugins/contribute-links.tsx b/packages/website/src/plugins/contribute-links.tsx index cb9f712190..705615770d 100644 --- a/packages/website/src/plugins/contribute-links.tsx +++ b/packages/website/src/plugins/contribute-links.tsx @@ -1,17 +1,57 @@ -import { BUILD_URL, SUBMIT_URL } from "./links"; +import { DOCS_URL, SUBMIT_URL } from "./links"; -const LINK_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground"; +const LINK_CLASS = + "text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground"; +const PRIMARY_BUTTON_CLASS = + "inline-flex items-center justify-center rounded-lg bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors hover:bg-foreground/90"; +const SECONDARY_BUTTON_CLASS = + "inline-flex items-center justify-center rounded-lg border border-white/12 px-4 py-2 text-sm text-white transition-colors hover:bg-white/10"; -/** "Build a plugin" and "Submit a plugin", as two quiet links. */ -export function ContributeLinks({ className = LINK_CLASS }: { className?: string }) { +/** "Read the docs" and "Submit a plugin", as two quiet links. */ +export function ContributeLinks() { return ( <> - <a href={BUILD_URL} className={className}> - Build a plugin + <a href={DOCS_URL} className={LINK_CLASS}> + Read the docs </a> - <a href={SUBMIT_URL} className={className}> + <a href={SUBMIT_URL} className={LINK_CLASS}> Submit a plugin </a> </> ); } + +/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */ +export function ContributeButtons() { + return ( + <> + <a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}> + Submit a plugin + </a> + <a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}> + Read the docs + </a> + </> + ); +} + +/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */ +export function ContributeSection() { + return ( + <section + aria-labelledby="build-your-own" + className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 text-center sm:px-8" + > + <h2 id="build-your-own" className="text-lg font-medium"> + Build your own plugin + </h2> + <p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground"> + Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one + with the plugin docs, then submit it to get it listed here. + </p> + <div className="mt-6 flex flex-wrap justify-center gap-3"> + <ContributeButtons /> + </div> + </section> + ); +} diff --git a/packages/website/src/plugins/hero.tsx b/packages/website/src/plugins/hero.tsx new file mode 100644 index 0000000000..0683a0d6e0 --- /dev/null +++ b/packages/website/src/plugins/hero.tsx @@ -0,0 +1,37 @@ +import { ContributeButtons } from "./contribute-links"; +import type { BrowseQuery } from "./links"; +import { PluginSearch } from "./plugin-search"; + +interface PluginsHeroProps { + pluginCount: number; + authorCount: number; + /** Where the search box sends its term. */ + searchScope: BrowseQuery; +} + +function plural(count: number, word: string): string { + return `${count} ${word}${count === 1 ? "" : "s"}`; +} + +/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */ +export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) { + return ( + <header> + <h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> + <span className="text-3xl font-medium tracking-tight">Plugins</span> + <span className="text-sm font-normal tabular-nums text-extra-muted-foreground"> + {plural(pluginCount, "plugin")} by {plural(authorCount, "author")} + </span> + </h1> + <p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground"> + Browse community plugins that extend Paseo with new functionality. + </p> + <div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex flex-wrap gap-3"> + <ContributeButtons /> + </div> + <PluginSearch scope={searchScope} className="w-full sm:w-64" /> + </div> + </header> + ); +} diff --git a/packages/website/src/plugins/install-count.tsx b/packages/website/src/plugins/install-count.tsx new file mode 100644 index 0000000000..198905ddea --- /dev/null +++ b/packages/website/src/plugins/install-count.tsx @@ -0,0 +1,13 @@ +import { Download } from "lucide-react"; +import { formatInstalls } from "./registry"; + +/** An install count as a download icon and the number; inherits the surrounding text style. */ +export function InstallCount({ count }: { count: number }) { + return ( + <span className="relative inline-flex items-center gap-1 tabular-nums"> + <Download aria-hidden className="h-3 w-3" /> + {formatInstalls(count)} + <span className="sr-only">installs</span> + </span> + ); +} diff --git a/packages/website/src/plugins/installs.ts b/packages/website/src/plugins/installs.ts index 01846e6797..78b4294639 100644 --- a/packages/website/src/plugins/installs.ts +++ b/packages/website/src/plugins/installs.ts @@ -12,6 +12,26 @@ const WINDOW_DAYS = { week: 7, month: 30 } as const; const totalKey = (id: string) => `plugin-installs:${id}`; const dailyKey = (id: string) => `plugin-installs-daily:${id}`; +export async function recordClientInstall({ + cache, + id, + ip, +}: { + cache: KVNamespace; + id: string; + ip: string | null; +}): Promise<void> { + if (!ip) return; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip)); + const hash = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + const key = `plugin-installs-client:${id}:${hash}`; + if ((await cache.get(key)) !== null) return; + await cache.put(key, "1", { expirationTtl: 3600 }); + await recordInstall(cache, id, new Date()); +} + export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise<void> { const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]); await Promise.all([ diff --git a/packages/website/src/plugins/launch.ts b/packages/website/src/plugins/launch.ts index bc784696de..bfe0d00385 100644 --- a/packages/website/src/plugins/launch.ts +++ b/packages/website/src/plugins/launch.ts @@ -1,2 +1 @@ -// Keep navigation on paseo.cafe until the coordinated directory announcement. -export const PLUGINS_LINKED = false; +export const PLUGINS_LINKED = true; diff --git a/packages/website/src/plugins/links.ts b/packages/website/src/plugins/links.ts index f55508601d..18eab1535f 100644 --- a/packages/website/src/plugins/links.ts +++ b/packages/website/src/plugins/links.ts @@ -1,11 +1,11 @@ import type { CategorySlug } from "./categories"; import type { InstallWindow } from "./installs"; -export const BUILD_URL = "/docs/plugins"; +export const DOCS_URL = "/docs/plugins"; export const SUBMIT_URL = "https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml"; -export type BrowseSort = "installs" | "new"; +export type BrowseSort = "installs" | "new" | "updated"; export interface BrowseQuery { /** Absent for all plugins. */ category?: CategorySlug; @@ -24,7 +24,7 @@ export function parseSearchTerm(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } export function parseSort(value: unknown): BrowseSort | undefined { - return value === "new" ? value : undefined; + return value === "new" || value === "updated" ? value : undefined; } export function pluginHref(id: string): string { @@ -37,7 +37,7 @@ export function browseHref({ category, sort, window, q }: BrowseQuery): string { const path = category ? categoryHref(category) : "/plugins/all"; const params = new URLSearchParams(); if (q) params.set("q", q); - if (sort === "new") params.set("sort", sort); + if (sort !== "installs") params.set("sort", sort); else if (window !== DEFAULT_WINDOW) params.set("window", window); const search = params.toString(); return search ? `${path}?${search}` : path; diff --git a/packages/website/src/plugins/loader.ts b/packages/website/src/plugins/loader.ts index 27416a4125..ebc9bf8272 100644 --- a/packages/website/src/plugins/loader.ts +++ b/packages/website/src/plugins/loader.ts @@ -11,14 +11,19 @@ import { function registryBase(): string { return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; } -/** Listed plugins in index order, their install counts per window, and the server's clock. */ +/** Listed plugins in index order, featured plugin IDs, install counts per window, and the server's clock. */ export const getRegistry = createServerFn({ method: "GET" }).handler(async () => { const context = getWebsiteCacheContext(); const [index, installs] = await Promise.all([ loadRegistryIndex(registryBase(), context), loadInstallCounts(registryBase(), context), ]); - return { plugins: index.plugins, installs, now: new Date().toISOString() }; + return { + plugins: index.plugins, + featured: index.featured, + installs, + now: new Date().toISOString(), + }; }); export const getRegistryPlugin = createServerFn({ method: "GET" }) .validator((id: string) => id) diff --git a/packages/website/src/plugins/media-gallery.tsx b/packages/website/src/plugins/media-gallery.tsx new file mode 100644 index 0000000000..ac25a6f53e --- /dev/null +++ b/packages/website/src/plugins/media-gallery.tsx @@ -0,0 +1,231 @@ +import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; +import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry"; +import { ChevronLeft, ChevronRight, Play, X } from "lucide-react"; +import { + type CSSProperties, + type KeyboardEvent, + type MouseEvent, + type SyntheticEvent, + useCallback, + useMemo, + useRef, + useState, +} from "react"; + +const TILE_CLASS = + "relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%]"; +// Two tiles fill the row; with more, a third peeks at the right edge to show the strip scrolls. +const FILLING_TILE_CLASS = `${TILE_CLASS} md:w-[calc(50%-0.375rem)]`; +const PEEKING_TILE_CLASS = `${TILE_CLASS} md:w-[42%]`; +// The viewer's room: media never exceeds this box, which leaves a margin around it. +const VIEW_WIDTH = "90vw"; +const VIEW_HEIGHT = "85vh"; +const VIEW_CLASS = "max-h-[85vh] max-w-[90vw] rounded-lg object-contain"; +const VIEWER_BUTTON_CLASS = + "fixed rounded-full bg-black/60 p-2 text-white transition-colors hover:bg-black/80"; +const ARROW_STEPS: Partial<Record<string, number>> = { ArrowLeft: -1, ArrowRight: 1 }; + +interface MediaItem { + url: string; + kind: "image" | "video"; + /** Image alt text, or the video's accessible name. */ + label: string; +} + +/** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */ +export function MediaGallery({ name, media }: { name: string; media: string[] }) { + const dialog = useRef<HTMLDialogElement>(null); + const entries = useMemo( + () => media.map((url, index) => mediaEntry(name, url, index)), + [name, media], + ); + const items = useMemo(() => entries.filter((entry) => typeof entry !== "string"), [entries]); + const [viewing, setViewing] = useState<number | null>(null); + const item = viewing === null ? undefined : items[viewing]; + const open = useCallback( + (opened: MediaItem) => { + setViewing(items.indexOf(opened)); + dialog.current?.showModal(); + }, + [items], + ); + const close = useCallback(() => dialog.current?.close(), []); + // Unmounting the viewed media stops a playing video. + const handleClose = useCallback(() => setViewing(null), []); + const closeOnBackdrop = useCallback((event: MouseEvent<HTMLDialogElement>) => { + if (event.target === event.currentTarget) event.currentTarget.close(); + }, []); + const step = useCallback( + (by: number) => + setViewing((index) => (index === null ? null : (index + by + items.length) % items.length)), + [items.length], + ); + const showPrevious = useCallback(() => step(-1), [step]); + const showNext = useCallback(() => step(1), [step]); + // Captured so arrow keys move between items even while a focused video would seek. + const stepWithArrowKeys = useCallback( + (event: KeyboardEvent<HTMLDialogElement>) => { + const by = ARROW_STEPS[event.key]; + if (by === undefined || items.length < 2) return; + event.preventDefault(); + step(by); + }, + [items.length, step], + ); + + if (media.length === 0) return null; + const tileClass = items.length > 2 ? PEEKING_TILE_CLASS : FILLING_TILE_CLASS; + return ( + <> + <div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0"> + {entries.map((entry) => + typeof entry === "string" ? ( + <p key={entry}>{entry}</p> + ) : ( + <MediaTile key={entry.url} item={entry} className={tileClass} onOpen={open} /> + ), + )} + </div> + {/* Focusable so arrow keys still reach it after a click on non-focusable media. */} + <dialog + ref={dialog} + tabIndex={-1} + aria-label={item?.label} + onClose={handleClose} + onClick={closeOnBackdrop} + onKeyDownCapture={stepWithArrowKeys} + className="m-auto overflow-visible bg-transparent p-0 outline-none backdrop:bg-black/80" + > + {item?.kind === "image" && ( + <img key={viewing} src={item.url} alt={item.label} className={VIEW_CLASS} /> + )} + {item?.kind === "video" && <ViewerVideo key={viewing} url={item.url} label={item.label} />} + {items.length > 1 && ( + <> + <button + type="button" + aria-label="Previous" + onClick={showPrevious} + className={`${VIEWER_BUTTON_CLASS} left-4 top-1/2 -translate-y-1/2`} + > + <ChevronLeft className="h-5 w-5" /> + </button> + <button + type="button" + aria-label="Next" + onClick={showNext} + className={`${VIEWER_BUTTON_CLASS} right-4 top-1/2 -translate-y-1/2`} + > + <ChevronRight className="h-5 w-5" /> + </button> + </> + )} + <button + type="button" + aria-label="Close" + onClick={close} + className={`${VIEWER_BUTTON_CLASS} right-4 top-4`} + > + <X className="h-5 w-5" /> + </button> + </dialog> + </> + ); +} + +/** A registry media entry as a gallery item, or the raw entry when it is not a safe URL. */ +function mediaEntry(name: string, url: string, index: number): MediaItem | string { + const source = pluginOverviewUrl(url); + if (!source) return url; + const kind = pluginMediaKind(source); + return { + url: source, + kind, + label: `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`, + }; +} + +/** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */ +function ViewerVideo({ url, label }: { url: string; label: string }) { + const [aspectRatio, setAspectRatio] = useState<number | null>(null); + const readAspectRatio = useCallback((event: SyntheticEvent<HTMLVideoElement>) => { + const { videoWidth, videoHeight } = event.currentTarget; + setAspectRatio(videoWidth / videoHeight); + }, []); + const style = useMemo<CSSProperties | undefined>( + () => + aspectRatio === null + ? undefined + : { aspectRatio, width: `min(${VIEW_WIDTH}, ${VIEW_HEIGHT} * ${aspectRatio})` }, + [aspectRatio], + ); + return ( + <video + src={url} + aria-label={label} + controls + autoPlay + playsInline + onLoadedMetadata={readAspectRatio} + style={style} + className={VIEW_CLASS} + /> + ); +} + +function MediaTile({ + item, + className, + onOpen, +}: { + item: MediaItem; + className: string; + onOpen: (item: MediaItem) => void; +}) { + const { url, kind, label } = item; + const handleClick = useCallback( + (event: MouseEvent<HTMLAnchorElement>) => { + // Modified clicks keep the link's own behavior, such as opening the file in a new tab. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + onOpen(item); + }, + [onOpen, item], + ); + return ( + <a + href={url} + {...pluginOverviewPolicy.link} + aria-label={kind === "video" ? label : undefined} + onClick={handleClick} + className={className} + > + {kind === "image" ? ( + <img + src={url} + alt={label} + loading="lazy" + className="h-full w-full object-cover object-top" + /> + ) : ( + <> + <video + src={url} + preload="metadata" + muted + playsInline + className="h-full w-full object-cover object-top" + /> + <span + aria-hidden + className="absolute inset-0 flex items-center justify-center bg-black/10" + > + <span className="rounded-full bg-black/60 p-3 text-white"> + <Play className="h-5 w-5 fill-current" /> + </span> + </span> + </> + )} + </a> + ); +} diff --git a/packages/website/src/plugins/overview.test.tsx b/packages/website/src/plugins/overview.test.tsx new file mode 100644 index 0000000000..45bccecb37 --- /dev/null +++ b/packages/website/src/plugins/overview.test.tsx @@ -0,0 +1,147 @@ +import registry from "../../e2e/registry.fixture.json"; +import { PluginTile } from "./plugin-tile"; +import { PluginCard } from "./plugin-card"; +import { authorAvatarUrl, getAuthor, type Plugin } from "./registry"; +import React, { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseFragment, type DefaultTreeAdapterMap } from "parse5"; +import { describe, expect, it } from "vitest"; +import corpus from "../../../protocol/tests/fixtures/plugin-overview.json"; +import { PluginOverview } from "./overview"; + +type Element = DefaultTreeAdapterMap["element"]; +function elements(html: string): Element[] { + const result: Element[] = []; + function visit(node: DefaultTreeAdapterMap["node"]) { + if ("tagName" in node) result.push(node); + if ("childNodes" in node) node.childNodes.forEach(visit); + } + visit(parseFragment(html)); + return result; +} +function render(markdown: string) { + return renderToStaticMarkup(<PluginOverview>{markdown}</PluginOverview>); +} +function attribute(element: Element, name: string) { + return element.attrs.find((attr) => attr.name === name)?.value; +} +function imageSources(html: string) { + return elements(html) + .filter((node) => node.tagName === "img") + .map((node) => attribute(node, "src")); +} +/** Cards show the author's GitHub avatar beside the plugin's own images. */ +function avatarUrl(plugin: Plugin) { + return authorAvatarUrl(getAuthor(plugin)); +} +const allowedTags = new Set([ + "div", + "p", + "br", + "hr", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "ul", + "ol", + "li", + "strong", + "em", + "del", + "pre", + "code", + "a", + "img", + "table", + "thead", + "tbody", + "tr", + "th", + "td", +]); + +describe("plugin overview security corpus", () => { + it.each(corpus)("$name", ({ markdown, links, images }) => { + const nodes = elements(render(markdown)); + expect( + nodes.filter((node) => !allowedTags.has(node.tagName)).map((node) => node.tagName), + ).toEqual([]); + expect( + nodes.flatMap((node) => node.attrs).filter((attr) => /^on|^style$|^srcdoc$/i.test(attr.name)), + ).toEqual([]); + const anchors = nodes.filter((node) => node.tagName === "a"); + expect(anchors.map((node) => attribute(node, "href"))).toEqual(links); + for (const anchor of anchors) { + expect(attribute(anchor, "rel")).toBe("noopener noreferrer nofollow"); + expect(attribute(anchor, "target")).toBe("_blank"); + } + expect( + nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")), + ).toEqual(images); + }); + + it.each(corpus)("preserves $name inside code spans and fences", ({ markdown }) => { + for (const code of [ + `\`${markdown.replaceAll("\n", " ")}\``, + `\`\`\`html\n${markdown}\n\`\`\``, + ]) { + const nodes = elements(render(code)); + expect( + nodes.filter((node) => ["a", "img", "script", "iframe", "svg"].includes(node.tagName)), + ).toEqual([]); + const text = nodes + .filter((node) => node.tagName === "code") + .flatMap((node) => node.childNodes) + .map((child) => ("value" in child ? child.value : "")) + .join(""); + expect(text.trim()).toBe( + code.startsWith("```html") ? markdown : markdown.replaceAll("\n", " "), + ); + } + }); + + it("keeps long untrusted input inert", () => { + const nodes = elements( + render("a".repeat(100_000) + "\n\n" + "[click](javascript:alert(1)) ".repeat(1_000)), + ); + expect(nodes.filter((node) => ["a", "img", "script"].includes(node.tagName))).toEqual([]); + }); +}); + +describe("registry images", () => { + it.each([ + "http://example.com/x", + "data:image/png;base64,AAAA", + "javascript:alert(1)", + "vbscript:msgbox(1)", + "./icon.png", + "//example.com/x", + ])("does not load %s in cards or icons", (url) => { + const plugin = { ...registry.plugins[0], icon: url, media: [url] } as Plugin; + for (const component of [ + createElement(PluginTile, { plugin, size: "sm" }), + createElement(PluginCard, { plugin }), + ]) { + expect( + imageSources(renderToStaticMarkup(component)).filter((src) => src !== avatarUrl(plugin)), + ).toEqual([]); + } + }); +}); + +it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () => { + const source = "https://example.com/screenshot.png"; + const plugin = { + ...registry.plugins[0], + icon: undefined, + media: ["data:image/png;base64,AAAA", source], + } as Plugin; + expect(imageSources(renderToStaticMarkup(createElement(PluginCard, { plugin })))).toEqual([ + source, + avatarUrl(plugin), + ]); +}); diff --git a/packages/website/src/plugins/overview.tsx b/packages/website/src/plugins/overview.tsx new file mode 100644 index 0000000000..8fc680f9a9 --- /dev/null +++ b/packages/website/src/plugins/overview.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import ReactMarkdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; + +/** A rejected destination leaves its label as text, without even an empty anchor. */ +export function PluginContentLink({ + href, + children, + className, +}: { + href?: string; + children: ReactNode; + className?: string; +}) { + const destination = pluginOverviewUrl(href); + return destination ? ( + <a href={destination} {...pluginOverviewPolicy.link} className={className}> + {children} + </a> + ) : ( + children + ); +} + +const allowedElements = [...pluginOverviewPolicy.elements]; +const remarkPlugins = [remarkGfm]; + +const components: Components = { + a: ({ href, children }) => <PluginContentLink href={href}>{children}</PluginContentLink>, + img: ({ src, alt }) => { + const source = pluginOverviewUrl(typeof src === "string" ? src : undefined); + return source ? <img src={source} alt={alt ?? ""} loading="lazy" /> : alt; + }, + // GFM alignment otherwise becomes an inline style in react-markdown. + th: ({ children }) => <th>{children}</th>, + td: ({ children }) => <td>{children}</td>, +}; + +/** Untrusted overviews do not inherit documentation directives, anchors, or HTML highlighters. */ +export function PluginOverview({ children }: { children: string }) { + return ( + <div className="docs-prose"> + <ReactMarkdown + skipHtml={!pluginOverviewPolicy.rawHtml} + allowedElements={allowedElements} + unwrapDisallowed + remarkPlugins={remarkPlugins} + urlTransform={pluginOverviewUrl} + components={components} + > + {children} + </ReactMarkdown> + </div> + ); +} diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index d8fdb01fc4..c46b9f553a 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -1,14 +1,17 @@ -import { Download } from "lucide-react"; +import type { ReactNode } from "react"; +import { AuthorAvatar } from "./author-link"; +import { InstallCount } from "./install-count"; +import { pluginCardScreenshot } from "./thumbnails"; import { pluginHref } from "./links"; import { PluginTile } from "./plugin-tile"; -import { formatInstalls, getCategory, type Plugin } from "./registry"; +import { firstMediaImage, getAuthor, getCategory, type Plugin } from "./registry"; export const PLUGIN_GRID_CLASS = "grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"; -/** First screenshot, or the plugin tile on a quiet backdrop when there is none. */ +/** First allowed image, or the plugin tile on a quiet backdrop when there is none. */ function PluginShot({ plugin }: { plugin: Plugin }) { - const url = plugin.screenshots[0]; + const url = firstMediaImage(plugin); return ( <div aria-hidden @@ -16,9 +19,10 @@ function PluginShot({ plugin }: { plugin: Plugin }) { > {url ? ( <img - src={url} + {...pluginCardScreenshot(plugin.id, url)} alt="" loading="lazy" + decoding="async" className="h-full w-full object-cover object-left-top" /> ) : ( @@ -30,57 +34,33 @@ function PluginShot({ plugin }: { plugin: Plugin }) { ); } -/** What's new card: screenshot, name, and when it was added. */ -export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) { +/** + * Gallery card, in three rows under the screenshot: tile and name, description, then author and + * `children`, such as when the plugin was added or its install count. + */ +export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) { + const author = getAuthor(plugin); return ( - <a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto"> + // Grid items size to their content by default; min-w-0 keeps the card in its column. + <a href={pluginHref(plugin.id)} className="group block min-w-0"> <PluginShot plugin={plugin} /> - <div className="mt-3 flex items-center gap-2.5"> - <PluginTile plugin={plugin} size="sm" /> - <div className="min-w-0"> - <p className="truncate text-sm font-medium text-white">{plugin.name}</p> - <p className="truncate text-xs text-extra-muted-foreground">Added {added}</p> - </div> - </div> - </a> - ); -} - -/** Grid card: screenshot, name, one muted stat, and a two-line description. */ -export function PluginCard({ - plugin, - installs, - added, -}: { - plugin: Plugin; - /** Shown with a download icon; otherwise `added` is shown. */ - installs?: number; - added?: string; -}) { - return ( - <a href={pluginHref(plugin.id)} className="group block"> - <PluginShot plugin={plugin} /> - <div className="mt-3 flex items-start gap-2.5"> - <PluginTile plugin={plugin} size="sm" /> - <div className="min-w-0 flex-1"> - <div className="flex items-baseline justify-between gap-2"> - <p className="truncate text-sm font-medium text-white">{plugin.name}</p> - <span className="inline-flex flex-shrink-0 items-center gap-1 text-xs tabular-nums text-extra-muted-foreground"> - {installs === undefined ? ( - added - ) : ( - <> - <Download className="h-3 w-3" /> - {formatInstalls(installs)} - </> - )} - </span> - </div> - <p className="mt-0.5 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground"> - {plugin.description} - </p> - </div> + <div className="mt-3 flex items-center gap-2"> + <PluginTile plugin={plugin} size="xs" /> + <p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p> </div> + <p className="mt-2 line-clamp-2 min-h-10 break-words text-xs leading-5 text-muted-foreground"> + {plugin.description} + </p> + <p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground"> + <AuthorAvatar author={author} size="xs" /> + <span className="truncate">{author.name}</span> + {children !== undefined && ( + <> + <span aria-hidden>·</span> + <span className="flex-shrink-0">{children}</span> + </> + )} + </p> </a> ); } @@ -98,11 +78,9 @@ export function PluginRankRow({ return ( <a href={pluginHref(plugin.id)} - className="flex items-center gap-3 rounded-lg px-2 py-2.5 transition-colors hover:bg-white/[0.04]" + className="flex items-center gap-3 rounded-lg px-4 py-2.5 transition-colors hover:bg-white/[0.04]" > - <span className="w-4 text-right text-sm tabular-nums text-extra-muted-foreground"> - {rank} - </span> + <span className="text-sm tabular-nums text-extra-muted-foreground">{rank}</span> <PluginTile plugin={plugin} size="sm" /> <div className="min-w-0 flex-1"> <p className="truncate text-sm text-white">{plugin.name}</p> @@ -110,7 +88,9 @@ export function PluginRankRow({ {getCategory(plugin.categories[0])?.label} </p> </div> - <span className="text-xs tabular-nums text-muted-foreground">{formatInstalls(installs)}</span> + <span className="text-xs text-muted-foreground"> + <InstallCount count={installs} /> + </span> </a> ); } diff --git a/packages/website/src/plugins/plugin-search.tsx b/packages/website/src/plugins/plugin-search.tsx index a6bd398a19..400341d5a4 100644 --- a/packages/website/src/plugins/plugin-search.tsx +++ b/packages/website/src/plugins/plugin-search.tsx @@ -1,27 +1,73 @@ -import { useNavigate } from "@tanstack/react-router"; -import { Search } from "lucide-react"; -import { type ChangeEvent, type FormEvent, useCallback, useState } from "react"; -import { type BrowseQuery, browseHref, DEFAULT_WINDOW } from "./links"; +import { useNavigate, useRouter } from "@tanstack/react-router"; +import { Search, X } from "lucide-react"; +import { type ChangeEvent, type FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import { type BrowseQuery, browseHref, DEFAULT_WINDOW, parseSearchTerm } from "./links"; + +const ICON_CLASS = "h-3.5 w-3.5 text-extra-muted-foreground"; /** - * Search box for the plugin pages. Typing replaces the URL with the browse page for the term, - * keeping `scope`'s category and sort. Without JavaScript the form submits the same URL. + * Search box for the plugin pages. Submitting opens the browse page for the term, keeping + * `scope`'s category and sort. With `live`, typing filters in place instead: the first character + * of a query pushes one history entry and later edits replace it, so Back leaves the search. + * Without JavaScript the form submits the same URL. */ -export function PluginSearch({ scope, className }: { scope: BrowseQuery; className?: string }) { +export function PluginSearch({ + scope, + live = false, + className, +}: { + scope: BrowseQuery; + live?: boolean; + className?: string; +}) { const navigate = useNavigate(); + const router = useRouter(); + const input = useRef<HTMLInputElement>(null); const [term, setTerm] = useState(scope.q ?? ""); - const handleChange = useCallback( - (event: ChangeEvent<HTMLInputElement>) => { - const next = event.target.value; - setTerm(next); - void navigate({ - href: browseHref({ ...scope, q: next.trim() ? next : undefined }), - replace: true, - }); + // Back and Forward between results of the same page keep this box mounted; show their term. + useEffect( + () => + router.history.subscribe(({ location, action }) => { + if (action.type === "PUSH" || action.type === "REPLACE") return; + setTerm(parseSearchTerm(new URLSearchParams(location.search).get("q")) ?? ""); + }), + [router], + ); + const show = useCallback( + (next: string, { replace }: { replace: boolean }) => { + void navigate({ href: browseHref({ ...scope, q: next.trim() ? next : undefined }), replace }); }, [navigate, scope], ); - const handleSubmit = useCallback((event: FormEvent) => event.preventDefault(), []); + const edit = useCallback( + (next: string) => { + setTerm(next); + if (live) show(next, { replace: Boolean(term.trim()) }); + }, + [live, show, term], + ); + // A term typed before the page hydrated is in the box but not in state; adopt it. Once hydrated, + // the box always matches state, so this does nothing. + useEffect(() => { + const typed = input.current?.value ?? ""; + if (typed !== term) edit(typed); + }, [edit, term]); + const handleChange = useCallback( + (event: ChangeEvent<HTMLInputElement>) => edit(event.target.value), + [edit], + ); + const handleClear = useCallback(() => { + edit(""); + input.current?.focus(); + }, [edit]); + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + // Live results already show the term. + if (!live) show(term, { replace: false }); + }, + [live, show, term], + ); return ( <form role="search" @@ -30,13 +76,16 @@ export function PluginSearch({ scope, className }: { scope: BrowseQuery; classNa onSubmit={handleSubmit} className={className} > - {scope.sort === "new" && <input type="hidden" name="sort" value="new" />} + {scope.sort !== "installs" && <input type="hidden" name="sort" value={scope.sort} />} {scope.sort === "installs" && scope.window !== DEFAULT_WINDOW && ( <input type="hidden" name="window" value={scope.window} /> )} - <label className="relative block"> - <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-extra-muted-foreground" /> + <div className="relative"> + <Search + className={`pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 ${ICON_CLASS}`} + /> <input + ref={input} type="search" name="q" value={term} @@ -45,9 +94,19 @@ export function PluginSearch({ scope, className }: { scope: BrowseQuery; classNa aria-label="Search plugins" // Typing on the directory lands here; keep the caret in the box. autoFocus={Boolean(scope.q)} - className="w-full rounded-lg border border-white/10 bg-white/[0.03] py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-extra-muted-foreground focus:border-white/20 focus:outline-none" + className="w-full rounded-lg border border-white/10 bg-white/[0.03] py-1.5 pl-8 pr-8 text-sm text-foreground placeholder:text-extra-muted-foreground focus:border-white/20 focus:outline-none [&::-webkit-search-cancel-button]:appearance-none" /> - </label> + {term && ( + <button + type="button" + onClick={handleClear} + aria-label="Clear search" + className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 transition-colors hover:bg-white/[0.06]" + > + <X className={ICON_CLASS} /> + </button> + )} + </div> </form> ); } diff --git a/packages/website/src/plugins/plugin-tile.tsx b/packages/website/src/plugins/plugin-tile.tsx index 01234ac8b8..5e2dc9c31b 100644 --- a/packages/website/src/plugins/plugin-tile.tsx +++ b/packages/website/src/plugins/plugin-tile.tsx @@ -1,6 +1,8 @@ +import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; import type { Plugin } from "./registry"; const SIZE_CLASS = { + xs: "h-6 w-6 rounded-md text-xs", sm: "h-9 w-9 rounded-lg text-sm", lg: "h-14 w-14 rounded-xl text-xl", } as const; @@ -12,7 +14,7 @@ export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeo aria-hidden className={`flex flex-shrink-0 items-center justify-center border border-white/10 bg-white/[0.06] font-medium text-white/80 ${SIZE_CLASS[size]}`} > - {plugin.icon ? ( + {pluginOverviewUrl(plugin.icon) ? ( <img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" /> ) : ( plugin.name.charAt(0).toUpperCase() diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts index 4d6826f11e..b57d16ec2a 100644 --- a/packages/website/src/plugins/published.ts +++ b/packages/website/src/plugins/published.ts @@ -4,8 +4,9 @@ import { PublishedPluginDetailSchema, } from "@getpaseo/protocol/plugin-registry"; import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache"; +import { handlePluginThumbnailRequest } from "./thumbnails"; import { CATEGORIES } from "./categories"; -import { type InstallCounts, readInstallCounts, recordInstall } from "./installs"; +import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs"; export interface RegistryEnvironment { PLUGINS_REGISTRY_URL?: string; WEBSITE_CACHE?: KVNamespace; @@ -63,6 +64,10 @@ export async function handlePluginRegistryRequest( const url = new URL(request.url); if (request.method !== "GET") return null; const base = env.PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; + if (url.pathname.startsWith("/plugins/thumb/")) { + const index = await loadRegistryIndex(base, context); + return handlePluginThumbnailRequest(request, index.plugins); + } if (url.hostname === "plugins.paseo.sh" && url.pathname === "/index.json") return Response.json(await loadRegistryIndex(base, context)); if (url.pathname === "/sitemap-plugins.xml") { @@ -111,7 +116,12 @@ export async function handlePluginRegistryRequest( url.searchParams.get("intent") === "install" ) { const cache = context.cache; - if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined)); + if (cache) + context.waitUntil( + recordClientInstall({ cache, id, ip: request.headers.get("CF-Connecting-IP") }).catch( + () => undefined, + ), + ); } return Response.json(plugin); } diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts index d5244fcb3f..d8f343b266 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -1,16 +1,22 @@ +import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { describe, expect, it } from "vitest"; import { + addedAgo, + featuredPlugins, formatInstalls, installCommand, mostInstalled, newestFirst, type Plugin, readmeBody, + recentlyUpdated, + reportBugUrl, searchPlugins, + updatedAgo, } from "./registry"; import { readInstallCounts, recordInstall } from "./installs"; -import { loadRegistryIndex, loadRegistryPlugin } from "./published"; +import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published"; const plugin: Plugin = { id: "acme/example", name: "Example", @@ -25,7 +31,7 @@ const plugin: Plugin = { resolved: "https://registry.npmjs.org/example.tgz", integrity: "sha512-YWJj", }, - screenshots: [], + media: [], submittedAt: "2026-10-03", reviewedAt: "2026-10-03", updatedAt: "2026-10-03", @@ -39,6 +45,7 @@ describe("plugin registry", () => { registry: { name: "Internal", url: "https://example.test" }, categories: [], plugins: [plugin], + featured: [plugin.id], generatedAt: "2026-10-03", }; const detail = { ...plugin, readme: "# Example" }; @@ -59,8 +66,70 @@ describe("plugin registry", () => { await new Promise<void>((resolve) => server.close(() => resolve())); } }); - it("orders by submission date and by installs in a window, keeping index order for ties", () => { - const older = { ...plugin, id: "acme/older", submittedAt: "2026-09-01" }; + it("features listed plugins once each, in the registry's order, dropping IDs that are not listed", () => { + const listed = [ + { ...plugin, id: "acme/first" }, + { ...plugin, id: "acme/second" }, + { ...plugin, id: "acme/third" }, + ]; + const featured = ["acme/third", "acme/not-published", "acme/first", "acme/third"]; + expect(featuredPlugins(listed, featured).map((p) => p.id)).toEqual([ + "acme/third", + "acme/first", + ]); + expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]); + }); + it("lists what's new by first listing time, so neither a slow review nor an update moves a plugin", () => { + const listedFirst = { + ...plugin, + id: "acme/listed-first", + submittedAt: "2026-09-30T00:00:00.000Z", + publishedAt: "2026-09-30T06:00:00.000Z", + reviewedAt: "2026-10-02T00:00:00.000Z", + updatedAt: "2026-10-02T00:00:00.000Z", + }; + const listedLater = { + ...plugin, + id: "acme/listed-later", + submittedAt: "2026-09-02T00:00:00.000Z", + publishedAt: "2026-09-30T08:00:00.000Z", + reviewedAt: "2026-09-30T08:00:00.000Z", + updatedAt: "2026-09-30T08:00:00.000Z", + }; + expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([ + "acme/listed-later", + "acme/listed-first", + ]); + const now = "2026-10-03T12:00:00.000Z"; + expect(addedAgo(listedLater, now)).toBe("3d ago"); + expect(updatedAgo(listedFirst, now)).toBe("yesterday"); + }); + it("lists only plugins updated after their first listing as recently updated, latest update first", () => { + const bumped = { + ...plugin, + id: "acme/bumped", + publishedAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-10-01T09:00:00.000Z", + }; + const bumpedLater = { + ...plugin, + id: "acme/bumped-later", + publishedAt: "2026-09-20T00:00:00.000Z", + updatedAt: "2026-10-01T17:00:00.000Z", + }; + const neverUpdated = { + ...plugin, + id: "acme/never-updated", + publishedAt: "2026-10-02T00:00:00.000Z", + updatedAt: "2026-10-02T00:00:00.000Z", + }; + expect(recentlyUpdated([bumped, neverUpdated, bumpedLater]).map((p) => p.id)).toEqual([ + "acme/bumped-later", + "acme/bumped", + ]); + }); + it("orders by first listing time and by installs in a window, keeping index order for ties", () => { + const older = { ...plugin, id: "acme/older", publishedAt: "2026-09-01" }; const twin = { ...plugin, id: "acme/twin" }; expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([ "acme/example", @@ -94,6 +163,82 @@ describe("plugin registry", () => { Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")), ).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]); }); + it("counts repeated install reports from the same IP once per hour", async () => { + let now = Date.now(); + const cache = memoryKv(() => now); + const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, headers); + now += 3_599_000; + await resolvePlugin(cache, headers); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 1, month: 1, all: 1 }, + }); + now += 1_000; + await resolvePlugin(cache, headers); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 2, month: 2, all: 2 }, + }); + }); + it("counts different IPs and plugins independently, including query install intent", async () => { + const cache = memoryKv(); + const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, first); + await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" }); + await resolvePlugin(cache, first, { id: "acme/other" }); + await resolvePlugin(cache, first, { query: "?intent=install" }); + await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true }); + expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({ + [plugin.id]: { week: 2, month: 2, all: 2 }, + "acme/other": { week: 1, month: 1, all: 1 }, + }); + const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex"); + expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1"); + expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1"); + expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1"); + }); + it("does not count without a Cloudflare IP or install intent", async () => { + const cache = memoryKv(); + await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" }); + await resolvePlugin(cache, {}, { query: "?intent=install" }); + await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" }); + await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" }); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 0, month: 0, all: 0 }, + }); + expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]); + }); + it("preserves historical totals and the installs endpoint response", async () => { + const cache = memoryKv(); + await cache.put(`plugin-installs:${plugin.id}`, "40"); + const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, headers); + await resolvePlugin(cache, headers); + const base = "https://registry.example.test"; + await cache.put( + `plugins:index:v1:${base}`, + JSON.stringify({ + fetchedAt: Date.now(), + value: { + schemaVersion: 1, + registry: { name: "Internal", url: base }, + categories: [], + plugins: [plugin], + featured: [], + generatedAt: "2026-10-03", + }, + }), + ); + const response = await handlePluginRegistryRequest( + new Request("https://paseo.sh/api/plugins/installs"), + { PLUGINS_REGISTRY_URL: base }, + { cache, waitUntil: () => undefined }, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ [plugin.id]: 41 }); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 1, month: 1, all: 41 }, + }); + }); it("searches name, description, ID, and author, ignoring case", () => { const other = { ...plugin, @@ -110,6 +255,32 @@ describe("plugin registry", () => { expect(searchPlugins(plugins, " ")).toEqual(plugins); expect(searchPlugins(plugins, "nothing")).toEqual([]); }); + it("ranks name, then author, then description matches, keeping the given order on ties", () => { + const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({ + ...plugin, + id, + name, + description, + author: { github }, + }); + // Given in install order: description-only matches first, the exact name last. + const plugins = [ + named("acme/editor", "Remote Editor", "Composer pill to open a workspace"), + named("acme/kit", "PromptKit", "Rewrite prompts"), + named("omp/tools", "Tools", "Helpers", "omp"), + named("acme/history", "History", "Inspect raw composer records"), + named("acme/omp", "OMP", "Paseo integration for OMP"), + ]; + expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([ + "acme/omp", + "acme/kit", + "omp/tools", + "acme/editor", + "acme/history", + ]); + expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]); + expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]); + }); it("strips the README title and quoted description that the page already shows", () => { expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n"); @@ -119,22 +290,63 @@ describe("plugin registry", () => { ); }); it("offers a registry install command", () => { - expect(installCommand(plugin)).toBe("paseo plugin install acme/example"); + expect(installCommand(plugin)).toBe("paseo plugin add acme/example"); expect(formatInstalls(1250)).toBe("1.3k"); }); + it("reports bugs on the source repository's GitHub issue tracker", () => { + const at = (url: string) => reportBugUrl({ ...plugin, repository: { url } }); + expect(at("https://github.com/acme/example")).toBe( + "https://github.com/acme/example/issues/new", + ); + expect(at("https://github.com/acme/plugins.git")).toBe( + "https://github.com/acme/plugins/issues/new", + ); + expect(at("https://github.com/acme/plugins/tree/HEAD/packages/example")).toBe( + "https://github.com/acme/plugins/issues/new", + ); + expect(at("https://gitlab.com/acme/example")).toBeUndefined(); + expect(at("javascript:alert(1)")).toBeUndefined(); + }); }); /** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */ -function memoryKv(): KVNamespace { +function memoryKv(now = Date.now): KVNamespace & { values: Map<string, string> } { const values = new Map<string, string>(); + const expiresAt = new Map<string, number>(); const kv = { + values, get: async (key: string, options?: { type?: string }) => { + if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key); const value = values.get(key) ?? null; return options?.type === "json" && value !== null ? JSON.parse(value) : value; }, - put: async (key: string, value: string) => { + put: async (key: string, value: string, options?: { expirationTtl?: number }) => { values.set(key, value); + expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity); }, }; - return kv as unknown as KVNamespace; + return kv as unknown as KVNamespace & { values: Map<string, string> }; +} + +async function resolvePlugin( + cache: KVNamespace, + headers: HeadersInit, + { id = plugin.id, query = "", staticDetail = false } = {}, +): Promise<void> { + const base = "https://registry.example.test"; + const detail = { ...plugin, id, readme: "# Example" }; + await cache.put( + `plugins:detail:v1:${base}:${id}`, + JSON.stringify({ fetchedAt: Date.now(), value: detail }), + ); + const pending: Promise<unknown>[] = []; + const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`; + const response = await handlePluginRegistryRequest( + new Request(`https://paseo.sh${path}${query}`, { headers }), + { PLUGINS_REGISTRY_URL: base }, + { cache, waitUntil: (promise) => pending.push(promise) }, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual(detail); + await Promise.all(pending); } diff --git a/packages/website/src/plugins/registry.ts b/packages/website/src/plugins/registry.ts index 933399b3a2..7d99cb6789 100644 --- a/packages/website/src/plugins/registry.ts +++ b/packages/website/src/plugins/registry.ts @@ -1,5 +1,12 @@ import type { z } from "zod"; -import type { PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; +import { + isGitHubHost, + parseGitHubRemoteIdentity, + parseGitRemoteLocation, +} from "@getpaseo/protocol/git-remote"; +import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; +import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; +import { scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { CATEGORIES, type Category, type CategorySlug } from "./categories"; import type { InstallCounts, InstallWindow } from "./installs"; export { CATEGORIES, type Category, type CategorySlug }; @@ -15,6 +22,10 @@ const DAY_MS = 24 * 60 * 60 * 1000; export function getCategory(slug: string): Category | null { return CATEGORIES.find((category) => category.slug === slug) ?? null; } +/** The plugin's first HTTPS image, for cards and link previews; videos never stand in for one. */ +export function firstMediaImage(plugin: Plugin): string | undefined { + return plugin.media.find((url) => pluginOverviewUrl(url) && pluginMediaKind(url) === "image"); +} export function getPluginsInCategory(plugins: Plugin[], slug: string): Plugin[] { return plugins.filter((plugin) => plugin.categories.includes(slug)); } @@ -35,7 +46,7 @@ export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] { return plugins.filter((plugin) => pluginOwner(plugin) === owner); } export function installCommand(plugin: Plugin): string { - return `paseo plugin install ${plugin.id}`; + return `paseo plugin add ${plugin.id}`; } export function pluginVersion(plugin: Plugin): string { return plugin.artifact.kind === "npm" @@ -47,6 +58,14 @@ export function npmUrl(plugin: Plugin): string | undefined { ? `https://www.npmjs.com/package/${plugin.artifact.package}` : undefined; } +/** A new issue on the plugin's GitHub repository; absent when the source is hosted elsewhere. */ +export function reportBugUrl(plugin: Plugin): string | undefined { + const location = parseGitRemoteLocation(plugin.repository.url); + if (!location || !isGitHubHost(location.host)) return undefined; + // A monorepo source points at the plugin's folder; issues belong to the repository. + const repository = parseGitHubRemoteIdentity(location.path.split("/").slice(0, 2).join("/")); + return repository ? `https://github.com/${repository.repo}/issues/new` : undefined; +} export function authorNpmUrl(author: Author): string | undefined { return author.npm ? `https://www.npmjs.com/~${author.npm}` : undefined; } @@ -61,20 +80,57 @@ export function formatInstalls(count: number): string { const k = count / 1000; return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; } -/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */ -export function searchPlugins(plugins: Plugin[], term: string): Plugin[] { - const needle = term.trim().toLowerCase(); - if (!needle) return plugins; - return plugins.filter((plugin) => - [plugin.name, plugin.description, plugin.id, getAuthor(plugin).name] - .join(" ") - .toLowerCase() - .includes(needle), - ); +/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */ +function searchFieldGroups(plugin: Plugin): string[][] { + const author = getAuthor(plugin); + const name = [plugin.name, plugin.id.split("/")[1]]; + const owner = [author.name, author.github, plugin.id]; + return [name, owner, [plugin.description], [...name, ...owner, plugin.description]]; } -/** Newest submissions first; equal dates keep index order. */ +interface SearchRank { + field: number; + tier: number; +} +function searchRank(plugin: Plugin, term: string): SearchRank | null { + const groups = searchFieldGroups(plugin); + for (let field = 0; field < groups.length; field += 1) { + const score = scoreTextFields(term, groups[field], { subsequence: false }); + if (score) return { field, tier: score.tier }; + } + return null; +} +/** + * Plugins whose name, author, ID, or description contains every word of the term, ignoring case. + * Name matches come first, then author, then description; within each, exact words beat partial + * ones, and equal matches keep the given order. + */ +export function searchPlugins(plugins: Plugin[], term: string): Plugin[] { + if (!term.trim()) return plugins; + return plugins + .flatMap((plugin) => { + const rank = searchRank(plugin, term); + return rank ? [{ plugin, rank }] : []; + }) + .sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier) + .map(({ plugin }) => plugin); +} +/** The featured IDs that name a listed plugin, in the registry's order, each once. */ +export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] { + const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); + return [...new Set(featured)].flatMap((id) => byId.get(id) ?? []); +} +/** + * Most recently listed first. A slow review does not push a plugin down, and an update does not + * bring it back up. Equal times keep index order. + */ export function newestFirst(plugins: Plugin[]): Plugin[] { - return [...plugins].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt)); + return [...plugins].sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt)); +} +/** Plugins with a version approved after their first listing, most recent update first. */ +export function recentlyUpdated(plugins: Plugin[]): Plugin[] { + return plugins + .filter((plugin) => Date.parse(plugin.updatedAt) > Date.parse(plugin.publishedAt)) + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); } /** Most installs in the window first; equal counts keep index order. */ export function mostInstalled( @@ -85,9 +141,16 @@ export function mostInstalled( const count = (plugin: Plugin) => installs[plugin.id]?.[window] ?? 0; return [...plugins].sort((a, b) => count(b) - count(a)); } -/** How long ago the registry accepted the plugin, relative to the loader's clock. */ +/** How long ago the plugin was first listed, relative to the loader's clock. */ export function addedAgo(plugin: Plugin, now: string): string { - const days = Math.floor((Date.parse(now) - Date.parse(plugin.submittedAt)) / DAY_MS); + return ago(plugin.publishedAt, now); +} +/** How long ago the plugin's current version was approved, relative to the loader's clock. */ +export function updatedAgo(plugin: Plugin, now: string): string { + return ago(plugin.updatedAt, now); +} +function ago(date: string, now: string): string { + const days = Math.floor((Date.parse(now) - Date.parse(date)) / DAY_MS); if (days <= 0) return "today"; if (days === 1) return "yesterday"; if (days < 14) return `${days}d ago`; diff --git a/packages/website/src/plugins/section.tsx b/packages/website/src/plugins/section.tsx new file mode 100644 index 0000000000..690a758fde --- /dev/null +++ b/packages/website/src/plugins/section.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; + +/** A block of a plugins page: its header, then its content one fixed gap below. */ +export function PluginSection({ + id, + labelledBy, + className = "", + children, +}: { + id?: string; + labelledBy?: string; + className?: string; + children: ReactNode; +}) { + return ( + <section id={id} aria-labelledby={labelledBy} className={`flex flex-col gap-8 ${className}`}> + {children} + </section> + ); +} + +/** A section's title with its trailing controls on one baseline. */ +export function PluginSectionHeader({ children }: { children: ReactNode }) { + return ( + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">{children}</div> + ); +} + +export function PluginSectionTitle({ id, children }: { id: string; children: ReactNode }) { + return ( + <h2 id={id} className="text-lg font-medium"> + {children} + </h2> + ); +} diff --git a/packages/website/src/plugins/thumbnails.test.ts b/packages/website/src/plugins/thumbnails.test.ts new file mode 100644 index 0000000000..aaaaf47418 --- /dev/null +++ b/packages/website/src/plugins/thumbnails.test.ts @@ -0,0 +1,258 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails"; +import { PluginCard } from "./plugin-card"; +import type { Plugin } from "./registry"; + +const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png"; +const video = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/demo.mp4"; +const plugin: Plugin = { + id: "acme/example", + name: "Example", + description: "An example", + categories: ["themes"], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { + kind: "npm", + package: "paseo-example", + version: "1.2.3", + resolved: "https://registry.npmjs.org/example.tgz", + integrity: "sha512-YWJj", + }, + media: [source], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + installs: 42, +}; + +const avatar = 'src="https://github.com/acme.png?size=80"'; + +describe("plugin card thumbnails", () => { + it("uses the first image in the plugin's media, skipping a leading video", () => { + const html = renderToStaticMarkup( + createElement(PluginCard, { plugin: { ...plugin, media: [video, source] } }), + ); + expect(html).toContain(`/plugins/thumb/592/${encodeURIComponent(source)}?`); + expect(html).not.toContain(encodeURIComponent(video)); + }); + + it("shows the plugin tile when the media has no image", () => { + const html = renderToStaticMarkup( + createElement(PluginCard, { plugin: { ...plugin, media: [video] } }), + ); + expect(html.match(/<img [^>]*>/g)).toEqual([expect.stringContaining(avatar)]); + expect(html).not.toContain("<video"); + expect(html).toContain(">E</div>"); + }); + + it("keeps a screenshot on an unsupported host visible at its original URL", () => { + const screenshot = "https://example.com/preview.png"; + const html = renderToStaticMarkup( + createElement(PluginCard, { plugin: { ...plugin, media: [screenshot] } }), + ); + expect(html).toContain(`src="${screenshot}"`); + expect(html).not.toContain("/plugins/thumb/"); + }); + + it("shows the author's avatar and name beside the card detail", () => { + const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today")); + expect(html).toContain(avatar); + expect(html).toMatch(/>acme<\/span><span aria-hidden="true">·<\/span><span[^>]*>today<\/span>/); + }); + + it("renders a thumbnail instead of downloading the original screenshot", () => { + const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today")); + expect(html).toContain( + `src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`, + ); + expect(html).toContain( + `srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`, + ); + expect(html).toContain('loading="lazy"'); + expect(html).toContain('decoding="async"'); + }); +}); + +function thumbnailRequest( + sourceUrl = source, + width = 592, + id = plugin.id, + accept = "image/avif,image/webp", +) { + return new Request( + `https://paseo.sh/plugins/thumb/${width}/${encodeURIComponent(sourceUrl)}?plugin=${encodeURIComponent(id)}`, + { headers: { accept } }, + ); +} + +function imageResponse(body: string, headers: Record<string, string> = {}) { + return new Response(body, { headers: { "content-type": "image/png", ...headers } }); +} + +// A typed fetch dependency, returning real Responses without patching the global fetch. +function imageFetch(...responses: (Response | Error)[]) { + const calls: { input: Parameters<typeof fetch>[0]; init: Parameters<typeof fetch>[1] }[] = []; + const fetchImage: typeof fetch = async (input, init) => { + calls.push({ input, init }); + const response = responses.shift(); + if (!response) throw new Error("Unexpected fetch"); + if (response instanceof Error) throw response; + return response; + }; + return { calls, fetchImage }; +} + +describe("thumbnail route", () => { + it("sandboxes an SVG fallback when opened as a document on the website origin", async () => { + const svg = + '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.domain)</script></svg>'; + const upstream = imageFetch( + new Response("unsupported", { status: 415 }), + imageResponse(svg, { "content-type": "image/svg+xml" }), + ); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(await response.text()).toBe(svg); + expect(response.headers.get("content-security-policy")).toBe("sandbox"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + it.each([ + [592, source, "image/avif,image/webp", "avif"], + [592, source, "image/avif;q=0,image/webp", "webp"], + [592, source, "image/avif;q=0,image/webp;q=0", undefined], + [ + 1184, + "https://raw.githubusercontent.com/acme/example/abc123/preview.png", + "image/webp", + "webp", + ], + [592, source, "image/png,image/*;q=0.8", undefined], + ] as const)( + "transforms a registered screenshot at %s from %s for %s", + async (width, screenshot, accept, format) => { + const upstream = imageFetch(imageResponse("thumbnail")); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(screenshot, width, plugin.id, accept), + [{ ...plugin, media: [screenshot] }], + upstream.fetchImage, + ); + expect(upstream.calls).toEqual([ + { + input: screenshot, + init: { + cf: { image: { width, fit: "scale-down", format, quality: 80 } }, + }, + }, + ]); + expect(response.status).toBe(200); + expect(await response.text()).toBe("thumbnail"); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + expect(response.headers.get("vary")).toBe("Accept"); + }, + ); + + it.each([ + ["Cloudflare error", () => new Response("error 9401", { status: 403 })], + [ + "non-image success", + () => new Response("<html>unavailable</html>", { headers: { "content-type": "text/html" } }), + ], + ["Cf-Resized error", () => imageResponse("error", { "cf-resized": "err=9401" })], + ["network exception", () => new Error("Transform unavailable")], + ] as const)("proxies the original with immutable caching on %s", async (_, failure) => { + const upstream = imageFetch(failure(), imageResponse("original")); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(upstream.calls.map((call) => call.input)).toEqual([source, source]); + expect(upstream.calls[1].init).toBeUndefined(); + expect(response.status).toBe(200); + expect(await response.text()).toBe("original"); + expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); + }); + + it.each([ + ["arbitrary width", thumbnailRequest(source, 593), [plugin], 400], + ["unlisted source on allowed host", thumbnailRequest(source + "?different"), [plugin], 404], + ["wrong plugin ID", thumbnailRequest(source, 592, "acme/other"), [plugin], 404], + [ + "missing plugin ID", + new Request(`https://paseo.sh/plugins/thumb/592/${encodeURIComponent(source)}`), + [plugin], + 404, + ], + ["removed plugin", thumbnailRequest(), [], 404], + ["listed video", thumbnailRequest(video), [{ ...plugin, media: [video, source] }], 404], + ["malformed encoding", new Request("https://paseo.sh/plugins/thumb/592/%ZZ"), [plugin], 400], + ["malformed URL", thumbnailRequest("not a URL"), [plugin], 400], + ...[ + "https://example.com/preview.png", + "https://cdn.jsdelivr.net.evil.test/preview.png", + "http://cdn.jsdelivr.net/preview.png", + "https://user:pass@cdn.jsdelivr.net/preview.png", + "https://cdn.jsdelivr.net:8080/preview.png", + ].map( + (url) => + [ + "disallowed registered source", + thumbnailRequest(url), + [{ ...plugin, media: [url] }], + 400, + ] satisfies [string, Request, Plugin[], number], + ), + ] satisfies [string, Request, Plugin[], number][])( + "rejects %s without fetching an image", + async (_, request, plugins, status) => { + const upstream = imageFetch(); + const response = await handlePluginThumbnailRequest( + request, + [...plugins], + upstream.fetchImage, + ); + expect(response.status).toBe(status); + expect(upstream.calls).toEqual([]); + expect(response.headers.get("cache-control")).toBeNull(); + }, + ); + + it.each([ + () => new Response("missing", { status: 404 }), + () => new Response("<html>error</html>", { headers: { "content-type": "text/html" } }), + () => new Error("Origin unavailable"), + ])("does not cache a failed original for a year", async (failure) => { + const upstream = imageFetch(new Response("transform error", { status: 502 }), failure()); + const response = await handlePluginThumbnailRequest( + thumbnailRequest(), + [plugin], + upstream.fetchImage, + ); + expect(response.status).toBe(502); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("round-trips screenshot URL punctuation through the card URL", async () => { + const screenshot = source + "?name=a&other=b#preview"; + const upstream = imageFetch(imageResponse("thumbnail")); + const request = new Request( + "https://paseo.sh" + pluginCardScreenshot(plugin.id, screenshot).src, + ); + const response = await handlePluginThumbnailRequest( + request, + [{ ...plugin, media: [screenshot] }], + upstream.fetchImage, + ); + expect(response.status).toBe(200); + expect(upstream.calls[0].input).toBe(screenshot); + }); +}); diff --git a/packages/website/src/plugins/thumbnails.ts b/packages/website/src/plugins/thumbnails.ts new file mode 100644 index 0000000000..1d5b0304ce --- /dev/null +++ b/packages/website/src/plugins/thumbnails.ts @@ -0,0 +1,119 @@ +import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry"; +import type { Plugin } from "./registry"; + +// The single-column grid reaches 591px just below sm (640px minus 48px padding). +const CARD_WIDTH = 592; +const WIDTHS = new Set([CARD_WIDTH, CARD_WIDTH * 2]); +const SOURCE_HOSTS = new Set(["cdn.jsdelivr.net", "raw.githubusercontent.com"]); + +export function pluginCardScreenshot(id: string, source: string): { src: string; srcSet?: string } { + if (!parseSource(source)) return { src: source }; + return { + src: pluginThumbnailUrl(id, source, 1), + srcSet: `${pluginThumbnailUrl(id, source, 1)} 1x, ${pluginThumbnailUrl(id, source, 2)} 2x`, + }; +} + +function pluginThumbnailUrl(id: string, source: string, scale: 1 | 2): string { + return `/plugins/thumb/${CARD_WIDTH * scale}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`; +} + +/** Only images listed in the plugin's media are eligible for transformation or original fallback. */ +export async function handlePluginThumbnailRequest( + request: Request, + plugins: Plugin[], + fetchImage: typeof fetch = fetch, +): Promise<Response> { + const url = new URL(request.url); + const match = /^\/plugins\/thumb\/(\d+)\/([^/]+)$/.exec(url.pathname); + if (!match || !WIDTHS.has(Number(match[1]))) + return new Response("Invalid thumbnail width or source", { status: 400 }); + + let decoded: string; + try { + decoded = decodeURIComponent(match[2]); + } catch { + return new Response("Invalid thumbnail source", { status: 400 }); + } + const source = parseSource(decoded); + if (!source) return new Response("Invalid thumbnail source", { status: 400 }); + + const plugin = plugins.find((entry) => entry.id === url.searchParams.get("plugin")); + if (!plugin?.media.includes(source) || pluginMediaKind(source) !== "image") + return new Response("Screenshot not found", { status: 404 }); + + const format = negotiatedFormat(request.headers.get("accept") ?? ""); + let response: Response | undefined; + try { + response = await fetchImage(source, { + cf: { image: { width: Number(match[1]), fit: "scale-down", format, quality: 80 } }, + }); + } catch { + // An unavailable transformation must not prevent fetching the original. + } + if (!response || !isImage(response) || /err=/i.test(response.headers.get("cf-resized") ?? "")) { + await response?.body?.cancel(); + try { + response = await fetchImage(source); + } catch { + return unavailableImage(); + } + } + if (!isImage(response)) { + await response.body?.cancel(); + return unavailableImage(); + } + return new Response(response.body, { + headers: { + "content-type": response.headers.get("content-type")!, + "cache-control": "public, max-age=31536000, immutable", + vary: "Accept", + // Originals can be SVGs. Keep direct navigation from executing them on our origin. + "content-security-policy": "sandbox", + "x-content-type-options": "nosniff", + }, + }); +} + +function isImage(response: Response): boolean { + return response.ok && /^image\//i.test(response.headers.get("content-type") ?? ""); +} + +function unavailableImage(): Response { + return new Response("Screenshot unavailable", { + status: 502, + headers: { "cache-control": "no-store" }, + }); +} + +function parseSource(source: string): string | null { + try { + const url = new URL(source); + if ( + url.protocol !== "https:" || + !SOURCE_HOSTS.has(url.hostname) || + url.username || + url.password || + url.port + ) + return null; + return source; + } catch { + return null; + } +} + +function negotiatedFormat(accept: string): "avif" | "webp" | undefined { + // The Worker API requires explicit negotiation; only the URL API accepts format=auto. + const accepted = new Map( + accept + .toLowerCase() + .split(",") + .map((range) => { + const [type, ...parameters] = range.split(";").map((part) => part.trim()); + const quality = parameters.find((parameter) => parameter.startsWith("q=")); + return [type, quality === undefined ? 1 : Number(quality.slice(2))] as const; + }), + ); + return (["avif", "webp"] as const).find((format) => (accepted.get(`image/${format}`) ?? 0) > 0); +} diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 38806673f9..5f0bc664ae 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -6,7 +6,7 @@ export const Route = createFileRoute("/")({ head: () => pageMeta( "Paseo – Run Claude Code, Codex, Copilot, OpenCode from anywhere", - "Self-hosted daemon for Claude Code, Codex, Copilot, OpenCode, and Pi. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.", + "Open source app for Claude Code, Codex, OpenCode, Pi, and 30+ more coding agents. Run many agents in parallel on your machines. Editor, terminals, diffs, pull requests, and a browser in one window. One download on desktop. The full app on iOS and Android.", "/", ), component: Home, @@ -17,16 +17,19 @@ function Home() { <LandingPage title={ <> - The control plane + The agentic development <br /> - for coding agents + environment </> } subtitle={ <> - Run any coding agent from anywhere. + Run many coding agents at once, + <br className="sm:hidden" /> + <span className="hidden sm:inline"> </span> + on any machine. <br /> - Self-hosted, multi-provider, open source + From your desk and from your phone. </> } /> diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx index e4db39a205..89c4f185ef 100644 --- a/packages/website/src/routes/plugins/$owner.tsx +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -1,3 +1,4 @@ +import { PluginContentLink } from "~/plugins/overview"; import { createFileRoute, notFound } from "@tanstack/react-router"; import { ExternalLink } from "lucide-react"; import { useMemo } from "react"; @@ -15,6 +16,7 @@ import { } from "~/plugins"; import { AuthorAvatar } from "~/plugins/author-link"; import { PluginsNotFound } from "~/plugins/not-found"; +import { InstallCount } from "~/plugins/install-count"; import { PluginCard } from "~/plugins/plugin-card"; import "~/styles.css"; @@ -64,21 +66,16 @@ function AuthorPage() { </p> <div className="flex items-center gap-4 pt-1"> {authorNpmUrl(author) && ( - <a - href={authorNpmUrl(author)} - target="_blank" - rel="noopener noreferrer" - className={LINK_CLASS} - > + <PluginContentLink href={authorNpmUrl(author)} className={LINK_CLASS}> npm <ExternalLink className="h-3 w-3" /> - </a> + </PluginContentLink> )} {github && ( - <a href={github} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}> + <PluginContentLink href={github} className={LINK_CLASS}> GitHub <ExternalLink className="h-3 w-3" /> - </a> + </PluginContentLink> )} </div> </div> @@ -86,7 +83,9 @@ function AuthorPage() { <div className="mt-12 grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3"> {plugins.map((plugin) => ( - <PluginCard key={plugin.id} plugin={plugin} installs={installs[plugin.id]?.all ?? 0} /> + <PluginCard key={plugin.id} plugin={plugin}> + <InstallCount count={installs[plugin.id]?.all ?? 0} /> + </PluginCard> ))} </div> </SiteShell> diff --git a/packages/website/src/routes/plugins/$owner_.$slug.tsx b/packages/website/src/routes/plugins/$owner_.$slug.tsx index b70a13d9da..b3d02177aa 100644 --- a/packages/website/src/routes/plugins/$owner_.$slug.tsx +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -1,11 +1,12 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import { ExternalLink } from "lucide-react"; +import { GitHubIcon } from "~/components/brand-icons"; import { CodeBlock } from "~/components/code-block"; -import { DocsMarkdown } from "~/components/docs-markdown"; +import { PluginOverview, PluginContentLink } from "~/plugins/overview"; import { SiteShell } from "~/components/site-shell"; import { pageMeta } from "~/meta"; import { - formatInstalls, + firstMediaImage, getAuthor, getCategory, getRegistry, @@ -14,9 +15,12 @@ import { npmUrl, pluginVersion, readmeBody, + reportBugUrl, } from "~/plugins"; -import { AuthorLink } from "~/plugins/author-link"; +import { AuthorAvatar, AuthorLink } from "~/plugins/author-link"; +import { InstallCount } from "~/plugins/install-count"; import { categoryHref } from "~/plugins/links"; +import { MediaGallery } from "~/plugins/media-gallery"; import { PluginsNotFound } from "~/plugins/not-found"; import "~/styles.css"; @@ -34,7 +38,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({ loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo", loaderData?.plugin?.description ?? "Plugin not found.", `/plugins/${params.owner}/${params.slug}`, - loaderData?.plugin.screenshots[0], + loaderData?.plugin && firstMediaImage(loaderData.plugin), ), component: PluginPage, notFoundComponent: () => ( @@ -46,76 +50,64 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({ const META_LINK_CLASS = "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; +const REPORT_BUG_CLASS = + "inline-flex items-center gap-2 rounded-lg border border-white/12 px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"; +const AUTHOR_LINK_CLASS = + "inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; function PluginPage() { const { plugin, installs } = Route.useLoaderData(); const category = getCategory(plugin.categories[0]); const author = getAuthor(plugin); const npm = npmUrl(plugin); + const reportBug = reportBugUrl(plugin); return ( <SiteShell width="default"> - <div className="max-w-3xl"> - <a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground"> - ← Plugins - </a> - <h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1> - <p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p> - <div className="mt-6"> + <a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground"> + ← Plugins + </a> + <h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1> + <p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p> + <div className="mt-6 flex flex-wrap items-center gap-3"> + <div className="w-fit max-w-full"> <CodeBlock size="sm">{installCommand(plugin)}</CodeBlock> </div> - <div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']"> - <AuthorLink author={author} className={META_LINK_CLASS}> - {author.name} - </AuthorLink> - {category && ( - <a href={categoryHref(category.slug)} className={META_LINK_CLASS}> - {category.label} - </a> - )} - <span className="tabular-nums">{formatInstalls(installs)} installs</span> - <span className="font-mono">{pluginVersion(plugin)}</span> - <a - href={plugin.repository.url} - target="_blank" - rel="noopener noreferrer" - className={META_LINK_CLASS} - > - Source - <ExternalLink className="h-3 w-3" /> - </a> - {npm && ( - <a href={npm} target="_blank" rel="noopener noreferrer" className={META_LINK_CLASS}> - npm - <ExternalLink className="h-3 w-3" /> - </a> - )} - </div> - - {plugin.screenshots.length > 0 && ( - <div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0"> - {plugin.screenshots.map((url, index) => ( - <a - key={url} - href={url} - target="_blank" - rel="noopener noreferrer" - className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]" - > - <img - src={url} - alt={`${plugin.name} screenshot ${index + 1}`} - loading="lazy" - className="h-full w-full object-cover object-top" - /> - </a> - ))} - </div> + {reportBug && ( + <PluginContentLink href={reportBug} className={REPORT_BUG_CLASS}> + <GitHubIcon className="h-4 w-4" /> + Report a bug + </PluginContentLink> )} + </div> + <div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']"> + <AuthorLink author={author} className={AUTHOR_LINK_CLASS}> + <AuthorAvatar author={author} size="xs" /> + {author.name} + </AuthorLink> + {category && ( + <a href={categoryHref(category.slug)} className={META_LINK_CLASS}> + {category.label} + </a> + )} + <InstallCount count={installs} /> + <span className="font-mono">{pluginVersion(plugin)}</span> + <PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}> + Source + <ExternalLink className="h-3 w-3" /> + </PluginContentLink> + {npm && ( + <PluginContentLink href={npm} className={META_LINK_CLASS}> + npm + <ExternalLink className="h-3 w-3" /> + </PluginContentLink> + )} + </div> - <div className="mt-10 border-t border-white/10 pt-10"> - <DocsMarkdown>{readmeBody(plugin.readme)}</DocsMarkdown> - </div> + <MediaGallery name={plugin.name} media={plugin.media} /> + + <div className="mt-10 border-t border-white/10 pt-10"> + <PluginOverview>{readmeBody(plugin.readme)}</PluginOverview> </div> </SiteShell> ); diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index c95273cc60..613afc375c 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -19,14 +19,19 @@ import { addedAgo, CATEGORIES, type CategorySlug, + featuredPlugins, getCategory, getPluginsInCategory, getRegistry, type InstallWindow, mostInstalled, newestFirst, + pluginOwner, + recentlyUpdated, + updatedAgo, } from "~/plugins"; -import { ContributeLinks } from "~/plugins/contribute-links"; +import { ContributeSection } from "~/plugins/contribute-links"; +import { PluginsHero } from "~/plugins/hero"; import { type BrowseQuery, browseHref, @@ -37,8 +42,9 @@ import { parseSort, parseWindow, } from "~/plugins/links"; -import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card"; -import { PluginSearch } from "~/plugins/plugin-search"; +import { InstallCount } from "~/plugins/install-count"; +import { PluginCard, PluginRankRow } from "~/plugins/plugin-card"; +import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section"; import { WindowSwitch } from "~/plugins/window-switch"; import "~/styles.css"; @@ -57,6 +63,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = { utils: Wrench, }; +// Card sections: a swipeable row on phones, a grid from `sm` up. +const CARD_ROW_CLASS = + "-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 *:w-[70%] *:flex-shrink-0 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 sm:*:w-auto lg:grid-cols-4"; const SEE_ALL_CLASS = "inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground"; @@ -72,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, @@ -89,9 +98,11 @@ export const Route = createFileRoute("/plugins/")({ }); function PluginsPage() { - const { plugins, installs, now } = Route.useLoaderData(); + const { plugins, featured: featuredIds, installs, now } = Route.useLoaderData(); const window = Route.useSearch().window ?? DEFAULT_WINDOW; + const featured = featuredPlugins(plugins, featuredIds); const newest = newestFirst(plugins).slice(0, NEW_COUNT); + const updated = recentlyUpdated(plugins).slice(0, NEW_COUNT); const top = mostInstalled(plugins, installs, window).slice(0, TOP_COUNT); const windowHrefs = useMemo( () => ({ @@ -102,93 +113,136 @@ function PluginsPage() { [], ); const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]); + const authorCount = new Set(plugins.map(pluginOwner)).size; return ( <SiteShell width="default"> - <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> - <h1 className="text-3xl font-medium tracking-tight"> - Plugins - <span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground"> - {plugins.length} - </span> - </h1> - <div className="flex flex-wrap items-center gap-x-5 gap-y-3"> - <PluginSearch scope={searchScope} className="w-full sm:w-56" /> - <ContributeLinks /> - </div> + <PluginsHero + pluginCount={plugins.length} + authorCount={authorCount} + searchScope={searchScope} + /> + + <div className="mt-16 flex flex-col gap-14"> + {featured.length > 0 && ( + <PluginSection labelledBy="featured"> + <PluginSectionHeader> + <div> + <PluginSectionTitle id="featured">Featured</PluginSectionTitle> + <p className="text-sm text-muted-foreground">A selection of hand picked plugins</p> + </div> + <a + href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })} + className={SEE_ALL_CLASS} + > + See all + <ChevronRight className="h-3.5 w-3.5" /> + </a> + </PluginSectionHeader> + <div className={CARD_ROW_CLASS}> + {featured.map((plugin) => ( + <PluginCard key={plugin.id} plugin={plugin}> + <InstallCount count={installs[plugin.id]?.all ?? 0} /> + </PluginCard> + ))} + </div> + </PluginSection> + )} + + <PluginSection labelledBy="whats-new"> + <PluginSectionHeader> + <PluginSectionTitle id="whats-new">What’s new</PluginSectionTitle> + <a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}> + See all + <ChevronRight className="h-3.5 w-3.5" /> + </a> + </PluginSectionHeader> + <div className={CARD_ROW_CLASS}> + {newest.map((plugin) => ( + <PluginCard key={plugin.id} plugin={plugin}> + {addedAgo(plugin, now)} + </PluginCard> + ))} + </div> + </PluginSection> + + {updated.length > 0 && ( + <PluginSection labelledBy="recently-updated"> + <PluginSectionHeader> + <PluginSectionTitle id="recently-updated">Recently updated</PluginSectionTitle> + <a + href={browseHref({ sort: "updated", window: DEFAULT_WINDOW })} + className={SEE_ALL_CLASS} + > + See all + <ChevronRight className="h-3.5 w-3.5" /> + </a> + </PluginSectionHeader> + <div className={CARD_ROW_CLASS}> + {updated.map((plugin) => ( + <PluginCard key={plugin.id} plugin={plugin}> + {updatedAgo(plugin, now)} + </PluginCard> + ))} + </div> + </PluginSection> + )} + + <PluginSection labelledBy="categories"> + <PluginSectionTitle id="categories">Categories</PluginSectionTitle> + <div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> + {CATEGORIES.map((category) => { + const Icon = CATEGORY_ICONS[category.slug]; + return ( + <a + key={category.slug} + href={categoryHref(category.slug)} + className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4" + > + <Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> + <span className="min-w-0 flex-1 text-sm leading-tight text-white"> + {category.label} + </span> + <span className="text-xs tabular-nums text-extra-muted-foreground"> + {getPluginsInCategory(plugins, category.slug).length} + </span> + </a> + ); + })} + </div> + </PluginSection> + + <PluginSection + id="most-installed" + labelledBy="most-installed-title" + className="scroll-mt-8" + > + <PluginSectionHeader> + <PluginSectionTitle id="most-installed-title"> + <a + href={browseHref({ sort: "installs", window })} + className="group inline-flex items-center gap-1" + > + Most installed + <ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" /> + </a> + </PluginSectionTitle> + <WindowSwitch current={window} hrefs={windowHrefs} /> + </PluginSectionHeader> + <div className="-mx-4 grid gap-x-8 md:grid-cols-2"> + {top.map((plugin, index) => ( + <PluginRankRow + key={plugin.id} + plugin={plugin} + rank={index + 1} + installs={installs[plugin.id]?.[window] ?? 0} + /> + ))} + </div> + </PluginSection> </div> - <section aria-labelledby="whats-new" className="mt-10"> - <div className="mb-4 flex items-baseline justify-between gap-4"> - <h2 id="whats-new" className="text-lg font-medium"> - What’s new - </h2> - <a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}> - See all - <ChevronRight className="h-3.5 w-3.5" /> - </a> - </div> - <div className="-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4"> - {newest.map((plugin) => ( - <NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} /> - ))} - </div> - </section> - - <section aria-labelledby="categories" className="mt-14"> - <h2 id="categories" className="mb-4 text-lg font-medium"> - Categories - </h2> - <div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> - {CATEGORIES.map((category) => { - const Icon = CATEGORY_ICONS[category.slug]; - return ( - <a - key={category.slug} - href={categoryHref(category.slug)} - className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4" - > - <Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> - <span className="min-w-0 flex-1 text-sm leading-tight text-white"> - {category.label} - </span> - <span className="text-xs tabular-nums text-extra-muted-foreground"> - {getPluginsInCategory(plugins, category.slug).length} - </span> - </a> - ); - })} - </div> - </section> - - <section - id="most-installed" - aria-labelledby="most-installed-title" - className="mt-14 scroll-mt-8" - > - <div className="mb-4 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2"> - <h2 id="most-installed-title" className="text-lg font-medium"> - <a - href={browseHref({ sort: "installs", window })} - className="group inline-flex items-center gap-1" - > - Most installed - <ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" /> - </a> - </h2> - <WindowSwitch current={window} hrefs={windowHrefs} /> - </div> - <div className="-mx-2 grid gap-x-8 md:grid-cols-2"> - {top.map((plugin, index) => ( - <PluginRankRow - key={plugin.id} - plugin={plugin} - rank={index + 1} - installs={installs[plugin.id]?.[window] ?? 0} - /> - ))} - </div> - </section> + <ContributeSection /> </SiteShell> ); } diff --git a/packages/website/src/routes/sponsor.tsx b/packages/website/src/routes/sponsor.tsx index 6c5b1b4f46..651f61dd21 100644 --- a/packages/website/src/routes/sponsor.tsx +++ b/packages/website/src/routes/sponsor.tsx @@ -7,7 +7,7 @@ export const Route = createFileRoute("/sponsor")({ head: () => pageMeta( "Sponsor Paseo", - "Paseo is built by one person with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or sponsor it as a company.", + "Paseo is an independent project used by tens of thousands of developers daily, maintained by one person, shaped by the community, with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or sponsor it as a company.", "/sponsor", ), component: Sponsor, diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index 946176fb43..bae421ff22 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -504,6 +504,24 @@ background-color: rgba(255, 255, 255, 0.02); } +/* Comparison tables: the browser would size each column by its longest cell, + so the two product columns get equal width regardless of content. */ +.comparison-prose table { + display: table; + table-layout: fixed; +} + +.comparison-prose th, +.comparison-prose td { + overflow-wrap: anywhere; + hyphens: auto; +} + +.comparison-prose th:first-child, +.comparison-prose td:first-child { + width: max(22%, 7.5rem); +} + .docs-prose pre { background-color: var(--color-card) !important; border: 1px solid var(--color-border); 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/antigravity-provider/server/internal/process.ts b/plugins/antigravity-provider/server/internal/process.ts index 899a88e5cd..c87a17aadb 100644 --- a/plugins/antigravity-provider/server/internal/process.ts +++ b/plugins/antigravity-provider/server/internal/process.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawnProcess } from "@getpaseo/plugin/server"; import { signalProcess } from "./signals.js"; import { createInterface } from "node:readline"; import type { ProviderLaunch, ProviderSessionConfig } from "@getpaseo/plugin/server/provider"; @@ -49,7 +50,7 @@ function driverArgs(options: DriverOptions): string[] { } export function startDriver(options: DriverOptions): Driver { - const child = spawn(options.launch.command, driverArgs(options), { + const child = spawnProcess(options.launch.command, driverArgs(options), { cwd: options.config.cwd, env: { ...options.launch.env, ...options.config.env }, detached: process.platform !== "win32", @@ -183,7 +184,7 @@ interface ProbeOptions { } export function probe(options: ProbeOptions): Promise<string> { return new Promise((resolve, reject) => { - const child = spawn(options.launch.command, [...options.launch.args, ...options.args], { + const child = spawnProcess(options.launch.command, [...options.launch.args, ...options.args], { env: options.launch.env, cwd: options.cwd, stdio: "pipe", diff --git a/plugins/antigravity-provider/server/internal/signals.ts b/plugins/antigravity-provider/server/internal/signals.ts index be34ecc8c9..f7f5b64eab 100644 --- a/plugins/antigravity-provider/server/internal/signals.ts +++ b/plugins/antigravity-provider/server/internal/signals.ts @@ -1,5 +1,4 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { execCommand } from "@getpaseo/plugin/server"; interface SignalOptions { platform: NodeJS.Platform; @@ -11,8 +10,6 @@ type SignalPlan = | { type: "group"; pid: number; signal: NodeJS.Signals } | { type: "tree"; command: "taskkill"; args: string[] }; -const executeFile = promisify(execFile); - export function signalPlan(options: SignalOptions): SignalPlan { if (options.platform === "win32") { return { type: "tree", command: "taskkill", args: ["/PID", String(options.pid), "/T", "/F"] }; @@ -32,7 +29,7 @@ export function signalProcess(options: SignalOptions): void | Promise<void> { async function killTree(plan: Extract<SignalPlan, { type: "tree" }>): Promise<void> { try { - await executeFile(plan.command, plan.args, { windowsHide: true }); + await execCommand(plan.command, plan.args, { shell: false }); } catch (error) { // taskkill reports an already-exited PID with exit code 128. if (!(error instanceof Error && "code" in error && error.code === 128)) throw error; diff --git a/plugins/antigravity-provider/server/provider.test.ts b/plugins/antigravity-provider/server/provider.test.ts index 8ba12841e0..631f10a6fe 100644 --- a/plugins/antigravity-provider/server/provider.test.ts +++ b/plugins/antigravity-provider/server/provider.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -773,3 +773,19 @@ describe("image prompts", () => { expect((await h.records()).filter((entry) => entry.input)).toHaveLength(0); }); }); + +it.runIf(process.platform === "win32")( + "launches an Antigravity command shim for probes and conversation startup", + async () => { + const h = await harness(); + const shim = path.join(h.cwd, "agy launcher.cmd"); + await writeFile(shim, `@echo off\r\n"${process.execPath}" "${fake}" %*\r\n`); + h.launch.command = shim; + h.launch.args = ["--launch-prefix"]; + expect(await provider.status!({ launch: h.launch })).toEqual({ available: true }); + await h.open(); + expect(await h.wait((event) => event.type === "session.opened")).toMatchObject({ + sessionId: "s", + }); + }, +); diff --git a/plugins/claude-usage-source/server/keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts index d85401037a..dd6c58f77a 100644 --- a/plugins/claude-usage-source/server/keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -141,6 +141,7 @@ describe("Claude credential routes", () => { expect(accounts).toEqual([ { key: "fixture-account.fixture-org", + harness: "Claude", input: { route: { store: "claude", path: join(dir, ".credentials.json") } }, }, ]); diff --git a/plugins/claude-usage-source/server/stores.test.ts b/plugins/claude-usage-source/server/stores.test.ts index 75248116cc..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(); } @@ -237,6 +240,7 @@ test("keychain identity belongs to its token even when Claude Code metadata name ); expect(identity).toEqual({ key: "pi-account.pi-org", + harness: "Claude", input: { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" }, }, @@ -247,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/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index 2a0c91235f..29e78a54e9 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -50,10 +50,10 @@ function mockFetch(handlers: Map<string, () => Response>): typeof fetch { }) as unknown as typeof fetch; } -function jsonResponse(body: unknown, status = 200): Response { +function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response { return new Response(JSON.stringify(body), { status, - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...headers }, }); } @@ -915,6 +915,7 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc expect(accounts).toEqual([ { key: expect.stringMatching(/^[a-f0-9]{64}$/), + harness: "Claude", input: { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture" }, }, @@ -960,7 +961,11 @@ it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account", const api: typeof fetch = async () => new Response(null, { status }); const accounts = await discover({ kind: "global" }, lookup, api); expect(accounts).toEqual([ - { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: credentialInput(lookup.claudeHome) }, + { + key: expect.stringMatching(/^[a-f0-9]{64}$/), + harness: "Claude", + input: credentialInput(lookup.claudeHome), + }, ]); let fetches = 0; expect( @@ -992,11 +997,42 @@ it.each([ return new Response(null, { status: 500 }); }; const accounts = await discover({ kind: "global" }, lookup, api); - expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]); + expect(accounts).toEqual([ + { key: hashAccountKey(JSON.stringify(input.route)), harness: "Claude", input }, + ]); await expect(fetchUsage(accounts[0]!.input as UsageInput, api, lookup)).rejects.toThrow(error); }, ); +it("says when a rate-limited login can retry and does not call the API before then", async () => { + let now = Date.parse("2026-10-04T21:00:00Z"); + const lookup = { ...tokenLookup("rate-limited-token"), now: () => now }; + const input = credentialInput(lookup.claudeHome); + let usageCalls = 0; + const api: typeof fetch = async () => { + usageCalls += 1; + if (usageCalls > 1) return jsonResponse(makeClaudeResponse()); + return jsonResponse( + { error: { type: "rate_limit_error", message: "Rate limited. Please try again later." } }, + 429, + { "Retry-After": "2240" }, + ); + }; + + await expect(fetchUsage(input, api, lookup)).rejects.toThrow( + "Rate limited by Claude. Try again in 38m.", + ); + now += 30 * 60_000; + await expect(fetchUsage(input, api, lookup)).rejects.toThrow( + "Rate limited by Claude. Try again in 8m.", + ); + expect(usageCalls).toBe(1); + + now += 8 * 60_000; + expect((await fetchUsage(input, api, lookup)).status).toBe("available"); + expect(usageCalls).toBe(2); +}); + it("re-reads a refreshed login at the original locator", async () => { const lookup = tokenLookup("expiring-token"); writeFileSync( @@ -1042,7 +1078,12 @@ it("session discovery reads only its Claude home and excludes foreign sessions", const lookup = { platform: "linux" as const, home: "/unused-default-home", env: {} }; const accounts = await discover(scope, lookup, api); expect(accounts).toEqual([ - { key: "session-account.org", label: "session@example.test", input: credentialInput(home) }, + { + key: "session-account.org", + label: "session@example.test", + harness: "Claude", + input: credentialInput(home), + }, ]); await discover(scope, lookup, api); expect(calls).toEqual(["https://api.anthropic.com/api/oauth/profile"]); diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index b3781ba86c..9efade2062 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -380,7 +380,10 @@ export async function discover( for (const input of candidates) { const credentials = await resolveClaudeCredentials(input, lookup); if (!credentials) continue; - const fallback = { key: hashAccountKey(JSON.stringify(input.route)), input }; + const harness = { pi: "Pi", omp: "OMP", claude: "Claude", keychain: "Claude" }[ + input.route.store + ]; + const fallback = { key: hashAccountKey(JSON.stringify(input.route)), harness, input }; if (credentials.expires !== undefined && credentials.expires <= (lookup.now ?? Date.now)()) { accounts.push(fallback); continue; @@ -391,7 +394,7 @@ export async function discover( fetchApi, lookup.now ?? Date.now, ); - accounts.push("status" in profile ? fallback : { ...profile, input }); + accounts.push("status" in profile ? fallback : { ...profile, harness, input }); } catch { // Keep the login visible; fetching usage reports the vendor failure. accounts.push(fallback); @@ -521,7 +524,16 @@ export async function fetchUsage( return parsed; } - async function callClaudeApi(token: string): Promise<ClaudeUsageResponse | number> { + async function callClaudeApi(token: string, now: number): Promise<ClaudeUsageResponse | number> { + for (const [key, until] of rateLimitedUntil) { + if (until <= now) rateLimitedUntil.delete(key); + } + const tokenKey = hashAccountKey(token); + const limitedUntil = rateLimitedUntil.get(tokenKey); + if (limitedUntil !== undefined && limitedUntil > now) + throw rateLimitedError(limitedUntil - now); + rateLimitedUntil.delete(tokenKey); + const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", { signal: AbortSignal.timeout(15_000), headers: { @@ -531,6 +543,11 @@ export async function fetchUsage( }, }); if (res.status === 401 || res.status === 403) return res.status; + if (res.status === 429) { + const waitMs = retryAfterMs(res.headers.get("retry-after"), now); + if (waitMs !== null) rateLimitedUntil.set(tokenKey, now + waitMs); + throw rateLimitedError(waitMs); + } if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`); return ClaudeUsageResponseSchema.parse(await res.json()); } @@ -559,7 +576,7 @@ export async function fetchUsage( const { oauth } = credentials; const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); - const resp = await callClaudeApi(oauth.accessToken); + const resp = await callClaudeApi(oauth.accessToken, (credentialLookup.now ?? Date.now)()); if (typeof resp === "number") return unavailable({ kind: "rejected", status: resp, refreshedBy }); @@ -595,6 +612,28 @@ export async function fetchUsage( }; } +// A 429 from the usage endpoint carries Retry-After, observed in the tens of minutes. Until it +// passes, a fetch (including a forced Refresh) answers from here instead of calling again. +const rateLimitedUntil = new Map<string, number>(); + +function retryAfterMs(header: string | null, now: number): number | null { + if (!header) return null; + const seconds = Number(header); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const at = Date.parse(header); + return Number.isNaN(at) ? null : Math.max(0, at - now); +} + +function rateLimitedError(waitMs: number | null): Error { + const when = waitMs === null ? "later" : `in ${formatWait(waitMs)}`; + return new Error(`Rate limited by Claude. Try again ${when}.`); +} + +function formatWait(ms: number): string { + const minutes = Math.max(1, Math.ceil(ms / 60_000)); + return minutes < 60 ? `${minutes}m` : `${Math.ceil(minutes / 60)}h`; +} + // The OAuth usage endpoint meters the active organization selected by the token. type ClaudeIdentity = { key: string; label?: string } | { status: number }; const PROFILE_TTL_MS = 300_000; 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/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index 8222aae315..4cdefa2bcd 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -1,3 +1,4 @@ +import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -412,3 +413,55 @@ test("session discovery isolates CODEX_HOME and excludes foreign routes", async await rm(home, { recursive: true, force: true }); } }); + +test("#6155: real Codex and OpenCode stores share an account but keep agent failures separate", async () => { + await writeAuth("fixture-codex", "same-account"); + const data = join(fixtureHome, "data"); + await mkdir(join(data, "opencode"), { recursive: true }); + await writeFile( + join(data, "opencode", "auth.json"), + JSON.stringify({ + openai: { type: "oauth", access: "fixture-opencode", accountId: "same-account", expires: 0 }, + }), + ); + const env = { HOME: fixtureHome, CODEX_HOME: fixtureHome, XDG_DATA_HOME: data }; + const lookup = { home: fixtureHome, env, platform: "linux" as const, now: () => 1_000 }; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => id === "agent", + usageSession: () => ({ provider: "codex", env, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: (scope) => discover(scope, lookup), + fetch: (input) => + fetchUsage(inputSchema.parse(input), async () => new Response(null, { status: 401 }), lookup), + }); + const [host] = await registry.listReports(); + expect(host?.loginErrors).toEqual([ + { + harness: "Codex", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex" }, + }, + }, + { + harness: "OpenCode", + report: { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: "1970-01-01T00:00:00.000Z", + refreshedBy: "opencode", + }, + }, + }, + ]); + const [agent] = await registry.listReports({ agentId: "agent" }); + expect(agent?.report).toEqual({ + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex" }, + }); + expect(agent?.loginErrors).toEqual([host!.loginErrors![0]]); +}); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index cf5207ff22..0b211726c6 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -72,7 +72,8 @@ export async function discover( const present: UsageAccount[] = []; for (const input of candidates) { const auth = await readAuth(input, lookup); - if (auth) present.push({ ...accountIdentity(auth, input), input }); + if (auth) + present.push({ ...accountIdentity(auth, input), harness: harnessLabel(input), input }); } return present; } @@ -294,3 +295,8 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab claimString(id?.["email"]); return { key: key ?? hashAccountKey(JSON.stringify(input.route)), ...(label ? { label } : {}) }; } + +function harnessLabel(input: CodexUsageInput): string { + const labels = { codex: "Codex", opencode: "OpenCode", pi: "Pi", omp: "OMP" }; + return labels[input.route.store]; +} diff --git a/plugins/hoplite-provider/icon.svg b/plugins/hoplite-provider/icon.svg deleted file mode 100644 index ed585a9083..0000000000 --- a/plugins/hoplite-provider/icon.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="1.394 1.679 8.494 7.828" fill="currentColor"><path d="M8.237 7.813C8.276 7.789 8.327 7.796 8.358 7.83L9.741 9.346C9.797 9.407 9.754 9.506 9.671 9.507H8.977C8.924 9.507 8.881 9.463 8.881 9.41V9.125C8.881 9.089 8.861 9.057 8.83 9.04L7.589 8.379C7.524 8.344 7.521 8.251 7.584 8.212L8.237 7.813ZM3.944 1.679C4.502 1.679 5.052 1.82 5.54 2.09L5.962 2.323C6.024 2.358 6.029 2.446 5.97 2.486L5.091 3.09C5.065 3.108 5.05 3.137 5.05 3.169V3.495C5.05 3.529 5.032 3.561 5.003 3.578L4.68 3.769C4.594 3.82 4.498 3.722 4.549 3.637L4.592 3.566C4.63 3.502 4.584 3.421 4.51 3.421H4.028C3.925 3.421 3.894 3.562 3.989 3.604L4.377 3.775C4.448 3.806 4.455 3.904 4.389 3.944L3.77 4.323C3.755 4.332 3.737 4.337 3.72 4.337H3.361C3.309 4.337 3.268 4.379 3.268 4.431C3.268 4.484 3.309 4.526 3.361 4.526H3.779C3.797 4.526 3.814 4.521 3.829 4.513L7.599 2.272C8.135 1.954 8.815 2.008 9.294 2.407C9.307 2.418 9.314 2.434 9.314 2.451V2.729C9.315 2.812 9.413 2.856 9.475 2.8L9.728 2.57C9.789 2.514 9.888 2.558 9.888 2.641V5.812C9.888 5.865 9.845 5.908 9.792 5.908H9.359C9.331 5.908 9.303 5.895 9.285 5.873L8.374 4.773C8.337 4.728 8.268 4.728 8.229 4.771L7.468 5.625C7.429 5.669 7.438 5.737 7.487 5.769L9.038 6.776C9.098 6.815 9.096 6.903 9.035 6.939L6.618 8.363C6.556 8.4 6.478 8.358 6.474 8.286L6.439 7.633C6.437 7.578 6.48 7.532 6.535 7.532H6.925C6.948 7.532 6.971 7.523 6.988 7.508L7.211 7.31C7.276 7.252 7.236 7.144 7.148 7.142L5.991 7.118H1.49C1.437 7.118 1.394 7.075 1.394 7.022V3.308C1.394 3.255 1.437 3.212 1.49 3.212H2.544C2.597 3.212 2.64 3.169 2.64 3.116V2.969C2.64 2.916 2.597 2.873 2.544 2.873H1.461C1.366 2.873 1.329 2.75 1.408 2.697L2.134 2.221C2.672 1.867 3.301 1.679 3.944 1.679Z"/></svg> diff --git a/plugins/hoplite-provider/index.server.ts b/plugins/hoplite-provider/index.server.ts deleted file mode 100644 index 4c101df470..0000000000 --- a/plugins/hoplite-provider/index.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { createHopliteProvider } from "./server/provider.js"; - -export default function contribute(server: PluginServerContext) { - server.registerProvider(createHopliteProvider()); - return () => {}; -} diff --git a/plugins/hoplite-provider/paseo-plugin.json b/plugins/hoplite-provider/paseo-plugin.json deleted file mode 100644 index 138cf83ad6..0000000000 --- a/plugins/hoplite-provider/paseo-plugin.json +++ /dev/null @@ -1 +0,0 @@ -{ "id": "hoplite-provider", "requirements": { "paseo": ">=0.11.0" } } diff --git a/plugins/hoplite-provider/server/provider.ts b/plugins/hoplite-provider/server/provider.ts deleted file mode 100644 index 02b73e73bd..0000000000 --- a/plugins/hoplite-provider/server/provider.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { runAcpProvider } from "@getpaseo/plugin/server/acp"; -import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; -import { runPhaseTransformer } from "./run-phase.js"; - -export function createHopliteProvider(): ProviderRegistration { - return runAcpProvider({ - id: "hoplite", - label: "Hoplite", - description: "Hoplite's coding agent through the local `hoplite acp` bridge", - icon: "icon.svg", - command: ["hoplite", "acp"], - transformers: [runPhaseTransformer], - }); -} diff --git a/plugins/hoplite-provider/server/run-phase.test.ts b/plugins/hoplite-provider/server/run-phase.test.ts deleted file mode 100644 index 5526f386c6..0000000000 --- a/plugins/hoplite-provider/server/run-phase.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { runPhaseTransformer } from "./run-phase.js"; - -const context = { sessionId: "session-1" }; - -// Frames captured from `hoplite acp` 3.0.0. -function runPhase(phase: string | null, label: string | null) { - return { - method: "_hoplite/run_phase", - params: { - sessionId: "thr_c75d39f7d7624256af918eeee2f4941b", - runId: "run_eb213b97bd3147afb2927fbc27f87180", - phase, - label, - startedAt: phase === null ? null : "2026-10-04T17:51:46.311Z", - }, - }; -} - -describe("runPhaseTransformer", () => { - it("shows a provisioning phase as one running workspace item per run", () => { - const update = runPhaseTransformer.notification?.( - runPhase("fetching_repository", "Cloning repository"), - context, - ); - - expect(update).toEqual({ - type: "timeline", - item: { - type: "tool_call", - id: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", - callId: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", - name: "Hoplite workspace", - detail: { type: "plain_text", label: "Cloning repository", icon: "wrench" }, - status: "running", - error: null, - }, - }); - }); - - it("completes the same item when the run leaves provisioning", () => { - const update = runPhaseTransformer.notification?.(runPhase(null, null), context); - - expect(update).toMatchObject({ - type: "timeline", - item: { - id: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", - status: "completed", - detail: { label: "Workspace ready" }, - }, - }); - }); - - it("falls back to the phase name when Hoplite sends no label", () => { - const update = runPhaseTransformer.notification?.( - runPhase("configuring_workspace", null), - context, - ); - - expect(update).toMatchObject({ item: { detail: { label: "configuring_workspace" } } }); - }); - - it("ignores other vendor notifications and malformed phases", () => { - expect( - runPhaseTransformer.notification?.({ method: "_hoplite/other", params: {} }, context), - ).toBeNull(); - expect( - runPhaseTransformer.notification?.( - { method: "_hoplite/run_phase", params: { phase: "x" } }, - context, - ), - ).toBeNull(); - }); -}); diff --git a/plugins/hoplite-provider/server/run-phase.ts b/plugins/hoplite-provider/server/run-phase.ts deleted file mode 100644 index 767ee0447d..0000000000 --- a/plugins/hoplite-provider/server/run-phase.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AcpTransformer } from "@getpaseo/plugin/server/acp"; -import { z } from "zod"; - -// Hoplite provisions a cloud workspace before the first agent output of a run and reports each -// step as `_hoplite/run_phase`. A null phase ends provisioning for that run. -const runPhaseSchema = z.object({ - runId: z.string().min(1), - phase: z.string().nullable(), - label: z.string().nullable(), -}); - -const RUN_PHASE_METHOD = "_hoplite/run_phase"; - -export const runPhaseTransformer: AcpTransformer = { - notification(notification) { - if (notification.method !== RUN_PHASE_METHOD) return null; - const params = runPhaseSchema.safeParse(notification.params); - if (!params.success) return null; - const { runId, phase, label } = params.data; - const id = `hoplite-run-phase:${runId}`; - const running = phase !== null; - return { - type: "timeline", - item: { - type: "tool_call", - id, - callId: id, - name: "Hoplite workspace", - detail: { - type: "plain_text", - label: running ? (label ?? phase) : "Workspace ready", - icon: "wrench", - }, - status: running ? "running" : "completed", - error: null, - }, - }; - }, -}; diff --git a/plugins/muse-provider/server/catalog.ts b/plugins/muse-provider/server/catalog.ts index d4e483ed79..5e603a9eda 100644 --- a/plugins/muse-provider/server/catalog.ts +++ b/plugins/muse-provider/server/catalog.ts @@ -63,10 +63,7 @@ export class Catalog { export function presentCatalog(response: z.infer<typeof catalogSchema>): ProviderCatalog { const models = response.models.map((model) => { - let supported: z.infer<typeof effortSchema>[] = efforts; - if (Array.isArray(model.reasoningEffortVariants) && model.reasoningEffortVariants.length > 0) - supported = model.reasoningEffortVariants; - else if (model.variants.length > 0) supported = model.variants; + const supported = model.variants.length > 0 ? model.variants : efforts; return { id: model.modelId, label: model.displayLabel, diff --git a/plugins/muse-provider/server/connection.ts b/plugins/muse-provider/server/connection.ts index 100bd101c7..3fca09ef5e 100644 --- a/plugins/muse-provider/server/connection.ts +++ b/plugins/muse-provider/server/connection.ts @@ -1,5 +1,5 @@ import type { ProviderLaunch } from "@getpaseo/plugin/server/provider"; -import { spawn } from "node:child_process"; +import { spawnProcess, terminateProcess } from "@getpaseo/plugin/server"; import { randomBytes } from "node:crypto"; import { createInterface } from "node:readline"; import { setTimeout as delay } from "node:timers/promises"; @@ -35,6 +35,7 @@ export class MspConnection { private stderr = ""; private failure: MuseError | null = null; private closing = false; + private closeResult: Promise<void> | null = null; private readonly exited: Promise<void>; constructor( @@ -42,15 +43,20 @@ export class MspConnection { launch: ProviderLaunch; cwd?: string; timeoutMs?: number; + startupTimeoutMs?: number; serveArgs?: string[]; }, ) { const { launch, cwd } = options; - this.child = spawn(launch.command, [...launch.args, "serve", ...(options.serveArgs ?? [])], { - env: launch.env, - cwd, - stdio: "pipe", - }); + this.child = spawnProcess( + launch.command, + [...launch.args, "serve", ...(options.serveArgs ?? [])], + { + env: launch.env, + cwd, + stdio: "pipe", + }, + ); this.child.stderr.setEncoding("utf8"); this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-8192); @@ -62,7 +68,7 @@ export class MspConnection { } catch (error) { const message = error instanceof Error ? error.message : String(error); this.fail(new MuseError("invalidFrame", `Invalid MSP frame: ${message}`)); - this.child.kill(); + void this.close().catch(() => {}); } }); this.child.stdin.on("error", () => { @@ -89,6 +95,7 @@ export class MspConnection { }, }, initializedSchema, + this.options.startupTimeoutMs ?? 30000, ); if (response.schema.fingerprint !== fingerprint) { process.stderr.write( @@ -123,7 +130,12 @@ export class MspConnection { } } } - async request<T>(method: string, params: object, schema: z.ZodType<T>): Promise<T> { + async request<T>( + method: string, + params: object, + schema: z.ZodType<T>, + timeoutMs = this.options.timeoutMs ?? 10000, + ): Promise<T> { if (this.failure) throw this.failure; if (this.closing) throw new MuseError("closed", "Muse host is closing"); const id = ++this.sequence; @@ -131,22 +143,31 @@ export class MspConnection { const timer = setTimeout(() => { this.pending.delete(id); reject(new MuseError("timeout", `Muse ${method} timed out`)); - }, this.options.timeoutMs ?? 10000); + }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); this.write({ jsonrpc: "2.0", id, method, params }); }); return schema.parse(response); } - async close(): Promise<void> { - if (this.closing) return this.exited; + close(): Promise<void> { + if (this.closeResult) return this.closeResult; this.closing = true; + this.closeResult = this.finishClose(); + return this.closeResult; + } + private async finishClose(): Promise<void> { this.child.stdin.end(); const drained = await Promise.race([ this.exited.then(() => true), - delay(1000).then(() => false), + delay(1000, undefined, { ref: false }).then(() => false), ]); - if (!drained) this.child.kill("SIGKILL"); - await this.exited; + if (drained) return; + await terminateProcess(this.child); + const stopped = await Promise.race([ + this.exited.then(() => true), + delay(5000, undefined, { ref: false }).then(() => false), + ]); + if (!stopped) throw new MuseError("timeout", "Muse host did not close after termination"); } private write(frame: object): void { this.child.stdin.write(JSON.stringify(frame) + "\n"); @@ -170,6 +191,12 @@ export class MspConnection { return; } if (frame.id === undefined) throw new MuseError("invalidFrame", "MSP response has no id"); + if (frame.id === null) { + if (!frame.error) throw new MuseError("invalidFrame", "MSP result has a null id"); + this.fail(new MuseError(frame.error.data?.kind ?? "rpc", frame.error.message)); + void this.close().catch(() => {}); + return; + } const pending = this.pending.get(frame.id); if (!pending) return; clearTimeout(pending.timer); diff --git a/plugins/muse-provider/server/provider.ts b/plugins/muse-provider/server/provider.ts index bef6693f91..8d9a3ddfea 100644 --- a/plugins/muse-provider/server/provider.ts +++ b/plugins/muse-provider/server/provider.ts @@ -10,7 +10,7 @@ import { } from "@getpaseo/plugin/server/provider"; import { serveArgs } from "./options.js"; import { Usage } from "./usage.js"; -import { execFile } from "node:child_process"; +import { execCommand } from "@getpaseo/plugin/server"; import { Catalog, launchKey } from "./catalog.js"; import { MspConnection } from "./connection.js"; import { MuseError, actionableError } from "./errors.js"; @@ -63,24 +63,23 @@ function requireLaunch(launch: ProviderLaunch | undefined): ProviderLaunch { async function status(launch: ProviderLaunch): Promise<ProviderStatus> { let host: MspConnection | undefined; try { - const versionText = await new Promise<string>((resolve, reject) => { - execFile( - launch.command, - [...launch.args, "--version"], - { env: launch.env, timeout: 3000, maxBuffer: 8192 }, - (error, stdout) => { - if (error) reject(new MuseError("version", error.message)); - else resolve(stdout); - }, - ); - }); + const { stdout: versionText } = await execCommand( + launch.command, + [...launch.args, "--version"], + { + env: launch.env, + timeout: 3000, + maxBuffer: 8192, + }, + ); const match = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(versionText); if (!match) return { available: false, diagnostic: "Muse returned an unrecognized version." }; const major = Number(match[1]); const minor = Number(match[2]); if (major < 1 || (major === 1 && minor < 3)) return { available: false, diagnostic: `Update Muse Code: found ${match[0]}, need ≥1.3.0` }; - host = new MspConnection({ launch, timeoutMs: 3000 }); + // Leave room for the version probe, account read and cleanup within the status RPC deadline. + host = new MspConnection({ launch, timeoutMs: 3000, startupTimeoutMs: 20000 }); await host.initialize(); const account = await host.request("account/read", {}, accountSchema); if (account.state === "loggedOut") diff --git a/plugins/muse-provider/server/session.ts b/plugins/muse-provider/server/session.ts index b152361060..31066c5b04 100644 --- a/plugins/muse-provider/server/session.ts +++ b/plugins/muse-provider/server/session.ts @@ -66,8 +66,7 @@ interface Live { export class Session { private readonly host: MspConnection; private liveState: Live | undefined; - private providerId = "meta"; - private cursor: string | undefined; + private providerId: string | undefined; private config: ProviderSessionConfig; private catalog: ProviderCatalog = { models: [], modes }; private readonly approvals = new Map<string, WireApproval>(); @@ -110,9 +109,11 @@ export class Session { if (input.persistence.version !== 1) throw new MuseError("invalidPersistence", "Unsupported Muse persistence version"); const saved = persistenceSchema.parse(input.persistence.data); + // A fresh timeline needs history. MSP cursor resumes return only the suffix, + // so even legacy persistence cursors cannot restore this session's projection. response = await this.host.command( "session/resume", - { sessionId: saved.sessionId, cursor: saved.cursor, config: { mcpServers } }, + { sessionId: saved.sessionId, config: { mcpServers } }, sessionSchema, ); this.config = { @@ -167,11 +168,10 @@ export class Session { }, ), }; - this.providerId = response.session.providerId; - this.cursor = response.viewCursor; + this.providerId = response.session.providerId ?? undefined; this.config = { ...this.config, - model: response.session.modelId, + model: response.session.modelId ?? this.config.model, mode: response.session.approvalMode?.mode ?? this.config.mode ?? "onRequest", }; this.options.emit({ @@ -194,14 +194,7 @@ export class Session { for (const notification of this.buffered) this.enqueue(notification); this.buffered.length = 0; await this.notifications; - this.catalog = presentCatalog( - await this.host.request("model/list", { sessionId: this.live().nativeId }, catalogSchema), - ); - const selected = this.catalog.models.find((model) => model.id === this.config.model); - this.config = { - ...this.config, - thinkingOption: this.config.thinkingOption ?? selected?.defaultThinkingOptionId, - }; + await this.readCatalog(); await this.live().commands.refresh(); this.publishPersistence(); this.publishConfig(); @@ -229,6 +222,19 @@ export class Session { for (const approval of pending.approvals) await this.permission(approval); for (const question of pending.userInputs) this.live().questions.requested(question); } + private async readCatalog(): Promise<void> { + this.catalog = presentCatalog( + await this.host.request("model/list", { sessionId: this.live().nativeId }, catalogSchema), + ); + const model = this.config.model ?? this.catalog.defaultModel; + const selected = this.catalog.models.find((entry) => entry.id === model); + this.config = { + ...this.config, + model, + thinkingOption: this.config.thinkingOption ?? selected?.defaultThinkingOptionId, + }; + } + async prompt(prompt: ProviderPrompt): Promise<void> { if (await this.live().commands.compact(prompt)) return; const id = commandId(); @@ -405,10 +411,6 @@ export class Session { } await this.processNotification(notification); this.live().recovery.activity(envelope.viewCursor, this.activeTurnIds().length > 0); - if (envelope.viewCursor) { - this.cursor = envelope.viewCursor; - this.publishPersistence(); - } } private async processNotification(notification: Notification): Promise<void> { switch (notification.method) { @@ -591,7 +593,6 @@ export class Session { version: 1, data: { sessionId: this.live().nativeId, - ...(this.cursor ? { cursor: this.cursor } : {}), ...(this.config.model ? { model: this.config.model } : {}), ...(this.config.thinkingOption ? { thinkingOption: this.config.thinkingOption } : {}), }, diff --git a/plugins/muse-provider/server/sessions.ts b/plugins/muse-provider/server/sessions.ts index 93a22cd54b..a10879e248 100644 --- a/plugins/muse-provider/server/sessions.ts +++ b/plugins/muse-provider/server/sessions.ts @@ -38,7 +38,10 @@ export class Sessions { updatedAt: session.updatedAt, persistence: { version: 1, - data: { sessionId: session.sessionId, model: session.modelId }, + data: { + sessionId: session.sessionId, + ...(session.modelId === null ? {} : { model: session.modelId }), + }, }, }); if (input.limit && summaries.length >= input.limit) return summaries; diff --git a/plugins/muse-provider/server/usage.ts b/plugins/muse-provider/server/usage.ts index a8c77428d6..e3915c6ddd 100644 --- a/plugins/muse-provider/server/usage.ts +++ b/plugins/muse-provider/server/usage.ts @@ -16,6 +16,13 @@ import { usageSchema } from "./wire.js"; const inputSchema = z.object({ account: z.string() }).strict(); type Observation = z.infer<typeof usageSchema>; + +function accountKey(env: ProviderLaunch["env"]): string { + // MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID. + const configHome = env.XDG_CONFIG_HOME ?? path.join(env.HOME ?? homedir(), ".config"); + return hashAccountKey(path.resolve(configHome, "muse")); +} + export class Usage { private readonly launches = new Map<string, ProviderLaunch>(); private readonly sessions = new Map< @@ -23,10 +30,7 @@ export class Usage { { account: string; read: () => Promise<Observation> } >(); remember(launch: ProviderLaunch): { account: string } { - // MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID. - const configHome = - launch.env.XDG_CONFIG_HOME ?? path.join(launch.env.HOME ?? homedir(), ".config"); - const key = hashAccountKey(path.resolve(configHome, "muse")); + const key = accountKey(launch.env); this.launches.set(key, launch); return { account: key }; } @@ -60,12 +64,21 @@ export class Usage { label: "Muse Code", icon: "icon.svg", input: inputSchema, - discover: async () => - [...this.launches.keys()].map((account) => ({ + discover: async (scope) => { + let accounts: string[]; + if (scope.kind === "global") { + accounts = [...this.launches.keys()]; + } else { + if (scope.provider !== "muse") return []; + const account = accountKey(scope.env); + accounts = this.launches.has(account) ? [account] : []; + } + return accounts.map((account) => ({ key: account, label: "Muse Code", input: { account }, - })), + })); + }, fetch: async (input) => { const { account } = inputSchema.parse(input); const launch = this.launches.get(account); diff --git a/plugins/muse-provider/server/wire.ts b/plugins/muse-provider/server/wire.ts index 4955acdaa5..45aac6fe4f 100644 --- a/plugins/muse-provider/server/wire.ts +++ b/plugins/muse-provider/server/wire.ts @@ -87,7 +87,7 @@ export const notificationSchema = z.object({ }); export const frameSchema = z.object({ jsonrpc: z.literal("2.0"), - id: z.union([z.string(), z.number()]).optional(), + id: z.union([z.string(), z.number()]).nullable().optional(), method: z.string().optional(), params: z.unknown().optional(), result: z.unknown().optional(), @@ -115,16 +115,20 @@ export const catalogSchema = z.object({ contextLimit: z.number().nullable(), isDefault: z.boolean(), defaultReasoningEffort: effortSchema.nullable().optional(), - reasoningEffortVariants: z.union([z.array(effortSchema), z.literal("unknown")]).default([]), - variants: z.array(effortSchema).default([]), + // Validate consumed fields only. Muse's described subset (reasoningEffortVariants) + // is presentation metadata; its shape must not gate catalog discovery or session startup. + variants: z + .union([z.array(effortSchema), z.literal("unknown")]) + .transform((variants) => (variants === "unknown" ? [] : variants)) + .default([]), }), ), }); export const sessionSchema = z.object({ session: z.object({ sessionId: z.string(), - modelId: z.string(), - providerId: z.string(), + modelId: z.string().nullable(), + providerId: z.string().nullable(), approvalMode: z.object({ mode: approvalModeSchema }).optional(), }), viewCursor: z.string(), @@ -139,11 +143,14 @@ export const sessionSchema = z.object({ }); export const persistenceSchema = z.object({ sessionId: z.string(), - cursor: z.string().optional(), model: z.string().optional(), thinkingOption: effortSchema.optional(), }); -export const deltaSchema = z.object({ itemId: z.string(), field: z.string(), delta: z.string() }); +export const deltaSchema = z.object({ + itemId: z.string(), + field: z.string().default("text"), + delta: z.string(), +}); export const turnSchema = z.object({ turnId: z.string(), commandId: z.string().optional(), @@ -241,7 +248,7 @@ export const sessionListSchema = z.object({ workspaceRoot: z.string().nullable(), title: z.string().optional(), updatedAt: z.string(), - modelId: z.string(), + modelId: z.string().nullable(), }), ), }); diff --git a/plugins/muse-provider/test/fake-muse.mjs b/plugins/muse-provider/test/fake-muse.mjs index fffca0cc89..16cc821b79 100644 --- a/plugins/muse-provider/test/fake-muse.mjs +++ b/plugins/muse-provider/test/fake-muse.mjs @@ -6,9 +6,20 @@ import { fileURLToPath } from "node:url"; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const scenario = process.env.MUSE_TEST_SCENARIO || "text-reasoning"; if (process.argv.includes("--version")) { + if (process.env.MUSE_TEST_VERSION_ERROR) { + process.stderr.write(process.env.MUSE_TEST_VERSION_ERROR + "\n"); + process.exit(17); + } process.stdout.write(`Muse Code ${process.env.MUSE_TEST_VERSION || "1.4.1"}\n`); process.exit(0); } +if (process.env.MUSE_TEST_STUBBORN) { + setInterval(() => {}, 1000); + appendFileSync( + process.env.MUSE_TEST_REQUESTS, + JSON.stringify({ event: "stubbornHost", pid: process.pid }) + "\n", + ); +} const rows = readFixture(scenario); if (process.env.MUSE_TEST_REQUESTS) appendFileSync( @@ -22,6 +33,14 @@ const receipts = new Set(); const lines = createInterface({ input: process.stdin }); lines.on("line", receive); function receive(line) { + const parsed = JSON.parse(line); + if (parsed.method === "initialize" && process.env.MUSE_TEST_INITIALIZE_DELAY_MS) { + setTimeout(() => receiveFrame(line), Number(process.env.MUSE_TEST_INITIALIZE_DELAY_MS)); + return; + } + receiveFrame(line); +} +function receiveFrame(line) { const frame = JSON.parse(line); if (process.env.MUSE_TEST_REQUESTS) appendFileSync(process.env.MUSE_TEST_REQUESTS, JSON.stringify(frame) + "\n"); @@ -61,11 +80,35 @@ function receive(line) { } else for (const row of messages) emitFixtureMessage(row.msg, recorded, frame); } function controlResponse(frame) { + if (frame.method === "session/resume" && process.env.MUSE_TEST_HISTORY) { + const result = responseFor(readFixture("resume-without-cursor"), "session/resume"); + result.session.sessionId = frame.params.sessionId; + result.history = frame.params.cursor + ? { mode: "none", noneReason: "cursorSuffix", items: null, snapshot: null } + : { + mode: "inline", + items: readFileSync(process.env.MUSE_TEST_HISTORY, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + snapshot: null, + }; + respond(frame, result); + return true; + } if (frame.method === "initialized") return true; if (frame.method === "initialize" && process.env.MUSE_TEST_EXIT) { process.stderr.write("diagnostic tail, not protocol\n"); process.exit(Number(process.env.MUSE_TEST_EXIT)); } + if (frame.method === "initialize" && process.env.MUSE_TEST_NULL_ERROR_ID) { + send({ + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "Cannot recover request id", data: { kind: "parseError" } }, + }); + return true; + } if (process.env.MUSE_TEST_HANG === frame.method) return true; if (frame.method === "session/setReasoningEffort") { respond(frame, { status: "accepted", commandId: frame.params.commandId }); @@ -138,6 +181,9 @@ function parityResponse(frame) { if (frame.method === "session/list") { const result = responseFor(catalogRows, "session/list"); result.nextCursor = null; + if (process.env.MUSE_TEST_NULL_MODEL) { + for (const session of result.sessions) session.modelId = null; + } respond(frame, result); return true; } @@ -262,6 +308,7 @@ function emitFixtureMessage(recordedMessage, recorded, frame) { return; if (frame.method === "session/start" && message.result?.session?.approvalMode) message.result.session.approvalMode.mode = frame.params.approvalMode; + applyWireVariants(message); if (message.result?.viewCursor && process.env.MUSE_TEST_EMPTY_CURSOR) message.result.viewCursor = ""; applyTestVariants(message); @@ -282,6 +329,19 @@ function emitFixtureMessage(recordedMessage, recorded, frame) { }); } } +function applyWireVariants(message) { + if ( + message.method === "item/delta" && + message.params?.field === "text" && + process.env.MUSE_TEST_IMPLICIT_TEXT_DELTA + ) { + delete message.params.field; + } + if (message.result?.session && process.env.MUSE_TEST_NULL_MODEL) { + message.result.session.modelId = null; + message.result.session.providerId = null; + } +} function parityDelivery(message) { if (process.env.MUSE_TEST_WORKFLOW && message.method === "turn/completed") send({ @@ -394,6 +454,9 @@ function replace(value) { return value; } function send(frame) { + if (frame.method === "item/completed" && process.env.MUSE_TEST_HISTORY) { + appendFileSync(process.env.MUSE_TEST_HISTORY, JSON.stringify(frame.params.item) + "\n"); + } process.stdout.write(JSON.stringify(frame) + "\n"); } function respond(frame, result) { diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index ffb7c0468e..4868634d70 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -1,3 +1,6 @@ +import { execFile } from "node:child_process"; +import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js"; +import { Usage as MuseUsage } from "../server/usage.js"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; import os from "node:os"; @@ -14,6 +17,7 @@ import { type ProviderInput, } from "@getpaseo/plugin/server/provider"; import type { UsageSourceRegistration } from "@getpaseo/plugin/server"; +import { execCommand } from "@getpaseo/plugin/server"; import contribute from "../index.server.js"; const connections: ProviderConnection[] = []; @@ -69,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); @@ -84,6 +89,7 @@ async function harness( async function open( persistence?: Extract<ProviderInput, { type: "session.open" }>["persistence"], mode = "onRequest", + model: string | null = "meta/muse-spark-1.3", ) { await send({ type: "session.open", @@ -97,7 +103,7 @@ async function harness( mcpServers: {}, persist: true, mode, - model: "meta/muse-spark-1.3", + model: model ?? undefined, thinkingOption: "high", systemPrompt: "Test instructions", }, @@ -218,9 +224,9 @@ test("text and reasoning stream, completion overwrites deltas, user echo retains { type: "text", text: "hello" }, ], }); - expect(h.events.filter((event) => event.type === "session.persistence").length).toBeGreaterThan( - 1, - ); + expect(h.events.findLast((event) => event.type === "session.persistence")).toMatchObject({ + persistence: { version: 1, data: { model: "meta/muse-spark-1.3", thinkingOption: "high" } }, + }); }); test("tool calls classify read, shell, and edit with fetched JSON patch converted to unified diff", async () => { const h = await harness("tools-edit"); @@ -324,23 +330,69 @@ test("interrupt awaits cancelled terminal and reconciles the tool", async () => ), ).toBeGreaterThan(terminalIndex); }); +test("a fresh connection restores earlier messages from persisted session state", async () => { + const original = await harness(); + const historyPath = path.join(original.root, "native-history.ndjson"); + original.launch.env.MUSE_TEST_HISTORY = historyPath; + await original.open(); + await original.prompt(); + await original.wait((event) => event.type === "session.turn" && event.state === "completed"); + const messages = [ + ...new Map( + original.events + .filter((event) => event.type === "timeline.item") + .map((event) => event.item) + .filter((item) => item.type === "user_message" || item.type === "assistant_message") + .map((item) => [item.id, item] as const), + ).values(), + ]; + expect(messages.map((item) => item.type)).toEqual(["user_message", "assistant_message"]); + const saved = original.events.findLast((event) => event.type === "session.persistence"); + if (!saved) throw new Error("Expected session persistence"); + const persistencePath = path.join(original.root, "persistence.json"); + // Older daemon records carry a view cursor even though their timeline is not durable. + await writeFile( + persistencePath, + JSON.stringify({ + ...saved.persistence, + data: { ...saved.persistence.data, cursor: saved.persistence.data.cursor ?? "v:old:7" }, + }), + ); + await original.connection.close(); + const reopened = await harness("text-reasoning", { MUSE_TEST_HISTORY: historyPath }); + expect(await reopened.open(JSON.parse(await readFile(persistencePath, "utf8")))).toMatchObject({ + type: "session.ready", + }); + const restored = reopened.events + .filter((event) => event.type === "timeline.item") + .map((event) => event.item) + .filter((item) => item.type === "user_message" || item.type === "assistant_message"); + expect(restored.map((item) => ({ type: item.type, text: item.text }))).toEqual( + messages.map((item) => ({ type: item.type, text: item.text })), + ); + const reopenedState = reopened.events.findLast((event) => event.type === "session.persistence"); + expect(reopenedState?.persistence.data).not.toHaveProperty("cursor"); + expect(reopened.events.filter((event) => event.type === "session.turn")).toEqual([]); + await reopened.prompt(); + await reopened.wait((event) => event.type === "session.turn" && event.state === "completed"); +}); + for (const cursor of [undefined, "v:old:7"]) { - test(`resume ${cursor ? "with cursor emits suffix and ignores old terminal" : "without cursor replays inline history"}`, async () => { - const h = await harness(cursor ? "resume-with-cursor" : "resume-without-cursor"); + test(`resume ${cursor ? "with a legacy persistence cursor" : "without a persistence cursor"} replays inline history`, async () => { + const h = await harness("resume-without-cursor"); await h.open({ version: 1, data: { sessionId: "saved-session", ...(cursor ? { cursor } : {}) }, }); expect(h.events.filter((event) => event.type === "session.turn")).toEqual([]); const history = h.events.filter((event) => event.type === "timeline.item"); - if (!cursor) - expect(history).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - item: expect.objectContaining({ type: "assistant_message", text: "RESUME_MARKER_OK" }), - }), - ]), - ); + expect(history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + item: expect.objectContaining({ type: "assistant_message", text: "RESUME_MARKER_OK" }), + }), + ]), + ); await h.prompt(); await h.wait((event) => event.type === "session.turn" && event.state === "completed"); expect( @@ -348,7 +400,10 @@ for (const cursor of [undefined, "v:old:7"]) { ).toHaveLength(1); expect( (await h.recorded()).find((frame) => frame.method === "session/resume").params, - ).toMatchObject({ sessionId: "saved-session", ...(cursor ? { cursor } : {}) }); + ).toMatchObject({ sessionId: "saved-session" }); + expect( + (await h.recorded()).find((frame) => frame.method === "session/resume").params, + ).not.toHaveProperty("cursor"); }); } test("steer sends ifBusy steer and joins the running turn", async () => { @@ -459,6 +514,25 @@ for (const [env, expected] of [ expect(await h.provider.status!({ launch: h.launch })).toEqual(expected); }); } +test("status preserves the version-probe command and stderr diagnostic", async () => { + const h = await harness("catalog-controls", { + MUSE_TEST_VERSION_ERROR: "Muse version probe could not read its installation metadata", + }); + // Compare against the original execFile callback: wrapping in MuseError only changed its kind. + const previousDiagnostic = await new Promise<string>((resolve, reject) => { + execFile(h.launch.command, [...h.launch.args, "--version"], { env: h.launch.env }, (error) => { + if (error) resolve(error.message); + else reject(new Error("Expected the version probe to fail")); + }); + }); + const status = await h.provider.status!({ launch: h.launch }); + expect(status).toEqual({ available: false, diagnostic: previousDiagnostic }); + expect(status.diagnostic).toContain("--version"); + expect(status.diagnostic).toContain( + "Muse version probe could not read its installation metadata", + ); + expect(await h.recorded()).toEqual([]); +}); test("status reports a missing executable", async () => { const h = await harness(); expect( @@ -610,12 +684,15 @@ test("ordinary subagent tool calls render sub_agent details without inventing ch }), ); }); -for (const [variants, reasoningEffortVariants, expected] of [ - [["low", "high"], ["medium"], ["medium"]], - [["low", "high"], "unknown", ["low", "high"]], - [[], [], ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], +for (const [variants, expected] of [ + [ + ["low", "high"], + ["low", "high"], + ], + ["unknown", ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + [[], ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], ] as const) { - test(`catalogue effort discovery ${JSON.stringify(reasoningEffortVariants)} with variants ${JSON.stringify(variants)}`, async () => { + test(`catalogue effort discovery with variants ${JSON.stringify(variants)}`, async () => { const model = { modelId: "test-model", providerId: "meta", @@ -623,7 +700,6 @@ for (const [variants, reasoningEffortVariants, expected] of [ contextLimit: 123456, isDefault: true, variants, - reasoningEffortVariants, defaultReasoningEffort: "high", }; const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify([model]) }); @@ -639,6 +715,153 @@ for (const [variants, reasoningEffortVariants, expected] of [ }); }); } +test("catalogue loads models whose effort tiers carry descriptions", async () => { + const deepest = "Use this for deepest analysis and complex fixes."; + const row = (modelId: string, isDefault: boolean) => ({ + modelId, + providerId: "meta", + displayLabel: modelId, + contextLimit: 1000000, + isDefault, + defaultReasoningEffort: "max", + variants: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffortVariants: isDefault ? [] : [{ tier: "xhigh", description: deepest }], + }); + const models = [ + row("muse-spark-1.3", true), + row("muse-spark-1.3-contributor", false), + row("muse-spark-1.2", false), + row("muse-spark-1.2-contributor", false), + ]; + const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify(models) }); + await h.send({ type: "catalog", requestId: "models" }); + const result = await h.wait( + (event) => event.type === "catalog" || event.type === "request.failed", + ); + const offered = [ + { id: "minimal" }, + { id: "low" }, + { id: "medium" }, + { id: "high" }, + { id: "xhigh" }, + { id: "max", isDefault: true }, + ]; + expect(result).toMatchObject({ + type: "catalog", + catalog: { + defaultModel: "muse-spark-1.3", + models: [ + { id: "muse-spark-1.3", defaultThinkingOptionId: "max" }, + { id: "muse-spark-1.3-contributor", thinkingOptions: offered }, + { id: "muse-spark-1.2", thinkingOptions: offered }, + { id: "muse-spark-1.2-contributor", thinkingOptions: offered }, + ], + }, + }); +}); +for (const [configuredModel, catalogModels, expectedModel] of [ + ["configured-model", [], "configured-model"], + [ + "", + [ + { + modelId: "catalog-model", + providerId: "meta", + displayLabel: "Catalog model", + contextLimit: null, + isDefault: true, + variants: ["high"], + }, + ], + "catalog-model", + ], + ["", [], undefined], +] as const) { + test(`null session model preserves ${expectedModel ?? "unset model"}`, async () => { + const h = await harness("text-reasoning", { + MUSE_TEST_NULL_MODEL: "1", + MUSE_TEST_MODELS: JSON.stringify(catalogModels), + }); + expect(await h.open(undefined, "onRequest", configuredModel || null)).toEqual({ + type: "session.ready", + requestId: "open", + sessionId: "paseo-session", + }); + const config = await h.wait((event) => event.type === "session.config"); + if (config.type !== "session.config") throw new Error("Expected session config"); + expect(config.config.model).toBe(expectedModel); + const saved = h.events.findLast((event) => event.type === "session.persistence"); + if (!saved) throw new Error("Expected persistence"); + expect(saved.persistence.data.model).toBe(expectedModel); + await h.prompt(); + await h.wait((event) => event.type === "session.turn" && event.state === "completed"); + }); +} +test("session startup accepts described catalog efforts and publishes the complete list", async () => { + const model = { + modelId: "meta/muse-spark-1.3", + providerId: "meta", + displayLabel: "Muse Spark", + contextLimit: null, + isDefault: true, + defaultReasoningEffort: "max", + variants: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffortVariants: [{ tier: "xhigh", description: "Deepest analysis" }], + }; + const h = await harness("text-reasoning", { MUSE_TEST_MODELS: JSON.stringify([model]) }); + expect(await h.open()).toEqual({ + type: "session.ready", + requestId: "open", + sessionId: "paseo-session", + }); + expect(await h.wait((event) => event.type === "session.config")).toMatchObject({ + config: { + models: [{ id: model.modelId, defaultThinkingOptionId: "max" }], + thinkingOptions: model.variants.map((id) => ({ id, isDefault: id === "max" })), + }, + }); + await h.send({ + type: "session.configure", + requestId: "choose-max", + sessionId: "paseo-session", + changes: { thinkingOption: "max" }, + }); + await h.wait((event) => event.type === "request.completed" && event.requestId === "choose-max"); + await h.prompt(); + await h.wait((event) => event.type === "session.turn" && event.state === "completed"); + expect( + (await h.recorded()).find((frame) => frame.method === "turn/start").params.reasoningEffort, + ).toBe("max"); +}); +for (const metadata of [ + "unknown", + [{ tier: "future-tier", description: { text: "Future metadata" } }], +]) { + test(`catalog ignores unused effort descriptions ${JSON.stringify(metadata)}`, async () => { + const model = { + modelId: "test-model", + providerId: "meta", + displayLabel: "Muse Spark", + contextLimit: null, + isDefault: true, + defaultReasoningEffort: "high", + variants: ["low", "high"], + reasoningEffortVariants: metadata, + }; + const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify([model]) }); + await h.send({ type: "catalog", requestId: "models" }); + expect( + await h.wait((event) => event.type === "catalog" || event.type === "request.failed"), + ).toMatchObject({ + type: "catalog", + catalog: { + models: [ + { id: model.modelId, thinkingOptions: [{ id: "low" }, { id: "high", isDefault: true }] }, + ], + }, + }); + }); +} test("status accepts future credential states and drains its maintenance host", async () => { const h = await harness("catalog-controls", { MUSE_TEST_ACCOUNT: "futureCredential" }); expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); @@ -691,7 +914,7 @@ test("fingerprint mismatch is logged but does not prevent opening", async () => }); for (const restoring of [false, true]) { test(`MCP servers map to MSP stdio and streamableHttp on ${restoring ? "resume" : "start"}`, async () => { - const h = await harness(restoring ? "resume-with-cursor" : "text-reasoning"); + const h = await harness(restoring ? "resume-without-cursor" : "text-reasoning"); await h.send({ type: "session.open", sessionId: "paseo-session", @@ -997,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(); @@ -1073,6 +1308,46 @@ test("resuming a session reapplies its provider options to the new host", async ]); expect(frames.some((f) => f.method === "session/resume")).toBe(true); }); +test("an uncorrelated MSP parse error keeps the provider diagnostic", async () => { + const h = await harness("text-reasoning", { MUSE_TEST_NULL_ERROR_ID: "1" }); + expect(await h.open()).toMatchObject({ + type: "request.failed", + error: { code: "parseError", message: "Cannot recover request id" }, + }); +}); +test("text deltas with an omitted field stream as text", async () => { + const h = await harness("text-reasoning", { MUSE_TEST_IMPLICIT_TEXT_DELTA: "1" }); + await h.open(); + await h.wait((e) => e.type === "session.ready"); + await h.prompt(); + await h.wait((e) => e.type === "session.turn" && e.state === "completed"); + expect( + h.events.some( + (e) => + e.type === "timeline.item" && + e.item.type === "assistant_message" && + e.item.text === "Sum with", + ), + ).toBe(true); +}); +test("sessions with a null model import and resume without a null persistence model", async () => { + const h = await harness("resume-without-cursor", { MUSE_TEST_NULL_MODEL: "1" }); + await h.send({ + type: "sessions", + requestId: "null-list", + cwd: "/tmp/muse-phase0/repo", + limit: 2, + }); + const listed = await h.wait((e) => e.type === "sessions"); + if (listed.type !== "sessions") throw new Error("Expected sessions"); + expect(listed.sessions).toHaveLength(2); + expect(listed.sessions[0]!.persistence.data.model).toBeUndefined(); + await h.open(listed.sessions[0]!.persistence); + await h.wait((e) => e.type === "session.ready"); + expect( + h.events.some((e) => e.type === "session.config" && e.config.model === "meta/muse-spark-1.3"), + ).toBe(true); +}); test("sessions list filters workspace and imported persistence opens via resume", async () => { const h = await harness("resume-without-cursor"); await h.send({ @@ -1155,7 +1430,7 @@ for (const usage of [ test(`usage source presents ${"usage" in usage ? "subscription windows" : "real route absence"} from discovered accounts`, async () => { const h = await harness("catalog-controls", { MUSE_TEST_USAGE: JSON.stringify(usage) }); await h.open(); - const inputs = await h.usageSource.discover(); + const inputs = await h.usageSource.discover({ kind: "global" }); expect(inputs).toHaveLength(1); const account = inputs[0]!; expect(account).toEqual({ @@ -1306,7 +1581,7 @@ test("usage identity follows the resolved config directory across credential and META_BASE_URL: route!, }); await h.open(); - const inputs = await h.usageSource.discover(); + const inputs = await h.usageSource.discover({ kind: "global" }); expect(inputs).toHaveLength(1); identities.push(inputs[0]!.key); } @@ -1316,7 +1591,7 @@ test("usage identity follows the resolved config directory across credential and test.each(["no sessions", "unrelated environment"])("Muse discovery is empty with %s", async () => { const { Usage } = await import("../server/usage.js"); - expect(await new Usage().registration().discover()).toEqual([]); + expect(await new Usage().registration().discover({ kind: "global" })).toEqual([]); }); test("Muse window identity follows the reported duration instead of assuming five hours", async () => { @@ -1331,7 +1606,7 @@ test("Muse window identity follows the reported duration instead of assuming fiv }), }); await h.open(); - const [account] = await h.usageSource.discover(); + const [account] = await h.usageSource.discover({ kind: "global" }); expect(await h.usageSource.fetch(account!.input)).toMatchObject({ status: "available", windows: [ @@ -1340,3 +1615,131 @@ test("Muse window identity follows the reported duration instead of assuming fiv ], }); }); + +test("status accepts initialization slower than the account deadline without isolating history", async () => { + const h = await harness("catalog-controls", { MUSE_TEST_INITIALIZE_DELAY_MS: "3500" }); + expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); + expect(await h.recorded()).toContainEqual({ event: "hostClosed" }); +}, 10000); + +for (const extension of ["cmd", "bat"]) { + test.runIf(process.platform === "win32")( + `launches the discovered Muse .${extension} for status, catalog and sessions`, + async () => { + const h = await harness("catalog-controls"); + const shim = path.join(h.root, `muse launcher.${extension}`); + await writeFile(shim, `@echo off\r\n"${process.execPath}" "${h.launch.args[0]}" %*\r\n`); + h.launch.command = shim; + h.launch.args = []; + expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); + await h.send({ type: "catalog", requestId: "shim-catalog" }); + expect(await h.wait((event) => event.type === "catalog")).toMatchObject({ + requestId: "shim-catalog", + }); + await h.open(); + expect(await h.wait((event) => event.type === "session.opened")).toMatchObject({ + sessionId: "paseo-session", + }); + }, + 20000, + ); +} + +test("force-stops a Muse host that ignores stdin EOF", async () => { + const h = await stubbornHostHarness(); + try { + const result = await Promise.race([ + h.provider.status!({ launch: h.launch }), + delay(10000).then(() => { + throw new Error("Muse status did not finish closing its host"); + }), + ]); + expect(result).toEqual({ available: true }); + const hosts = (await h.recorded()).filter((row) => row.event === "stubbornHost"); + expect(hosts).toHaveLength(1); + expect(() => process.kill(hosts[0].pid, 0)).toThrow(); + } finally { + await cleanupStubbornHosts(h); + } +}, 15000); + +test("closes the whole Muse host after an uncorrelated MSP error", async () => { + const h = await stubbornHostHarness({ MUSE_TEST_NULL_ERROR_ID: "1" }); + try { + const result = await h.provider.status!({ launch: h.launch }); + expect(result).toEqual({ available: false, diagnostic: "Cannot recover request id" }); + const recorded = await h.recorded(); + expect(recorded.some((row) => row.event === "hostClosed")).toBe(true); + const hosts = recorded.filter((row) => row.event === "stubbornHost"); + expect(hosts).toHaveLength(1); + expect(() => process.kill(hosts[0].pid, 0)).toThrow(); + } finally { + await cleanupStubbornHosts(h); + } +}, 15000); + +async function stubbornHostHarness(env: Record<string, string> = {}) { + const h = await harness("catalog-controls", { MUSE_TEST_STUBBORN: "1", ...env }); + if (process.platform === "win32") { + const shim = path.join(h.root, "stubborn muse.cmd"); + await writeFile(shim, `@echo off\r\n"${process.execPath}" "${h.launch.args[0]}" %*\r\n`); + h.launch.command = shim; + h.launch.args = []; + } + return h; +} +async function cleanupStubbornHosts(h: Awaited<ReturnType<typeof harness>>) { + // Keep a failing Windows regression from leaving its deliberately stubborn child alive. + for (const host of (await h.recorded()).filter((row) => row.event === "stubbornHost")) { + if (process.platform === "win32") { + await execCommand("taskkill.exe", ["/PID", String(host.pid), "/T", "/F"], { + shell: false, + }).catch(() => {}); + } else { + try { + process.kill(host.pid, "SIGKILL"); + } catch {} + } + } +} + +test("Muse reports belong only to the session's account and remain available globally", async () => { + const usage = new MuseUsage(); + const homeEnv = { HOME: "/usage/home" }; + const configEnv = { HOME: "/usage/home", XDG_CONFIG_HOME: "/usage/work" }; + const accountIds = [homeEnv, configEnv].map((env, index) => { + const launch = { command: "muse", args: [], env }; + usage.attach(`session-${index}`, launch, async () => ({ + usage: { + observedAtMs: 1700000000000, + tier: "Pro", + window: { usedPercent: 11, resetsAtMs: 1700018000000, windowDurationMins: 120 }, + weekly: { usedPercent: 22, resetsAtMs: 1700604800000 }, + }, + })); + return `muse:${usage.remember(launch).account}`; + }); + const sessions = new Map([ + ["claude", { provider: "claude", env: homeEnv, sessionKey: "claude" }], + ["codex", { provider: "codex", env: configEnv, sessionKey: "codex" }], + ["muse-home", { provider: "muse", env: homeEnv, sessionKey: "muse-home" }], + ["muse-work", { provider: "muse", env: configEnv, sessionKey: "muse-work" }], + ["muse-unknown", { provider: "muse", env: { HOME: "/usage/unknown" }, sessionKey: "unknown" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register(usage.registration()); + + expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds); + expect(await registry.listReports({ agentId: "claude" })).toEqual([]); + expect(await registry.listReports({ agentId: "codex" })).toEqual([]); + for (const [index, agentId] of ["muse-home", "muse-work"].entries()) { + const reports = await registry.listReports({ agentId }); + expect(reports.map((report) => report.id)).toEqual([accountIds[index]]); + expect(reports[0]!.report.status).toBe("available"); + } + expect(await registry.listReports({ agentId: "muse-unknown" })).toEqual([]); + expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds); +}); 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 b650ffa697..4bd6ca429b 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.11.0-beta.3", + "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/configuration.md b/public-docs/configuration.md index ca578a96ac..a11c4c69ba 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -141,7 +141,7 @@ Daemon logging uses separate console and file sinks by default: - Console: `info` and above - File (`$PASEO_HOME/daemon.log`): `trace` and above -- File rotation: `10m` max file size, `2` retained files total (active + 1 rotated) +- File rotation: `10m` max file size, `3` rotated files kept alongside the active file ```json { @@ -155,7 +155,7 @@ Daemon logging uses separate console and file sinks by default: "path": "daemon.log", "rotate": { "maxSize": "10m", - "maxFiles": 2 + "maxFiles": 3 } } } diff --git a/public-docs/connectivity.md b/public-docs/connectivity.md index 3263e61510..89dd596d95 100644 --- a/public-docs/connectivity.md +++ b/public-docs/connectivity.md @@ -45,7 +45,9 @@ paseo --host 'ssh://user@host?daemonPort=7777' ls -a Put `--host` before the command. `paseo daemon status` observes the default local home; use `paseo --host ssh://user@host daemon status` to query a remote daemon. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host. -In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination. +In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination, including `:port` or `?daemonPort=` when those differ from the defaults. + +If the remote daemon has a password, enter it in **Daemon password**; it is stored with the host and sent on every connection, the same as a direct connection's password. SSH login itself stays key-based — Paseo never prompts for an SSH password. ## Paseo relay @@ -123,6 +125,7 @@ If the host was already paired through the relay, Paseo adds the direct connecti - **SSH authentication failed:** Run `ssh user@host` in a terminal and fix the key, agent, host key, or `~/.ssh/config` entry there. Paseo does not prompt for SSH passwords. - **SSH connects but Paseo is refused:** Run `paseo daemon status` on the remote host. SSH transport does not start the daemon. +- **SSH connects but Paseo reports "Password required":** The remote daemon is password-protected. Remove the SSH connection from the host and add it again, this time entering the daemon password in **Daemon password**. - **Connection timed out:** Check that Tailscale is connected on both devices and that you used the daemon machine's Tailscale IP. - **Connection refused:** Run `paseo daemon status` and confirm the daemon is running on the configured IP and port. - **Config change has no effect:** Run `paseo reload`. `daemon.listen` is a startup setting, so restart when the command reports it. diff --git a/public-docs/index.md b/public-docs/index.md index f93216883d..54bd56b7b4 100644 --- a/public-docs/index.md +++ b/public-docs/index.md @@ -8,7 +8,7 @@ category: Getting started # Getting started -Paseo runs your coding agents on your machine and gives you a mobile, desktop, web, and CLI client to drive them from anywhere. Three common ways to install. +Paseo is an agentic development environment for running coding agents, editing files, reviewing changes, and testing your work. Agents run on your machines; desktop, mobile, web, and CLI clients let you work from anywhere. Choose how to install it below. ## Desktop app (recommended) @@ -16,27 +16,6 @@ Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub rele The desktop app bundles its own daemon and starts it automatically, no separate install required. On first launch you'll see a brief startup screen, then connect from your phone using **Settings → your host → Pair Device**. -### Linux - -Use the `.deb` on Debian/Ubuntu or the `.rpm` on Fedora to keep Chromium's sandbox available even when your distribution restricts user namespaces. The installer configures the bundled sandbox helper; you do not need to change system security settings. - -For an AppImage, make the download executable and open it: - -```bash -chmod +x Paseo-x86_64.AppImage -./Paseo-x86_64.AppImage -``` - -If it reports `error loading libfuse.so.2`, run without FUSE: - -```bash -./Paseo-x86_64.AppImage --appimage-extract-and-run -``` - -Alternatively, install `libfuse2t64` on Ubuntu 24.04 or newer (`sudo apt install libfuse2t64`), or your distribution's FUSE 2 compatibility package. This dependency belongs to the AppImage runtime, before Paseo starts. - -Paseo checks sandbox availability each time it launches. AppImage and extracted tar archives retain sandboxing when user namespaces work. On a restricted host without a usable installed helper, they launch with Chromium's sandbox disabled. Prefer the installed package if you require OS process isolation. **Settings → Diagnostics → App Diagnostics** reports the sandbox state and reason; the desktop log records the same decision. An explicit `--no-sandbox` argument overrides the automatic choice. - ## Server / CLI For headless machines, dev boxes, or any setup where you want the daemon running without the desktop UI: @@ -71,6 +50,7 @@ The image runs the daemon and serves the bundled web UI. It does not bundle agen ## Where next +- [Run parallel tasks](/docs/parallel-development), use separate worktrees, review diffs, and test changes in the desktop app. - [Connectivity](/docs/connectivity), connect through the relay or Tailscale. - [Docker](/docs/docker), run the daemon and bundled web UI in a container. - [Workspaces](/docs/workspaces), the project, workspace, and session model Paseo is built around. diff --git a/public-docs/mcp.md b/public-docs/mcp.md index a52327174a..58e8a2db56 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -87,27 +87,29 @@ MCP does not expose an agent-detach tool. Detaching is a manual user action in t ### Agents -| Tool | Function | -| -------------------- | --------------------------------------------------------------------------------------- | -| `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. | -| `send_agent_prompt` | Send a prompt to an existing agent using its `agentId` and a `prompt`. | -| `get_agent_status` | Return the latest snapshot for an agent. | -| `list_agents` | List recent agents as compact metadata. | -| `cancel_agent` | Abort an agent's current run but keep the agent alive. | -| `archive_agent` | Soft-delete an agent and remove it from the active list. | -| `kill_agent` | Terminate an agent session permanently. | -| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | -| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | -| `set_agent_mode` | Switch an agent's session mode. | +| Tool | Function | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. Use `initialPrompt` to start work; returns without waiting for completion. | +| `send_agent_prompt` | Send a prompt using `agentId` and `prompt`; returns without waiting for completion. | +| `get_agent_status` | Return the latest snapshot for an agent. | +| `list_agents` | List recent agents as compact metadata; `includeBackground: true` includes background work. | +| `cancel_agent` | Abort an agent's current run but keep the agent alive. | +| `archive_agent` | Soft-delete an agent and remove it from the active list. | +| `kill_agent` | Terminate an agent session permanently. | +| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | +| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | +| `set_agent_mode` | Switch an agent's session mode. | + +Paseo agents receive finish, error, and permission notifications by default; set `notifyOnFinish: false` to disable them. External MCP clients use `get_agent_status` and `get_agent_activity` to check results. ### Workspaces -| Tool | Function | -| ------------------- | ----------------------------------------------------------------------------------------------------- | -| `create_workspace` | Create a local or worktree-isolated workspace. Worktrees can branch off, check out a branch, or a PR. | -| `list_workspaces` | List active workspaces and their directories and isolation. | -| `rename_workspace` | Change the user-visible name of the current or specified workspace. | -| `archive_workspace` | Archive a workspace and the sessions it owns. | +| Tool | Function | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create_workspace` | Create a local or worktree-isolated workspace. Worktrees can branch off, check out a branch, or a PR. `background: true` hides it from default lists and the sidebar while preserving history and access by ID. Omission inherits the calling agent’s workspace setting, or defaults to false without a caller. | +| `list_workspaces` | List active workspaces and their directories and isolation. Use `includeBackground: true` to include background workspaces. | +| `rename_workspace` | Change the user-visible name of the current or specified workspace. | +| `archive_workspace` | Archive a workspace and the sessions it owns. | For worktree isolation, `create_workspace` accepts the same useful choices as the app: branch off from a base, check out an existing branch, or check out a pull request. The worktree remains an implementation detail of the workspace lifecycle. diff --git a/public-docs/orchestration.md b/public-docs/orchestration.md index 74c15a36aa..cd65e709ce 100644 --- a/public-docs/orchestration.md +++ b/public-docs/orchestration.md @@ -10,6 +10,8 @@ category: Orchestration Paseo lets your coding agents coordinate other agents, split work across providers and machines, and keep tasks moving automatically. +You can also start and supervise each task yourself in the desktop app. [Run parallel tasks in separate worktrees](/docs/parallel-development), inspect their diffs, and test each app in a terminal and browser before choosing what to merge. + ## What your agents can do - **Choose providers and models:** launch other agents using any provider and model configured on the host. @@ -46,12 +48,12 @@ See the [MCP reference](/docs/mcp) for tool configuration and the full catalog. Agents with shell access can also use the Paseo CLI. This route does not require enabling tool injection. With Paseo installed, a running host, and Codex configured: ```bash -paseo run --provider codex --background \ +paseo run --provider codex --no-wait \ "Review this branch without changing files" paseo ls -a ``` -The first command starts a worker and returns immediately; the second lists agents from active workspaces, including archived agents. When a Paseo agent runs the command, the worker becomes its subagent in the same workspace. From your own terminal, it starts in a new local workspace. +The first command starts a worker and returns immediately; the second lists archived and background agents as well. When a Paseo agent runs the command, the worker becomes its subagent in the same workspace. From your own terminal, it starts in a new local workspace. See the [CLI reference](/docs/cli) for follow-ups, output, worktrees, and remote hosts. diff --git a/public-docs/parallel-development.md b/public-docs/parallel-development.md new file mode 100644 index 0000000000..4d9c450d25 --- /dev/null +++ b/public-docs/parallel-development.md @@ -0,0 +1,52 @@ +--- +title: Run parallel tasks +description: Run coding agents in separate worktrees, review their diffs, and test each change with terminals and the built-in browser in Paseo. +nav: Run parallel tasks +order: 12 +category: Workspaces +--- + +# Run parallel tasks + +Use Paseo's desktop app to give several coding agents independent tasks, review their changes, and run the results. Each task gets a workspace with its own git worktree and branch, with agents, files, diffs, terminals, and browser tabs together. + +Start with the [desktop app](/docs#desktop-app-recommended), a local git repository, and at least one [coding agent installed and authenticated](/docs/supported-providers). The examples below use a repository with an `origin/main` branch. + +## Start two tasks + +1. Open **New workspace** and select your project. +2. Set **Isolation** to **New worktree** and choose `origin/main` as the starting ref. +3. Choose an agent and model, then submit a task, for example: “Add filtering to the project list. Run the relevant tests.” +4. Create a second workspace with **New worktree** from the same starting ref. Give its agent another task: “Add keyboard navigation to the settings page. Run the relevant tests.” + +Both agents run concurrently. Select either workspace in the sidebar to read progress or send a follow-up. Choose the same provider for both tasks or a different provider for each. + +Separate worktrees keep their file edits on separate branches. For two alternative implementations of the same feature, give both agents the same task and compare their results before choosing one. + +## Review each change + +Select a workspace and open **Changes** from the Command Center to inspect its diff. Open **Files** to browse and edit the source. Use split panes to keep the agent conversation beside the code you're reviewing. + +Send corrections to that workspace's agent. To get another opinion, use **New agent** in the same workspace and ask it to review the changes without editing files. Agents in the same workspace share its working directory. + +## Run and test the app + +In the selected workspace, use **New terminal** from the Command Center. Install dependencies and run your project's test or development command there. + +Each worktree has its own files, but processes still share the machine's network ports. Assign a different dev-server port to each running copy. For repeatable setup and service ports, configure [worktree setup and scripts](/docs/worktrees#paseojson). + +For a web app, use **New browser** and enter that workspace's dev-server URL. Exercise the changed flow, then switch workspaces to test the other implementation. Browser tabs run inside the desktop app. + +You can also ask the agent to check the app in those tabs. Enable [browser automation](/docs/browser#enabling), then give it the URL and concrete steps to verify, for example: + +> Open http://localhost:3001, filter the project list by name, and verify that clearing the filter restores the full list. Report any failures and take a screenshot of the result. + +## Choose what to merge + +Review the diff and test results for each workspace. Ask for any remaining changes before committing and opening a pull request for that branch, using your usual Git workflow in the workspace terminal or through its agent. + +For independent tasks, review and merge each branch. For competing implementations, choose the version you want to keep. Archive a workspace when you no longer need it; [worktree cleanup](/docs/worktrees#manage-the-workspace) removes the managed directory after its last workspace is archived. + +## Let an agent coordinate the tasks + +When you want an agent to create the workspaces, launch workers, and collect their results, use [orchestration](/docs/orchestration). The workers appear in Paseo so you can inspect their conversations and changes as they work. diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index 033235b7f0..50d9be9826 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -21,6 +21,16 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add [connect a coding agent as a provider](/docs/plugins/providers). Client contributions run on every Paseo client connected to that daemon, including mobile. +Browse [published plugins](https://paseo.sh/plugins), then install one on your daemon: + +```bash +paseo plugin add owner/slug +``` + +Or paste `owner/slug` into **Settings → Plugins → Plugin source** and select **Install plugin**. +Paseo installs the registry's reviewed artifact. Use `git:owner/repository` or a full Git URL +when you want to install directly from a Git repository. + This guide scaffolds a plugin, runs it, and adds a workspace panel to it. ## Create a plugin @@ -246,13 +256,13 @@ reload use the installed files. See [plugin sources](/docs/plugins/reference#plugin-sources) for identifier syntax. To share your own plugin, follow [Publish a plugin](/docs/plugins/publishing). -Plugins published in a Git repository install by shorthand or URL: +Plugins published in a Git repository install by explicit Git shorthand or URL: ```bash -paseo plugin add owner/repository +paseo plugin add git:owner/repository paseo plugin add https://gitlab.com/group/repository.git -paseo plugin add owner/monorepo:plugins/workspace -paseo plugin add owner/repository --ref main +paseo plugin add git:owner/monorepo:plugins/workspace +paseo plugin add git:owner/repository --ref main ``` Append `:relative/path` when the plugin lives below the repository root. `--ref` selects the initial diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 69cf206641..2f4951669c 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -73,6 +73,32 @@ The connection has three operations: Keep the native SDK, process, and stream inside the connection implementation. Convert its output to `ProviderEvent` objects before publishing it. +## Launch the provider CLI + +Use `spawnProcess` and `execCommand` from `@getpaseo/plugin/server` for process transports +and CLI probes. Both handle Windows `.cmd`/`.bat` launchers and quote shell arguments. +Raw Node `spawn` and `execFile` reject those launchers on Windows. + +The daemon supplies `request.launch` with the resolved command, argument prefix, and complete +environment. Pass that environment explicitly; the helpers do not apply daemon environment policy. +Keep the process lifecycle and protocol deadlines inside your provider. End stdin for graceful +shutdown; use `terminateProcess(child)` for a forced stop. It kills the process tree on Windows, +including the CLI behind a command-script launcher. Bound your final wait for closed streams. + +```ts +import { spawnProcess, execCommand } from "@getpaseo/plugin/server"; + +const child = spawnProcess(launch.command, [...launch.args, "serve"], { + env: launch.env, + cwd, + stdio: "pipe", +}); +const { stdout } = await execCommand(launch.command, [...launch.args, "--version"], { + env: launch.env, + timeout: 5000, +}); +``` + ## Return models, modes, and thinking options Paseo requests the catalog before creating a session. Return the choices needed by the agent form: diff --git a/public-docs/plugins/publishing.md b/public-docs/plugins/publishing.md index 0e7f2f89cf..16cde2389a 100644 --- a/public-docs/plugins/publishing.md +++ b/public-docs/plugins/publishing.md @@ -11,9 +11,24 @@ category: Plugins Publish your plugin so other people can install and use it in Paseo. Start with a working [plugin project](/docs/plugins), then choose where to share it: +- [Plugin registry](#plugin-registry): share a reviewed artifact users install by `owner/slug`. - [npm](#publish-on-npm): publish a package on the public npm registry. - [GitHub or Git](#share-through-github-or-git): let users install from a repository. +## Plugin registry + +Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins). +Users install the reviewed artifact with: + +```bash +paseo plugin add owner/slug +``` + +Publish your artifact through npm or Git, then follow the +[registry submission guide](https://github.com/getpaseo/plugins) to submit it for review. +Registry installations and updates use the approved revision and plugin path. +Direct Git installs use explicit `git:owner/repository` shorthand or a full Git URL. + ## Publish on npm The scaffold prepares the package files and development dependencies. You choose the package name @@ -56,6 +71,7 @@ The scaffold includes this `files` list in `package.json`: { "files": [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", @@ -136,12 +152,84 @@ In the app, enter only the source identifier. ::: +## Icons and screenshots + +Declare your display name, icon, screenshots, and demo videos in `paseo-plugin.json`: + +```json +{ + "id": "review-tools", + "name": "Review tools", + "description": "Reviews changes before merge", + "icon": "assets/icon.png", + "media": ["assets/screenshot.png", "https://example.com/review-demo.mp4"], + "requirements": { "paseo": ">=0.11.0" } +} +``` + +All three fields are optional. `name` gives the registry and website a display name without +changing the installation ID. When omitted, the registry falls back to a humanized ID. +Registry and website display requires the registry builder to read these fields from the pinned +manifest. That update is a separate follow-up; until it lands, these fields are exposed only +through the daemon's installed-plugin list. +`icon` must point to a PNG inside the plugin package. `media` accepts paths to images and videos +inside the package or HTTPS URLs, in display order. Paths are relative to the manifest; use +forward slashes and keep them inside the package. See the [manifest reference](/docs/plugins/reference#project-files) +for validation rules. + +Commit local assets alongside the manifest at the pinned revision. For npm, add their directory +(for example, `assets/`) to `package.json`'s `files` list and verify it with `npm pack --dry-run`. +The scaffold documents these fields in its manifest's `$comment`; add your display name and +assets before publishing. + +Manifests using these fields fail to install on daemons before 0.11.0. Set +`requirements.paseo` to `>=0.11.0` or a narrower supported range. + +## Your listing page + +Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo. +It helps someone decide whether to install your plugin; the install command is already at +the top of that page. A README assumes a GitHub audience and carries installation +instructions, technical details, and badges. Long, AI-generated READMEs make people read +past that material to understand what a plugin does. + +`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside +`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it +relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm +package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment +with useful facts before publishing. + +The repository overview takes precedence over a registry import stopgap. Online validation +fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records +that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump +requires a repository overview and removes the stopgap in the same PR. A bump without the +repository overview fails validation. README files do not supply the overview. + +Author overviews and registry stopgaps follow the same content contract, in this order. +Choose headings only when they help; length follows complexity. A theme needs one paragraph. + +1. Explain what the plugin is and does in plain terms first. +2. Explain how it works only when that is not obvious. +3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other + plugins. Include applicable daemon version and operating system requirements. Setup + guidance is allowed; installation instructions are not. +4. Explain capabilities and settings worth understanding, what each option does, what the + plugin reads or sends and where, permissions, and known limits. + +Use sentence case and plain factual language, with no em dashes. Omit installation commands, +badges, changelog, contributing or license sections, marketing, and unsupported claims. +Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields, +or lists of absent features. Keep only what helps someone choose the plugin. + +If your plugin is an unchanged imported record, you can propose an author-written overview +to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`. + ## Share through GitHub or Git Push the plugin project to a repository. Users can install it with: ```bash -paseo plugin install github:acme/paseo-review +paseo plugin add git:acme/paseo-review ``` For another Git host: diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 760cb1857d..04c88bdf1a 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -52,12 +52,24 @@ The required root manifest is `paseo-plugin.json`: } ``` -| Field | Required | Behavior | -| -------------- | -------- | ---------------------------------------------------------------------- | -| `id` | Yes | Default installation ID. | -| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | -| `requirements` | No | Supported Paseo versions, described below. | -| `build` | No | Preparation commands, described in the CLI reference. | +| Field | Required | Behavior | +| -------------- | -------- | ----------------------------------------------------------------------------------------- | +| `id` | Yes | Default installation ID. | +| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. | +| `icon` | No | Relative path to a PNG inside the plugin package. | +| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | +| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | +| `requirements` | No | Supported Paseo versions, described below. | +| `build` | No | Preparation commands, described in the CLI reference. | + +Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..` +segments. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) for an example. +Paseo validates the references without opening local assets or fetching URLs. + +Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside +`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check. +Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject +these fields during installation. ### Requirements @@ -342,6 +354,7 @@ A source implements two calls: interface UsageAccount { key: string; label?: string; + harness?: string; input: JsonValue; } @@ -375,25 +388,29 @@ type UsageProblem = | { kind: "no_quota"; detail: string }; ``` -`discover({ kind: "global" })` queries machine login stores, including expired logins. Session -scope queries only the login stores selected by that harness's resolved launch environment. -Return `[]` when no login exists or the session does not use your source. Never scan default stores -from session discovery or scan agents from global discovery. Discovery is a query, with no agent +Session discovery returns only the login that agent runs on. The agent popover uses only those +inputs; it never tries logins from global discovery or other agents. For any other provider, +return `[]`. Global discovery lists every account, including expired logins. + +Resolve the session account from that session's launch environment. Return `[]` when no login exists. +Never scan default stores from session discovery or scan agents from global discovery. Discovery is a query, with no agent lifecycle hooks. Closed agents have no session scope until resumed. Inputs name credential stores; never put credentials in inputs or reports. Paseo validates each input against your schema before calling `fetch()`. The same key in any scope is the same report; -agents sharing an account share the fetch cache. Fetches have a 20-second deadline. +fetches share a five-minute cache only when their ordered login inputs match. Each login fetch +has a 20-second deadline. Built-in session routes: -| Source | Session | Login store | -| ------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence | -| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store | -| Codex | `codex` | `CODEX_HOME/auth.json`, or the default | -| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store | -| Other sources | Any | No session discovery | +| Source | Session | Login store | +| ---------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence | +| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store | +| Codex | `codex` | `CODEX_HOME/auth.json`, or the default | +| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store | +| Muse | `muse` | That session's own Muse launch/config | +| Other built-in sources | Any | No session discovery | Claude excludes Bedrock, Vertex, and foreign `ANTHROPIC_BASE_URL` sessions. Codex excludes sessions with `OPENAI_BASE_URL` set. @@ -403,9 +420,13 @@ organization whose quota is metered and survives token rotation. Never use a cre email as the key. Use `hashAccountKey()` for sensitive stable identities or a store locator when account metadata is unavailable. Labels can name accounts without becoming their identity. -Return logins in preference order. Several entries with the same key become one card, with their -inputs tried in order until a report is `available`. An unavailable report, error report, or thrown -fetch falls through to the next input. If none succeeds, the card carries the last report. +Return logins in preference order and set `harness` to the owning harness's display name, such as +Codex, OpenCode, Pi, or OMP. The daemon treats inputs as opaque and never derives labels from them. + +The host-wide Usage modal groups logins with the same key into one account card and tries them +concurrently, preferring the discovery order when selecting a result. If any login returns +`available`, it shows usage with no errors. If all fail, it shows +every login's error on a separate line with its harness label and its own remedy. Discovery failures are logged by the daemon and produce no card. Window names must come from provider data: an explicit duration, a named API field such as @@ -485,7 +506,8 @@ metadata or JWT claims. Pi and OMP logins remain discoverable when expired. OMP `usage.list_reports` discovers accounts when called without IDs. With IDs, it refreshes known accounts without rediscovering identity. If a store switches accounts, the existing card shows the new login's quota until the next discovery. Reports are cached for five minutes; `forceRefresh` -bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Clients gate the feature +bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Failed accounts also carry optional +`loginErrors` entries with `harness` and each login's failed `report`. Clients gate the feature on `server_info.features.usageSources`. The older `provider.usage.list` RPC maps the same reports for 0.10 clients and renders problems into its `error` string. @@ -708,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`): @@ -765,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:** @@ -2248,16 +2268,20 @@ failures stay inside the plugin error boundary. Paste one of these source identifiers into **Settings → Plugins**, or pass it to `paseo plugin install`. `paseo plugin add <source>` and `paseo plugin install <source>` are aliases. -Absolute host paths are recommended because relative paths resolve against the daemon's working -directory. The app does not expand `~`; your shell may expand it before the CLI runs. +The CLI resolves relative directory paths from your current working directory and expands `~` +to your local home directory, preserving any `:plugin/path` suffix. With `--host`, use an absolute +path that exists on the daemon host; plugin files are read there and are never uploaded by the CLI. +Paths entered in Settings resolve relative to the daemon's working directory, and `~` expands to +the daemon's home directory. | Source | Accepted form | Example | | -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | +| Plugin registry | `owner/slug` or `host/owner/slug` | `acme/review` | | Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` | -| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` | +| GitHub repository | `git:owner/repository` or `github:owner/repository` | `github:acme/paseo-review` | | Git repository | `git:<URL or SCP source>`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` | | npm package | `npm:<name>[@<version, tag, or range>]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` | -| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` | +| Plugin below a source root | Append `:relative/plugin/path` to a directory, Git, or npm source | `github:acme/monorepo:plugins/review` | Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use `user@host:path`. `file://` selects Git acquisition, not directory installation. @@ -2273,24 +2297,27 @@ The package registry validates the selected version, tag, or range. Paseo resolves an identifier in this order: -1. An existing directory matching the complete identifier on the daemon host wins, including a - literal directory containing `:`. +1. A bare `owner/slug` is a registry id; local directory sources are `.` or `..`, or start with `./`, `../`, `.\`, `..\`, `/`, `~`, a Windows drive (`C:\` or `C:/`), or a UNC (`\\server\share`) prefix. 2. Otherwise, recognize `npm:`, `github:`, or `git:` before interpreting a subdirectory suffix. `git://` is a Git URL scheme. An explicit prefix selects acquisition of that kind. 3. Recognize a final `:relative/plugin/path` only when its suffix contains no empty, `.` or `..` segments. A lone `.` selects the source root. Both `/` and `\` separate suffix segments; use `/` across hosts. URL ports and the separator in an SCP source stay part of the source. A suffix that does not satisfy these rules stays part of the identifier. -4. Without an explicit prefix, an existing directory matching the remaining source wins. -5. Resolve Git URLs and SCP sources as Git; expand exact `owner/repository` shorthand to GitHub - HTTPS. `github:` requires that shorthand; `git:` accepts it as well as URLs and SCP sources. -6. Resolve a remaining npm package name with its optional selector through the host's registry. +4. Resolve bare `owner/slug` through the default plugin registry and `host/owner/slug` through + that registry host. Resolve Git URLs and SCP sources as Git. `github:` requires + `owner/repository` shorthand; `git:` accepts it as well as URLs and SCP sources. +5. Resolve a remaining npm package name with its optional selector through the host's registry. Reject anything else. -Plugin registry installs are off by default. When the daemon enables them with -`pluginRegistryEnabled: true` or `PASEO_PLUGIN_REGISTRY_ENABLED=1`, bare `owner/slug` and -`host/owner/slug` resolve through the plugin registry instead of GitHub, the registry record owns -the revision and plugin path, and GitHub shorthand requires `github:`. +The default registry is `https://plugins.paseo.sh`. Browse [published plugins](https://paseo.sh/plugins) +and install with `paseo plugin add owner/slug`. Registry records own the revision and plugin path. +Use `git:owner/repository` for explicit GitHub shorthand or a full Git URL. +Set `PASEO_PLUGIN_REGISTRY` to use a self-hosted default URL, including a path prefix. +Private registry credentials use `pluginRegistries: { "host": { "authorization": "Bearer token" } }` +in daemon config. Restart the daemon after changing these startup settings. Credentials go only +to that registry, never to artifact hosts or redirects. Installed plugins retain their recorded +source and registry URL when defaults change. Directory lookup happens on the daemon host. The app uses the `paseo-plugin.json` ID; the CLI accepts `--id <runtime-id>` to override it. An existing installation ID is rejected without changing @@ -2337,9 +2364,10 @@ deletes its managed files; removing a directory plugin keeps your source directo paseo plugin init /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin --id another-runtime-id -paseo plugin add owner/repository +paseo plugin add owner/slug +paseo plugin add git:owner/repository paseo plugin add https://git.example.com/owner/repository.git --ref main -paseo plugin add owner/monorepo:plugins/review +paseo plugin add git:owner/monorepo:plugins/review paseo plugin ls [id] paseo plugin update <id> paseo plugin update --all --check diff --git a/public-docs/providers.md b/public-docs/providers.md index 0bc892574d..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 @@ -16,7 +18,7 @@ A provider is the contract between Paseo and one external agent CLI: how to laun ## Two tiers -- **Native support**, Paseo ships a bundled adapter for the major agents (Claude Code, Codex, OpenCode, Pi, [Antigravity](/docs/supported-providers#antigravity), [Muse Code](/docs/muse-code), [Hoplite](/docs/supported-providers#hoplite)). Auto-discovered when the underlying CLI is installed, with mode metadata and voice support where applicable. Hoplite's CLI runs on your machine, but its agent works in a Hoplite cloud workspace, so your local MCP servers and Paseo tools are not available there. +- **Native support**, Paseo ships a bundled adapter for the major agents (Claude Code, Codex, OpenCode, Pi, [Antigravity](/docs/supported-providers#antigravity), [Muse Code](/docs/muse-code)). Auto-discovered when the underlying CLI is installed, with mode metadata and voice support where applicable. - **ACP catalog**, any agent speaking the [Agent Client Protocol](https://agentclientprotocol.com) is supported through a generic adapter. Paseo ships a curated catalog of one-click installs (Cursor, Gemini, GitHub Copilot, Hermes, Kimi, Qwen Code, and 25+ more), and you can add any other ACP agent yourself. Either way, **you install the underlying CLI**. Paseo runs it. diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md index fa3ed80c00..87d3e993cf 100644 --- a/public-docs/sdk/agents.md +++ b/public-docs/sdk/agents.md @@ -105,6 +105,31 @@ const child = await workspace.agents.create({ `parent` establishes parentage. Archiving a parent cascade-archives its children. Call `detach()` first when a child should continue independently. +## Run work in a background workspace + +Create a background workspace and its first agent in one call. Put `background` on the +workspace options and the agent's configuration under `agent`: + +```ts +const cwd = "/Users/me/dev/storefront"; +const workspace = await client.workspaces.create({ + source: { kind: "directory", path: cwd }, + background: true, + agent: { + cwd, + config: { provider: "codex/gpt-5.5" }, + prompt: "Review the checkout flow.", + }, +}); +``` + +The call returns a workspace handle and starts its first agent with the supplied prompt. + +Background agents keep their history and survive daemon restarts. Default discovery hides them; +use `client.agents.list({ filter: { includeBackground: true } })` to include them. +Exact-ID handles work normally. See [workspace creation](./workspaces.md#create-a-fresh-workspace) +for defaults and caller inheritance. + ## Request structured output ```ts diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 620f398691..76490974fe 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -57,7 +57,10 @@ Create a new client after `close()`. `list({ subscribe: {} })` also returns a `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Capable daemons assign the ID and keep observations independent. Older daemons use local IDs and their established shared delivery behavior. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). -Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`. +Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, `background`, and `labels`. + +`background` is workspace creation intent. It cannot accompany an existing `workspaceId`. +See [background workspaces](./workspaces.md) for caller inheritance and inclusive discovery. `config` accepts: @@ -123,14 +126,19 @@ See [events](./events.md#follow-provider-catalog-changes) for explicit event obs ## `client.workspaces` -| Method | Result | Behavior | -| ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------- | -| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | -| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | -| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | -| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | -| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | -| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | +| Method | Result | Behavior | +| ------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | +| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | +| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. `background: true` hides it from default discovery. | +| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | +| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | +| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | + +`create({ source, agent, background })` creates a workspace and its initial agent together. +`agent` accepts agent creation options except `worktree`, `git`, `onEvent`, `idempotencyKey`, +and `requestId`. Set `background` on the workspace options; `agent.background` is rejected. +See the [combined creation example](./agents.md#run-work-in-a-background-workspace). A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. diff --git a/public-docs/sdk/workspaces.md b/public-docs/sdk/workspaces.md index 6775e90b3d..2bf1dd7ee3 100644 --- a/public-docs/sdk/workspaces.md +++ b/public-docs/sdk/workspaces.md @@ -50,8 +50,25 @@ const workspace = await client.workspaces.create({ }); ``` +Pass `agent` to create the workspace and its first agent in one call. See the +[combined background workspace example](./agents.md#run-work-in-a-background-workspace). +Set visibility on the workspace options; `agent.background` is rejected. + You can pass `projectId` in either source when you already have one. Most integrations should omit it; the daemon finds or creates the project from the directory. +Pass `background: true` to hide a workspace from default discovery. Its agents and terminals +remain durable, with ordinary history, lifecycle hooks, archive, and exact-ID access. The app's +sidebar offers **Show background** without dropping replicated state. + +`background` is creation-only. Omission defaults to false; when `callerAgentId` identifies an agent +creating a new workspace, omission inherits its workspace's value. Explicit false or true overrides +inheritance. Agent creation with `workspaceId` rejects explicit background creation intent; configure +visibility when creating the workspace. SDK/RPC implicit agent creation also accepts `background` +as workspace creation intent, never as an agent property. + +Creation and inclusive discovery require `features.backgroundWorkspaces`; the client rejects an +unsupported host with an update-host message. + ## Start an agent in a workspace Create through the workspace handle: @@ -116,6 +133,11 @@ do { } while (cursor); ``` +Background workspaces are left out unless `filter.includeBackground` is true. Use +`client.workspaces.list({ filter: { includeBackground: true } })` or +`client.agents.list({ filter: { includeBackground: true } })`. Workspace descriptors carry +`background`; agents derive visibility from their workspace and have no separate setting. + ## Refresh and archive a handle ```ts diff --git a/public-docs/security.md b/public-docs/security.md index a935a75878..d1b6ee229a 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -121,6 +121,14 @@ The image runs the daemon and launched agents as the non-root `paseo` user, but See [Docker](/docs/docker) for Compose and reverse proxy examples. +## Linux desktop sandbox + +Paseo checks Chromium sandbox availability each time the desktop app launches on Linux. It keeps sandboxing enabled when user namespaces work or an installed sandbox helper is usable. The `.deb` and `.rpm` installers configure that helper. + +AppImage and extracted tar archives launch with Chromium's sandbox disabled when neither is available. Use the `.deb` or `.rpm` package if you require Chromium process isolation. Passing `--no-sandbox` explicitly disables it. + +Open **Settings → Diagnostics → App Diagnostics** to check the sandbox state and reason. The [desktop log](/docs/troubleshooting#reading-the-logs) records the same decision. + ## Agent authentication Paseo wraps agent CLIs (Claude Code, Codex, OpenCode, Muse Code) but does not manage their authentication. Each agent provider handles its own credentials: diff --git a/public-docs/supported-providers.md b/public-docs/supported-providers.md index a958276224..1aa981ba29 100644 --- a/public-docs/supported-providers.md +++ b/public-docs/supported-providers.md @@ -20,7 +20,6 @@ Work out of the box once the underlying CLI is installed and authenticated. - [Pi](https://pi.dev). Minimal terminal-based coding agent with multi-provider LLM support. - [Antigravity](#antigravity). Uses your installed `agy` CLI through a built-in provider plugin. - [Muse Code](/docs/muse-code). Meta's terminal coding agent, bundled as a provider plugin. -- [Hoplite](#hoplite). Uses your installed `hoplite` CLI through a built-in provider plugin. ### Antigravity @@ -59,37 +58,6 @@ for provider-specific environment variables: See [Custom providers](/docs/custom-providers) for command and environment overrides. -### Hoplite - -Install the Hoplite CLI on your daemon host with `brew install carboncopyinc/tap/hoplite`, then run -`hoplite login`. Pick **Hoplite** in Paseo. The provider ships with Paseo and talks to Hoplite -through `hoplite acp`; no separate plugin install is required. - -Hoplite runs the agent in a cloud workspace for a Hoplite project. The project comes from the -GitHub repository of the agent's directory, so open a repository that is linked to a Hoplite -project. In any other directory Hoplite reports `No Hoplite project`, and the Hoplite row in -**Settings → Providers** shows that error for the daemon's own directory. To use one project -everywhere, set its ID: - -```json -{ - "agents": { - "providers": { - "hoplite": { - "env": { "HOPLITE_PROJECT_ID": "your-project-id" } - } - } - } -} -``` - -Models come from your Hoplite organization. While Hoplite prepares the workspace, the chat shows -one **Hoplite workspace** row with the current step. Conversations resume after a daemon restart. -Paseo MCP tools are unavailable because the agent runs in Hoplite's workspace, not on your host; -Hoplite's own tools, including its subagents, still work. - -Override `command` if `hoplite` is outside your daemon's PATH. Keep `acp` in the arguments. - ## ACP catalog Pick any of these from the in-app provider catalog. Each entry is a one-click install of the underlying CLI. @@ -117,6 +85,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in - [goose](https://block.github.io/goose/), Block's local open-source AI agent. - [Grok](https://docs.x.ai/build/overview), xAI's Grok Build agentic coding CLI. - [Hermes Agent](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp), Nous Research's self-improving agent. +- [Hoplite](#hoplite), cloud coding agent through its local ACP bridge. - [Junie](https://junie.jetbrains.com/docs/junie-cli-acp.html), JetBrains' coding agent. - [Kilo Code](https://kilo.ai/docs/code-with-ai/platforms/cli), open-source coding agent. - [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), Moonshot AI's coding assistant. @@ -132,3 +101,18 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in - [VT Code](https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md), open-source multi-provider coding agent. The in-app catalog is the canonical, version-pinned source. Anything not listed here can still be added manually, see [Custom providers](/docs/custom-providers). + +### Hoplite + +Install the [Hoplite CLI](https://hoplite.sh/docs/cli) on your daemon host and run +`hoplite login`. In **Settings → Hosts → your host → Providers**, find **Hoplite** +in the catalog and choose **Add**. Paseo launches `hoplite acp` through the generic ACP adapter. + +Hoplite runs the agent in a cloud workspace. Open a repository linked to a Hoplite project, +or set `agents.providers.hoplite.env.HOPLITE_PROJECT_ID` to use one project everywhere. +Without a linked repository or pinned project, discovery reports `No Hoplite project`. +Your local files and Paseo's loopback MCP tools are not available in that cloud workspace. + +See [Hoplite's ACP guide](https://hoplite.sh/docs/cli/acp) for authentication and project setup. +Use a [command override](/docs/custom-providers) if `hoplite` is outside your daemon's PATH; +keep `acp` in the arguments. diff --git a/public-docs/troubleshooting.md b/public-docs/troubleshooting.md index 77b2497e03..420c3363aa 100644 --- a/public-docs/troubleshooting.md +++ b/public-docs/troubleshooting.md @@ -1,6 +1,6 @@ --- title: Troubleshooting -description: Why Paseo can't find a provider you've installed, and how to fix the PATH and environment mismatches behind most setup issues. +description: Fix Linux AppImage launch errors, missing providers, and environment mismatches. nav: Common problems order: 90 category: Troubleshooting @@ -8,7 +8,22 @@ category: Troubleshooting # Troubleshooting -Almost every "it works in my terminal but not in Paseo" problem is the same thing: Paseo and your terminal aren't searching the same `PATH`. This page covers how to spot that and fix it. +## AppImage won't open on Linux + +Make the downloaded AppImage executable, then launch it from a terminal to see any error: + +```bash +chmod +x Paseo-x86_64.AppImage +./Paseo-x86_64.AppImage +``` + +If it reports `error loading libfuse.so.2`, run without FUSE: + +```bash +./Paseo-x86_64.AppImage --appimage-extract-and-run +``` + +The Paseo window should open. You can also install the `.deb` on Debian/Ubuntu or the `.rpm` on Fedora from the [download page](https://paseo.sh/download). ## Paseo can't find my provider diff --git a/public-docs/web-ui.md b/public-docs/web-ui.md index 9e80e8355e..6fe574e3cd 100644 --- a/public-docs/web-ui.md +++ b/public-docs/web-ui.md @@ -197,7 +197,7 @@ If you don't want to manage a reverse proxy or open ports, a tunnel gives you an - **Tailscale Serve** keeps it inside your tailnet, no public exposure, TLS handled for you: ```bash - tailscale serve https / http://127.0.0.1:6767 + tailscale serve --bg http://127.0.0.1:6767 ``` Reach it at `https://<your-machine>.<tailnet>.ts.net/`. Only devices on your tailnet can connect. diff --git a/public-docs/why.md b/public-docs/why.md index 44ac4a90c9..8b3d0d8531 100644 --- a/public-docs/why.md +++ b/public-docs/why.md @@ -1,6 +1,6 @@ --- title: Why Paseo? -description: What Paseo is, what it isn't, and how it fits into your workflow. +description: A full agentic development environment for running coding agents, editing files, reviewing changes, and testing your work. nav: Why Paseo? order: 2 category: Getting started @@ -8,38 +8,36 @@ category: Getting started # Why Paseo? -Paseo is a self-hostable platform for running and orchestrating coding agents. It runs the agent CLIs you already use, on the hardware you already have, and gives you a UI, CLI, and API to drive them from anywhere. +Paseo is a full agentic development environment. Run coding agents, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. For agent-driven development, Paseo replaces your IDE: take a task from the first prompt through review and testing in one app. -## Architecture +## Develop in one workspace -- Daemon-client architecture. The daemon manages agents; clients (mobile, desktop, web, CLI) connect locally or over a relay. Remote access isn't an add-on. -- macOS, Windows, and Linux are all primary targets. None of them are a port or an afterthought. -- Mobile, desktop, and web are separate native clients. The mobile app is built in React Native, not a webview. +Open the desktop app, choose a project and an agent, and start working. Keep the conversation, editor, terminal, diffs, and browser together in split panes. Run your app, inspect the result, and ask the agent for changes without leaving the workspace. -## Providers +The desktop app is available on macOS, Windows, and Linux. It starts and manages its local daemon automatically. -- Bring your own. Use your Claude subscription, your OpenAI account, your own API keys, a self-hosted endpoint. Paseo doesn't proxy model calls. -- Local voice stack. Speech-to-text and text-to-speech run on-device by default. OpenAI providers are configurable if you want cloud quality. -- Open source. No telemetry on your code. +## Run tasks in parallel -## Where agents run +Give independent tasks their own worktrees and branches. Run several agents at once, inspect each diff, test each implementation, and choose what to merge. Use the same provider for every task or choose a different provider and model for each. -- Your laptop, a homelab, a company server. Same daemon, same client surface. -- Any directory, git or not. Launch agents, merge locally, review the diff in the app. -- GitHub PRs, checks, and reviews surface in the app when you want them. Not required. +Configure [workspace scripts](/docs/worktrees#scripts-and-services) to start development servers with separate ports for each worktree. [Try the parallel development workflow](/docs/parallel-development). -## Parallel work +## Use your agents and tools -- Splits and panes. Agents, terminals, and browsers side by side in one workspace. -- Per-worktree services. Each worktree gets allocated ports for dev servers and databases, reachable through proxy URLs like `web.fix-auth.my-app.localhost` so they don't collide. -- Multiple agents on the same repo via worktrees. +Paseo runs the coding agents you already use, with your subscriptions, credentials, configuration, skills, and MCP servers. Your agents work with the files and tools on your machine. See [supported providers](/docs/supported-providers). -## Automation +Dictate tasks or use [voice mode](/docs/voice). Speech-to-text and text-to-speech run locally by default, with cloud providers available in settings. -- The CLI exposes the same surface as the app. Anything in the UI is scriptable. -- [Paseo tools](/docs/orchestration). Agents can drive Paseo themselves: create isolated workspaces, spawn subagents, open terminals, and send prompts. -- [Hub](/docs/hub). A service you host that starts agents on your daemon when someone mentions you on GitHub, Slack, or Discord. +## Continue from another device -## What it isn't +The daemon owns the running agents and workspaces. The desktop app connects to it locally; mobile, web, and CLI clients connect to the same environment. That separation lets you continue working from another device or run your environment on a server. -Not a hosted agent, not an IDE, not a model provider. Paseo runs the CLIs you already use and stays out of the way. +Use Paseo entirely on your laptop, or connect to another machine when you need it. The native iOS and Android app gives you agents, files, terminals, and diffs on your phone. The built-in browser runs in the desktop app. [Connect your devices](/docs/connectivity) directly or through the optional end-to-end encrypted relay. + +## Automate and extend your workflow + +Give agents [Paseo tools](/docs/orchestration) to create worktrees, launch other agents, send prompts, and collect results. Use the [CLI](/docs/cli) or [TypeScript SDK](/docs/sdk) to automate agent and workspace operations. + +[Plugins](/docs/plugins) add providers, workspace panels, commands, and workflows. [Hub](/docs/hub) connects external events to agents running on your machines. + +Paseo is open source under Apache-2.0, with no telemetry, tracking, or required Paseo account. diff --git a/public-docs/workspaces.md b/public-docs/workspaces.md index caf59884c4..2e74935d54 100644 --- a/public-docs/workspaces.md +++ b/public-docs/workspaces.md @@ -48,6 +48,8 @@ The workspace is the product concept; a git worktree is one way to isolate its f ## Creating a workspace +For a desktop walkthrough from starting parallel agents to reviewing and testing their changes, see [Run parallel tasks](/docs/parallel-development). + You can create a workspace in the app or from the CLI: ```bash diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 4235127a63..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/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index cb80266c13..2b6e26c3ea 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -13,7 +13,8 @@ // node_modules populated (the Nix build invokes this post-configHook). import { nodeFileTrace } from "@vercel/nft"; -import { glob } from "node:fs/promises"; +import { glob, readFile, readdir } from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -29,6 +30,47 @@ const { sherpaPlatformPackageName } = await import( ); const traceDesktop = process.env.PASEO_TRACE_DESKTOP === "1"; +const terminalModule = "packages/server/dist/server/terminal/terminal.js"; +const sherpaModule = + "packages/server/dist/server/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.js"; +const sherpaEnvModule = + "packages/server/dist/server/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; + +// Resolve-only and computed requires need explicit graph edges, not installed +// paths. Analyze them at their real importer so nft retains the package exports, +// manifests and workspace symlinks used by Node's resolution. +const runtimeDependencies = new Map([ + ["packages/cli/dist/commands/daemon/local-daemon.js", ["@getpaseo/server"]], + [terminalModule, ["@getpaseo/cli/bin/paseo", "node-pty/package.json"]], + [sherpaModule, ["sherpa-onnx-node"]], + [sherpaEnvModule, [`${sherpaPlatformPackageName()}/package.json`]], + ...(traceDesktop + ? [ + ["packages/desktop/dist/daemon/runtime-paths.js", ["@getpaseo/server"]], + ["packages/desktop/dist/integrations/cli-install/paths.js", ["@getpaseo/cli/bin/paseo"]], + ] + : []), +]); + +function requireFrom(importer) { + return createRequire(path.join(REPO_ROOT, importer)); +} + +function resolvedPackageFiles(importer, specifier) { + const manifest = requireFrom(importer).resolve(`${specifier}/package.json`); + return path.join(path.dirname(manifest), "**"); +} + +// Let node-pty select the native build it actually loads. npm hoisting and +// prebuild/build layout are owned by the package, not this trace. Its Darwin +// spawn-helper lives beside the selected addon; retain the files in that dir. +const terminalRequire = requireFrom(terminalModule); +const ptyLoader = terminalRequire.resolve("node-pty/lib/utils"); +const { dir: ptyNativeDir } = terminalRequire(ptyLoader).loadNativeModule("pty"); +const ptyNativeRoot = path.resolve(path.dirname(ptyLoader), ptyNativeDir); +const ptyNativeFiles = (await readdir(ptyNativeRoot, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(ptyNativeRoot, entry.name)); // Daemon entry points. Workers forked into their own Node processes have // independent require trees; nft does not follow fork boundaries, so trace @@ -63,33 +105,40 @@ const additionalInputs = [ "packages/server/dist/server/server/agent/providers/opencode/**/bridge-plugin.bundle.mjs", // Server runtime config files (read by path, not require) "packages/server/.env.example", - // CLI shebang script wrapping dist/index.js - "packages/cli/bin/paseo", - // node-pty's compiled native addon. nft can't trace it because - // node-pty loads it via `require(path.join(__dirname, 'prebuilds/<plat>/pty.node'))` - // with a runtime-computed platform suffix. Pin to the host platform — - // the Nix derivation builds for one platform at a time and ships only - // its own binaries. - `node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/**`, - // sherpa-onnx-node dynamically resolves a platform-specific native package. - // Copy the wrapper plus the host platform package explicitly. - "node_modules/sherpa-onnx-node/**", - `node_modules/${sherpaPlatformPackageName()}/**`, + ...ptyNativeFiles, + // Resolve native speech packages from the same modules as the runtime. + resolvedPackageFiles(sherpaModule, "sherpa-onnx-node"), + resolvedPackageFiles(sherpaEnvModule, sherpaPlatformPackageName()), ...(traceDesktop ? [ // The unpackaged Nix launcher resolves these beside desktop/dist. "packages/desktop/package.json", "packages/desktop/assets/**", - // resolveExternalCliEntrypoint() looks up the workspace through this - // link at runtime; nft traces the target files but not the link. - "node_modules/@getpaseo/cli", ] : []), ]; +// Fail at build time if a required runtime edge cannot resolve. +for (const [importer, specifiers] of runtimeDependencies) { + for (const specifier of specifiers) requireFrom(importer).resolve(specifier); +} + // Trace. const { fileList, warnings } = await nodeFileTrace(entries, { base: REPO_ROOT, + async readFile(file) { + let source; + try { + source = await readFile(file); + } catch (error) { + if (error.code === "ENOENT" || error.code === "EISDIR") return null; + throw error; + } + const dependencies = runtimeDependencies.get(path.relative(REPO_ROOT, file)); + if (!dependencies) return source; + // These statements exist only in nft's input, never in the shipped code. + return `${source}\n${dependencies.map((specifier) => `require(${JSON.stringify(specifier)});`).join("\n")}`; + }, // Tolerate the conditional / dynamic patterns we already audited: // sherpa-onnx-${platform}-${arch} package resolution (the host package // is copied explicitly above), and a handful of test-only requires that @@ -129,10 +178,10 @@ const expanded = new Set(fileList); for (const pattern of additionalInputs) { if (pattern.includes("*")) { for await (const file of glob(pattern, { cwd: REPO_ROOT })) { - expanded.add(file); + expanded.add(path.relative(REPO_ROOT, path.resolve(REPO_ROOT, file))); } } else { - expanded.add(pattern); + expanded.add(path.relative(REPO_ROOT, path.resolve(REPO_ROOT, pattern))); } } diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 6fc8c90f87..e511a7e12f 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -64,6 +64,7 @@ The generated project contains: ```text my-plugin/ paseo-plugin.json + OVERVIEW.md package.json tsconfig.json index.client.tsx @@ -638,6 +639,47 @@ Do not edit a local config when the target is a remote daemon. Perform the edit When the same screen or sidebar item exists on several connected hosts, Paseo shows it once. The screen header has a host picker; a sidebar item uses the host of the screen on display, else the host last picked in one of the plugin's screens. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host. +## Publishing a listing + +Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo. +It helps someone decide whether to install your plugin; the install command is already at +the top of that page. A README assumes a GitHub audience and carries installation +instructions, technical details, and badges. Long, AI-generated READMEs make people read +past that material to understand what a plugin does. + +`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside +`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it +relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm +package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment +with useful facts before publishing. + +The repository overview takes precedence over a registry import stopgap. Online validation +fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records +that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump +requires a repository overview and removes the stopgap in the same PR. A bump without the +repository overview fails validation. README files do not supply the overview. + +Author overviews and registry stopgaps follow the same content contract, in this order. +Choose headings only when they help; length follows complexity. A theme needs one paragraph. + +1. Explain what the plugin is and does in plain terms first. +2. Explain how it works only when that is not obvious. +3. Explain setup when needed: settings, accounts, tokens, providers, external tools, or other + plugins. Include applicable daemon version and operating system requirements. Setup + guidance is allowed; installation instructions are not. +4. Explain capabilities and settings worth understanding, what each option does, what the + plugin reads or sends and where, permissions, and known limits. + +Use sentence case and plain factual language, with no em dashes. Omit installation commands, +badges, changelog, contributing or license sections, marketing, and unsupported claims. +Avoid implementation filler such as empty cleanup functions, catalogs of theme-token fields, +or lists of absent features. Keep only what helps someone choose the plugin. + +If your plugin is an unchanged imported record, you can propose an author-written overview +to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`. + +See [Your listing page](https://paseo.sh/docs/plugins/publishing.md#your-listing-page). + ## Typecheck and manage When editing a plugin, typecheck its source before install or reload: diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 60ca70057a..11b5f2ff97 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -23,9 +23,11 @@ For a local daemon, `project create` defaults to the current directory and resol **`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`. +`background: true` hides a workspace from default lists and the sidebar while its contents remain durable and accessible; omit `background` to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks. + Choose `baseBranch` explicitly: `origin/main` selects the remote-tracking branch; `refs/heads/main` selects local main. Bare `main` prefers local main when it exists, otherwise origin/main. Paseo retains the resolved ref for workspace comparisons, even after rebasing the branch or changing its PR target. -**`list_workspaces`** — list active workspaces. +**`list_workspaces`** — list active workspaces, excluding background workspaces unless `includeBackground: true`. **`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived. @@ -61,11 +63,11 @@ Detach is an explicit user action in the subagents track, not an agent tool. A c Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents. -**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Agent-scoped prompt calls default to `background: true` and `notifyOnFinish: true`; top-level calls default to blocking with no callback. For a synchronous follow-up, pass `background: false` and use the returned result. +**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback. **`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`. -**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`. +**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`; set `includeBackground: true` to include agents in background workspaces. **`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list. @@ -106,9 +108,11 @@ Schedules have the full list/inspect/update/pause/resume/run-once/log/delete sur Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows. -For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own. +`create_agent` and `send_agent_prompt` return after startup or dispatch acknowledgement in both MCP scopes. Top-level callers have no parent callback; observe completion through status/activity tools or SDK/RPC subscriptions and wait APIs. -Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you. +For agent-scoped `create_agent` and `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own. + +With finish notifications enabled, don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you. ## CLI semantics diff --git a/vitest.config.ts b/vitest.config.ts index 77e75f6d6c..47bd965113 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import fs from "node:fs"; import path from "node:path"; import { configDefaults, defineConfig } from "vitest/config"; +// React's test APIs require its development build, even in a production shell. +process.env.NODE_ENV = "test"; + const appDir = path.resolve(__dirname, "packages/app"); const appNodeModules = path.resolve(appDir, "node_modules"); const rootNodeModules = path.resolve(__dirname, "node_modules");