diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index cd20556182..f99dabaea9 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -7,6 +7,7 @@ on: - "CHANGELOG.md" - "public-docs/**" - "packages/website/**" + - "packages/protocol/**" - "package.json" - "package-lock.json" - "patches/**" @@ -29,7 +30,10 @@ jobs: cache: "npm" - name: Install dependencies - run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root + run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root + + - name: Build protocol + run: npm run build:protocol - name: Typecheck run: npm run typecheck --workspace=@getpaseo/website 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..cbd1d0551d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,63 +1,93 @@ # Changelog -## 0.11.0-beta.3 - 2026-10-02 +## 0.11.4 - 2026-10-15 -### 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)) +[Hoplite](https://hoplite.sh) joins Paseo as our first sponsor through the company sponsorship program. Hoplite builds cloud coding agents that turn tasks from GitHub, Linear, Slack and more into pull requests. Thank you to the Hoplite team for supporting Paseo. ### Fixed -- Fixed 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 Hoplite showing an error in Settings → Providers instead of discovering models in workspaces linked to a Hoplite project -## 0.11.0-beta.2 - 2026-10-01 +## 0.11.3 - 2026-10-15 ### 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)) +- Added Hoplite as an opt-in provider in the ACP provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953) by [@odinglyn0](https://github.com/odinglyn0)) +- Added search to the Add provider catalog ([#6953](https://github.com/getpaseo/paseo/pull/6953), [#7030](https://github.com/getpaseo/paseo/pull/7030)) ### Changed -- Changed 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)) +- Changed Add provider failures to show as a toast ([#7030](https://github.com/getpaseo/paseo/pull/7030)) ### 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 Remove being offered for plugin providers such as Antigravity and Muse Code ([#6568](https://github.com/getpaseo/paseo/pull/6568) by [@Ctrl-Creeper](https://github.com/Ctrl-Creeper)) -## 0.11.0-beta.1 - 2026-10-01 +## 0.11.2 - 2026-10-09 + +### Fixed + +- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445)) + +## 0.11.1 - 2026-10-07 + +### Added + +- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332)) + +### Fixed + +- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308)) +- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316)) +- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301)) + +## 0.11.0 - 2026-10-07 + +Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry). + +**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory. ### Added - Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95)) -- Added Antigravity as a provider, 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 +95,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 +143,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 e1ac653338..7076471ff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof | | [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | | [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints | -| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences | +| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar, side-pane, and compact workspace sheet host contracts, lifecycle, placement, routing preferences | | [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | | [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha | | [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata | diff --git a/README.ja.md b/README.ja.md index c2d1ad88a0..5d1c8f5ebb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -53,6 +53,17 @@ - **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。 - **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。 +## スポンサー + + + + + + + + +
Hoplite
Cloud coding agents
Paseo のスポンサーになるPaseo のスポンサーになる
+ ## はじめかた Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。 @@ -151,11 +162,9 @@ npm run build:server npm run typecheck ``` -## スポンサー +## Paseo を支援する -Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。 - - +Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。 ## 関連プロジェクト diff --git a/README.ko.md b/README.ko.md index 60581767cd..7f2098221d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -47,6 +47,17 @@ - **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다. - **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다. +## 스폰서 + + + + + + + + +
Hoplite
Cloud coding agents
Paseo 스폰서 되기Paseo 스폰서 되기
+ ## 시작하기 Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다. @@ -161,11 +172,9 @@ npm run build:server npm run typecheck ``` -## 스폰서 +## Paseo 후원하기 -Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다. - - +Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다. ## 관련 프로젝트 diff --git a/README.md b/README.md index aa0a841e7e..1f6df3c3e4 100644 --- a/README.md +++ b/README.md @@ -39,21 +39,44 @@ Paseo mobile app

-Run agents in parallel on your own machines. Ship from your phone or your desk. +Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android. +- **Parallel agents:** Run many agents at once, each in its own worktree. +- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers. +- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. - **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job. - **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it. - **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal. - **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins. +[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser. + +## Sponsors + + + + + + + + +
Hoplite
Cloud coding agents
Become a sponsorBecome a sponsor
+ ## Plugins -Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted -TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install `. +Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, +iOS, and Android. Write a plugin once and it is on your phone. -Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon -machine and inside connected clients; install only code you trust. +- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes. +- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends. +- **Providers:** add a coding agent as a provider. + +Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory. + +**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)** + +Plugins run with access to your daemon machine and inside connected clients; install only code you trust. ## Getting Started @@ -77,16 +100,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r To connect from your phone, open **Settings → your host → Pair Device**. -### CLI / headless +### Server -Install the CLI and start Paseo: +For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon: ```bash npm install -g @getpaseo/cli paseo ``` -Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines. +Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host. For full setup and configuration, see: @@ -195,11 +218,9 @@ npm run build:server npm run typecheck ``` -## Sponsors +## Support Paseo -Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. - - +Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs. ## Related projects diff --git a/README.zh-CN.md b/README.zh-CN.md index dfdf673b2e..745071f0fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -53,6 +53,17 @@ - **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。 - **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。 +## 赞助商 + + + + + + + + +
Hoplite
Cloud coding agents
成为 Paseo 赞助商成为 Paseo 赞助商
+ ## 快速开始 Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。 @@ -151,11 +162,9 @@ npm run build:server npm run typecheck ``` -## 赞助 +## 支持 Paseo -Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。 - - +Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。 ## 相关项目 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 8662ee3287..378a0070ea 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -69,11 +69,11 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a normal root agent immediately, and closing its still-open tab archives it. -Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace. +Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested. Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. -`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. +`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown. @@ -84,6 +84,32 @@ Some providers can create their own child sessions inside one provider runtime. The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle. +## Background workspaces + +`workspace.background` controls discovery only. Background workspaces contain ordinary durable +agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery +keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot +change it. New workspaces default to false, inherit the caller workspace's value when an agent +creates one without an override, and accept explicit true or false. + +The daemon derives agent discovery from workspace ownership. Default workspace and agent lists, +including History requests, exclude background work; `filter.includeBackground` includes it. +Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only +its sidebar with **Show background**, so direct navigation and restored tabs remain available. + +Sequenced directories keep complete canonical collections. A filtered read must never replace +those collections with its subset. Inclusive and default observations have distinct cursor +scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache. +Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`. + +### Private daemon helpers + +Branch-name and commit-message generators still use private disposable internal runtimes. +`AgentManager` does not persist them or their provider sessions, excludes them from public +discovery and plugin hooks, and drops their timelines on archive. An archived helper retains +its exact-ID result briefly to cover completion before a waiter attaches. This internal +lifecycle is separate from public workspace visibility and is not a public creation option. + ## Archive Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client. @@ -125,11 +151,12 @@ lifecycle queue. Resume chooses its history or interactive purpose from the dura entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed close retains the runtime for cleanup and blocks replacement through that close operation. -Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that -purpose initializes a temporary app-server, reads the persisted thread and child histories, and -releases the process before returning. It never loads, resumes, or unarchives a native thread, -including legacy records whose native archive failed. The retained history session contains only -the read results. Interactive resume remains responsible for repairing a provider session archived +Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained +history session contains only the read results and must release its provider process once replay +finishes. Codex reads the persisted thread and child histories on a temporary app-server and never +loads, resumes, or unarchives a native thread, including legacy records whose native archive failed. +ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process +before returning. Interactive resume remains responsible for repairing a provider session archived outside Paseo while its Paseo agent is active. Provider session connection owns every process it spawns until the session is registered with @@ -185,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt - **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. -Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. +Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. @@ -197,10 +224,11 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta - **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent. - **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table. -- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`. +- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`. - **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline. - **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`. - **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them. +- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one. - **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent. - **On replay, `/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work. - **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path. diff --git a/docs/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/custom-providers.md b/docs/custom-providers.md index efc5fdf24f..a6c6cefbdc 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -484,7 +484,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout. -Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below. +Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Most catalog entries create the same `extends: "acp"` provider config shown below. Hoplite activates its [bundled provider](plugins.md#built-in-plugins) instead. ### Adding a generic ACP provider diff --git a/docs/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 2ecf842671..c9a89f8a58 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -7,6 +7,7 @@ contracts. | ---------------- | ---------------------------- | ------------------------------------------ | | Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock | | Side pane | Ordinary workspace content | Created and closed like any workspace pane | +| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack | ## Panel host contract @@ -15,11 +16,43 @@ fixed-target labels and icons from that registration, filter by host, and never panel type for another. Tab moves reject unsupported destinations, and placement resolves only to a compatible pane. -Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible -tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes. +Files and Changes are the Explorer defaults. Their panel manifests mark them as singletons, +so a pane’s + menu omits each while that pane already contains it. Closing one makes its menu +item available again. Other compatible tabs, including agents, terminals, files, and diffs, +can move between Explorer and main panes. Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts. +## Sheets + +On compact layouts, files and subagents open in a sheet over the workspace, so the tab switcher +keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest +(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between +sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one +ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every +other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a +subagent chosen from the subagents list included, lands on top of it, and closing the top sheet +(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the +sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract. + +The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet +remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the +ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab +dismisses the sheets above it, since the tab would sit under them. The stack is not persisted. + +Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag +handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail +holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside +Close, so the title keeps its width. The sheets present +from inside the compact column's `SheetHostScope`, so their panels read the navigator and every +workspace provider an ordinary pane has without a context bridge +([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)). + +A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it. +Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the +mounted view, so the move would drop it. Widening the layout is the known exception: it still moves +the sheet's tab into a pane, and edits the autosave has not started writing are lost. + ## Explorer sidebar `packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection. @@ -41,11 +74,13 @@ The persisted layout still contains the Explorer pane so tabs survive reloads. T that pane from the workspace split tree and docks it separately. Persisted identifiers retain the literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility. -The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and -toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared -launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs -can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace -focus. +Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes +hide their close buttons through the panel manifest; close them from the tab context menu. +Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and +omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer. +Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged +between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab +does not change workspace focus. Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders. @@ -58,7 +93,9 @@ lifecycle. `packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The layout store remembers one ordinary pane per workspace. The first side open creates a full-height -right split around the workspace root; later side opens reuse it. +right split around the workspace root; later side opens reuse it. Only the `split-panes` layout +mode opens beside. Compact and single-pane layouts (touch tablets) open the same target in the main +pane, so callers never check whether splits render. Removing a pane clears its remembered id; a later side open creates a new pane. The last visible ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 9c4e793fb8..1af71ac4c9 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 @@ -302,9 +343,20 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked another pushed sheet. Close React state from `onDismiss`; use `onChange` only to track phase. -All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This -includes native centered dialogs: separate React Native Modals cannot present a root-owned -sibling dialog over an existing iOS presenter. +All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no +exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on +close. This includes native centered dialogs: separate React Native Modals cannot present a +root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree +(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their +panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the +portal host and container measurement while the sheet still joins the same stack. That export is +what `patches/@gorhom+bottom-sheet+*.patch` adds. + +A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every +close path then parks the sheet at index -1 with its content mounted; the owner hears +`onChange(-1)`, runs its guard, and either snaps back to 0 or drops its state, which unmounts the +sheet and takes it off the stack. Calling `dismiss()` on such a sheet bypasses the guard and +unmounts it, so its backdrop and Back press call `close()`. The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary but stores it in the modal queue and later reads `.current`; provider dismissal, replacement, @@ -314,9 +366,10 @@ forwards commands to the current Gorhom handle. Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web, Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop -keeps its press target until portal teardown and asks the provider to dismiss the top modal. -Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native -retains Gorhom's gesture backdrop and the same push/replace presentation semantics. +keeps its press target until portal teardown and asks the provider to dismiss the top modal +(a guarded sheet closes itself instead, see above). Gorhom's presentation queue remains the +authority; backdrop mount order is irrelevant. Native retains Gorhom's gesture backdrop and the +same push/replace presentation semantics. ## Gotcha 7 — A sheet cannot read context from its call site diff --git a/docs/glossary.md b/docs/glossary.md index 67730885fb..644c109d44 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -21,6 +21,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. - **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. - **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md). +- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md). +- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets). - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). @@ -33,8 +35,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). -- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`). -- **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). 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..9dc3e26cae 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -52,6 +52,11 @@ is another, with its own icons and hover-revealed actions — but both hand the and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press outright leaves the surface open behind whatever it opened, and skips the iOS wait. +A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu +sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab +closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout +store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout. + ## Pages A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id` @@ -193,11 +198,14 @@ its own. backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes. - **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth). +- **A menu closes when its panel leaves the screen.** `MenuSurface` closes the menu when its + retained panel goes inactive. Hiding the surface alone leaves the open state behind, and the + menu would reappear with the panel on the next tab switch. - **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes inactive. An async action can navigate before its menu closes; a hidden panel must not leave a portal backdrop blocking the destination. On web the chat suspends one commit after it goes 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/mobile-panels.md b/docs/mobile-panels.md index 4270ddb108..6bd528e3e8 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -32,8 +32,10 @@ The UI worklet owns transient motion: React publishes the active panel only when the canonical target and the UI-thread position agree at the final anchor. Retained content never observes gesture previews, progress, or an unsettled target. -The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while -React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists +The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while +React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing +their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or +release. Native panel hosts and their dependent draggable lists also retain identity across appearance hydration and settings changes. Do not wrap those hosts in appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences). diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 8ed61a8630..93fa35aedd 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -65,6 +65,26 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in the headless input helper cannot verify the [visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors). +The Android sidebar scroll-retention flow requires a connected host with enough +workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and +`SCROLLED_WORKSPACE` as the `serverId:workspaceId` suffixes of their sidebar row +IDs. Choose one row near the top and one initially below the viewport. Replace +the quoted example values below with those IDs. Start in a workspace with the +sidebar closed and keep the list stable during the run. The same flow checks +reopening by swipe and button: + +```bash +agent-device replay packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml \ + --maestro --platform android --serial emulator-5556 --metro-port 8099 \ + --env TOP_WORKSPACE='server-id:top-workspace-id' \ + --env SCROLLED_WORKSPACE='server-id:scrolled-workspace-id' +``` + +Pass this checkout's Metro port so the debug app uses the intended bundle. +For iOS, compare the scrolled row's screen bounds and screenshots across reopening. +Agent Device's iOS visibility lookup includes offscreen scroll descendants, so +its `assertNotVisible` cannot establish the starting scroll position for this flow. + On a Play-image emulator, disable Gmail and Calendar first. Their welcome screens launch on their own, take input focus, and the run fails with `IME did not become visible` while the app still looks focused: diff --git a/docs/plugins.md b/docs/plugins.md index ab12fe7dd2..4e1985ea81 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -107,6 +107,12 @@ absent from the installed plugin list and source configuration; their client bun appear in the plugin catalog. Editing one in development requires a daemon restart. Directory, Git, and npm installs cannot use a built-in ID. +A provider registration with `requiresConfiguration: true` participates only while its ID exists +under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }` +without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting +`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent +provider does not enter executable resolution or catalog discovery. + Provider plugins use separate installation and provider IDs. `muse-provider` registers the selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports availability and a diagnostic after the daemon resolves the executable. Configure command, @@ -115,33 +121,53 @@ the bundled integration; an entry with `extends` shadows it with a custom provid [provider contributions](#contribute-a-provider) for the contract and [Muse Code](../public-docs/muse-code.md) for setup, per-agent options, and version limitations. +## Install from a registry + +`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 +`pluginRegistries: { "host": { "authorization": "Bearer token" } }`. +Restart your daemon after changing these startup settings. Credentials go only to the registry, +never artifact hosts or redirects. Git/npm use their own host authentication. + +The [open registry protocol](https://github.com/getpaseo/plugins/blob/main/PROTOCOL.md) +owns static hosting, record shapes, pins, and advisory install counts. + ## Install a Git source -GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing -directory always wins over shorthand resolution. +GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git +URL. ```bash -paseo plugin add owner/repository -paseo plugin add https://gitlab.com/group/repository.git -paseo plugin add https://git.example.com/owner/repository.git -paseo plugin add owner/monorepo:plugins/review -paseo plugin add owner/repository --ref main -paseo plugin ls -paseo plugin update review -paseo plugin update --all +paseo plugin install git:owner/repository +paseo plugin install https://gitlab.com/group/repository.git +paseo plugin install git:owner/monorepo:plugins/review +paseo plugin install github:owner/repository --ref main ``` -Append `:relative/path` to the source when the plugin lives below the repository root. - -`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's -current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote. -Removing a Git source deletes Paseo's managed checkout. +Append `:relative/path` for a plugin below the repository root. `--ref` chooses the initial +branch, tag, or commit once. Updates of explicit Git sources resolve the remote's default HEAD +and ask for approval. `ls` reports the installed commit without contacting the remote. ## 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 @@ -233,7 +259,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 | @@ -473,9 +499,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. Keep discovery independent of agent sessions and provider names: a harness can use a subscription through a proxy or renamed provider. 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 47c39fd1a8..298bee651b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -10,7 +10,9 @@ Keep a bundled provider in `plugins//` and register it through 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. @@ -59,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. @@ -101,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/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/rpc-namespacing.md b/docs/rpc-namespacing.md index 5ddb7220e4..627a227eb5 100644 --- a/docs/rpc-namespacing.md +++ b/docs/rpc-namespacing.md @@ -25,7 +25,13 @@ checkout.forge.set_auto_merge.request; // -> checkout.forge.set_auto_merge.response ``` -Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema. +A finite streaming request can emit `.update` messages before its terminal `.response`. +`usage.list_reports.request` uses this shape: each `.update` carries one settled report, and +`.response` carries the request-level error or `null`. All three share `requestId`; no updates +follow the response. An empty successful stream is an empty list. Register updates as replies +of the originating request so source-owned delivery routes them to the requesting connection. + +Most new RPCs should follow these shapes. If a request does not have a one-to-one terminal response, call that out in the code near the schema. ## Message Shape 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/terminal-activity.md b/docs/terminal-activity.md index 42826e5f49..ee1f411a7a 100644 --- a/docs/terminal-activity.md +++ b/docs/terminal-activity.md @@ -51,7 +51,7 @@ Claude hook mapping: - `UserPromptSubmit` → `running` - `Stop`, `StopFailure`, `SessionEnd` → `idle` -- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input` +- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input` Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input while terminal activity is working clears the activity without finished attention. The same 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 f42dbcdbf3..8906abc66b 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-L1gb8LCZKPnK6T9XabQ6ctrvyUJnM+TPUCAayssVPMg= +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 8a71d40f8c..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": [ @@ -26270,17 +26270,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/lucide-react-native": { - "version": "0.546.0", - "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz", - "integrity": "sha512-v/qe5JnOHKIhccqfE7Ln78wd0mn5YgmCBvkTX2ca8sWeoym3ZYZcY/mapFsxssarutO4bMI/bRtTqiusGbpOUw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-native": "*", - "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" - } - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -36424,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", @@ -36489,7 +36478,7 @@ "fast-deep-equal": "^3.1.3", "htmlparser2": "^12.0.0", "i18next": "^26.3.0", - "lucide-react-native": "^0.546.0", + "lucide-react-native": "^1.50.0", "markdown-it": "^10.0.0", "mermaid": "^11.16.0", "mnemonic-id": "^3.2.7", @@ -37344,6 +37333,23 @@ "node": ">=20.19.0" } }, + "packages/app/node_modules/lucide-react-native": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.50.0.tgz", + "integrity": "sha512-uMtIW3I6jfHbUp2FnUwxbDJc7XFJDz2g5YVcw6T/De8Sp9f21vTO7HlJNhxpBynDYlqLyWHaXeR0dBSiw6TqOw==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-native": "*", + "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "packages/app/node_modules/miniflare": { "version": "4.20260625.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz", @@ -37490,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", @@ -37742,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": { @@ -37756,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": "*", @@ -38000,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", @@ -38896,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", @@ -38925,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", @@ -38942,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" @@ -38965,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" @@ -38992,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", @@ -39210,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", @@ -40140,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", @@ -40167,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", @@ -41746,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..ceca7489e3 100644 --- a/packages/app/e2e/browser/acp-provider-catalog.spec.ts +++ b/packages/app/e2e/browser/acp-provider-catalog.spec.ts @@ -1,13 +1,24 @@ -import { test } from "../support/fixtures"; +import { + closeModelPicker, + openModelPicker, + seedAgentProfiles, +} from "../support/helpers/agent-profiles"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { writeFile } from "node:fs/promises"; +import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import { getServerId } from "../support/helpers/server-id"; import { expectProviderInstalledInSettings, + findAcpCatalogProvider, installAcpCatalogProvider, openAddProviderArea, openSettingsHost, openSettingsHostSection, + seedSavedSettingsHosts, } from "../support/helpers/settings"; const ACP_PROVIDER = { @@ -16,8 +27,13 @@ const ACP_PROVIDER = { }; interface ProviderCatalogDaemonClient { + getLastServerInfoMessage(): { + features?: { hopliteProvider?: boolean; plugins?: boolean }; + } | null; connect(): Promise; close(): Promise; + getDaemonConfig(): Promise<{ config: { providers?: Record } }>; + getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string }> }>; patchDaemonConfig(config: { removeProviders?: string[] }): Promise; } @@ -41,3 +57,124 @@ test.describe("ACP provider catalog", () => { } }); }); + +for (const theme of ["light", "dark"] as const) { + test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-catalog-e2e", + }); + try { + expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(true); + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }); + expect( + (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"), + ).toBe(false); + await page.addInitScript((value) => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value })); + }, theme); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await expect( + page.getByRole("button", { name: "Hoplite provider details", exact: true }), + ).toHaveCount(0); + await openAddProviderArea(page); + await findAcpCatalogProvider(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) }); + await installAcpCatalogProvider(page, "Hoplite"); + await expectProviderInstalledInSettings(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) }); + expect((await client.getDaemonConfig()).config.providers?.hoplite).toEqual({ enabled: true }); + await writeFile( + testInfo.outputPath(`hoplite-added-${theme}.json`), + JSON.stringify(await client.getProvidersSnapshot(), null, 2), + ); + await removeHoplite(page); + await expect( + page.getByRole("button", { name: "Hoplite provider details", exact: true }), + ).toHaveCount(0); + expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined(); + expect( + (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"), + ).toBe(false); + await writeFile( + testInfo.outputPath(`hoplite-removed-${theme}.json`), + JSON.stringify(await client.getProvidersSnapshot(), null, 2), + ); + await openAddProviderArea(page); + await findAcpCatalogProvider(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) }); + await installAcpCatalogProvider(page, "Hoplite"); + await expectProviderInstalledInSettings(page, "Hoplite"); + } finally { + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined); + await client.close().catch(() => undefined); + } + }); +} + +async function removeHoplite(page: import("@playwright/test").Page): Promise { + await page.getByTestId("provider-actions-hoplite").click(); + page.once("dialog", (dialog) => { + void dialog.accept(); + }); + await page.getByTestId("provider-remove-hoplite").click(); +} + +test("unsupported host asks for an update without writing Hoplite config", async ({ + page, + outdatedDaemon, +}, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-unsupported", + port: Number(new URL(`http://${outdatedDaemon.endpoint}`).port), + }); + try { + expect(client.getLastServerInfoMessage()?.features?.plugins).toBe(true); + expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(false); + const before = await client.getDaemonConfig(); + await seedSavedSettingsHosts(page, [outdatedDaemon]); + await page.reload(); + await openSettings(page); + await openSettingsHost(page, outdatedDaemon.serverId); + await openSettingsHostSection(page, outdatedDaemon.serverId, "providers"); + await openAddProviderArea(page); + await page.getByRole("button", { name: "Add Hoplite", exact: true }).click(); + const errorToast = page.getByTestId("app-toast-message"); + await expect(errorToast).toHaveText("Update the host to add Hoplite."); + await expect(errorToast).toBeInViewport(); + expect((await client.getDaemonConfig()).config).toEqual(before.config); + await expect( + page.getByRole("button", { name: "Hoplite provider details", exact: true }), + ).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath("hoplite-update-host.png") }); + } finally { + await client.close(); + } +}); + +test("model selector excludes Hoplite before opt-in", async ({ page }, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-picker", + }); + const profiles = await seedAgentProfiles([ + { id: "hoplite-picker-anchor", name: "Picker anchor", provider: "mock" }, + ]); + const workspace = await seedWorkspace({ repoPrefix: "hoplite-picker-" }); + try { + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await openModelPicker(page); + await expect(page.getByTestId("model-provider-mock")).toBeVisible(); + await expect(page.getByTestId("model-provider-hoplite")).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath("hoplite-model-selector-absent.png") }); + await closeModelPicker(page); + } finally { + await workspace.cleanup(); + await profiles.restore(); + await client.close(); + } +}); diff --git a/packages/app/e2e/browser/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts index 027d6156e7..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/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts new file mode 100644 index 0000000000..73c662124f --- /dev/null +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -0,0 +1,204 @@ +import { test } from "../support/fixtures"; +import { expect } from "@playwright/test"; +import { + findScrollJumps, + expectImageSpaceReserved, + type ScrollFrame, + observeTimelinePages, + openOnlyTimelineTail, + recordUpwardTraversal, + reportScrollJumps, + scrollCadences, + withVariedTimeline, +} from "../support/helpers/timeline-scroll-smoothness"; + +test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => { + const before: ScrollFrame = { + at: 1000, + scrollTop: 1000, + scrollHeight: 5000, + viewportHeight: 800, + virtualized: true, + loading: false, + rows: [ + { id: "image", top: -150, height: 150 }, + { id: "reading", top: 0, height: 100 }, + ], + anchor: "reading", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 0, + unmounted: 0, + }; + const after: ScrollFrame = { + ...before, + at: 1400, + scrollTop: 900, + wheelTotal: 100, + lastWheelAt: 1050, + rows: [ + { id: "image", top: -50, height: 1150 }, + { id: "reading", top: 1100, height: 100 }, + ], + }; + expect(findScrollJumps([before, after])).toEqual([]); + expect( + findScrollJumps([ + { ...before, scrollTop: 200 }, + { + ...after, + wheelTotal: 1000, + scrollTop: 0, + rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })), + }, + ]), + ).toEqual([]); + expect( + findScrollJumps([ + before, + { ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) }, + ]), + ).toHaveLength(1); + expect( + findScrollJumps([ + { ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] }, + { ...after, rows: [{ id: "image", top: 600, height: 1150 }] }, + ]), + ).toHaveLength(1); +}); + +test("scroll detector accounts for delayed wheel input and growth below the new reader", () => { + const before: ScrollFrame = { + at: 1000, + scrollTop: 2000, + scrollHeight: 5000, + viewportHeight: 800, + virtualized: true, + loading: false, + rows: [ + { id: "new-reader", top: -520, height: 278 }, + { id: "image", top: -242, height: 184 }, + { id: "old-reader", top: -58, height: 130 }, + ], + anchor: "old-reader", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 0, + unmounted: 0, + }; + const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 }; + const after: ScrollFrame = { + ...wheel, + at: 1220, + scrollTop: 1520, + scrollHeight: 5041, + anchor: "new-reader", + rows: [ + { id: "new-reader", top: -40, height: 278 }, + { id: "image", top: 238, height: 225 }, + { id: "old-reader", top: 463, height: 130 }, + ], + }; + expect(findScrollJumps([before, wheel, after])).toEqual([]); + // The previous 480 px input has already moved the viewport; the recent + // budget includes it, but only the next 480 px belongs to this frame. + const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 }; + const steadyAfter = { + ...after, + at: 1100, + wheelTotal: 960, + lastWheelAt: 1099, + scrollHeight: 6000, + rows: [ + { id: "new-reader", top: -40, height: 278 }, + { id: "image", top: 238, height: 1184 }, + { id: "old-reader", top: 1422, height: 130 }, + ], + }; + expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]); + + expect( + findScrollJumps([ + before, + wheel, + { + ...after, + rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })), + }, + ]), + ).toHaveLength(1); +}); + +test("scroll detector follows the entered image through resize and virtual offset correction", () => { + for (const height of [225, 1632]) { + const before: ScrollFrame = { + at: 1000, + scrollTop: 2208, + scrollHeight: 5000, + viewportHeight: 516, + virtualized: true, + loading: false, + rows: [ + { id: "image", top: -704, height: 560 }, + { id: "caption", top: -144, height: 100 }, + { id: "old-reader", top: -44, height: 127 }, + ], + anchor: "old-reader", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 3, + unmounted: 0, + }; + const resize = height - 560; + const after: ScrollFrame = { + ...before, + at: 1060, + // The virtualizer corrects 110px of estimates while the wheel moves 160px. + scrollTop: 1938, + scrollHeight: 5000 + resize - 110, + wheelTotal: 160, + lastWheelAt: 1020, + imageLoads: 1, + rows: [ + { id: "image", top: -544, height }, + { id: "caption", top: 16 + resize, height: 100 }, + { id: "old-reader", top: 116 + resize, height: 127 }, + ], + }; + expect(findScrollJumps([before, after])).toEqual([]); + // Resizing must not excuse additional viewport movement. + expect( + findScrollJumps([ + before, + { + ...after, + rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })), + }, + ]), + ).toHaveLength(1); + } +}); + +for (const cadence of scrollCadences) { + test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + await withVariedTimeline(async (agent, newestPrompt) => { + const pages = observeTimelinePages(page, agent.agentId); + await openOnlyTimelineTail(page, agent, newestPrompt, pages); + const frames = await recordUpwardTraversal(page, cadence, testInfo); + await reportScrollJumps(page, testInfo, frames, pages); + }); + }); +} + +test("reserves image space before its response arrives", async ({ page }, testInfo) => { + await expectImageSpaceReserved(page, testInfo); +}); 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/changes-shortcut-placement.spec.ts b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts index a27880a111..74e6971c8d 100644 --- a/packages/app/e2e/browser/changes-shortcut-placement.spec.ts +++ b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts @@ -17,7 +17,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) = await page.keyboard.press(CHANGES_SHORTCUT); const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); - await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({ + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible({ timeout: 30_000, }); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); 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..77a2a0882b --- /dev/null +++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts @@ -0,0 +1,154 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test } from "../support/fixtures"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + expectSubagentRowVisible, + openSubagentFromCompactTrack, + seedSubagent, +} from "../support/helpers/subagents"; +import { + closeSheet, + expectOpenTabCount, + openSheetAsTab, + expectDeclinedDiscardPrompt, + expectSheetAtSnap, + replaceSheetFileText, + selectTabFromSwitcher, + sheet, + sheetHeader, + tapSheetBackdrop, +} from "../support/helpers/sheet"; + +const FILE_LINK = "linked.ts:3"; + +function promptMentioning(target: string): string { + return [ + "Generate a title and a git branch name for a coding agent from the user prompt and attachments.", + "Return JSON only with fields 'title' and 'branch'.", + "", + "", + `Open \`${target}\` now`, + "", + ].join("\n"); +} + +test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "compact-workspace-sheet-", + title: "Sheet parent", + initialPrompt: promptMentioning(FILE_LINK), + }); + try { + const gate = await installDaemonWebSocketGate(page); + const linkedPath = path.join(workspace.cwd, "linked.ts"); + await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8"); + const child = await seedSubagent( + { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId }, + { parentId: workspace.agentId, title: "Sheet child" }, + ); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId }); + await expectOpenTabCount(page, 1); + + await test.step("a chat file link opens the file in a sheet", async () => { + const fileLink = page.getByText(FILE_LINK, { exact: true }); + await expect(fileLink).toBeVisible({ timeout: 15_000 }); + await fileLink.click(); + + const fileSheet = sheet(page, "linked.ts"); + await expect(fileSheet).toBeVisible(); + await expect(fileSheet.getByText("three")).toBeVisible(); + await expectSheetAtSnap(page, "linked.ts"); + await expect( + sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }), + ).toHaveCount(0); + await expectOpenTabCount(page, 1); + await closeSheet(page, "linked.ts"); + }); + + await test.step("a subagent opens in a sheet over the subagents list", async () => { + await openSubagentFromCompactTrack(page, child.id); + + const childSheet = sheet(page, child.title); + await expect(childSheet).toBeVisible(); + await expectSheetAtSnap(page, child.title); + await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0); + // The composer's model, thinking, and mode stand in for it, resolved the same way. An idle + // seeded agent has no context usage yet. + await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText( + "Ten second stream · Low · Load test", + ); + await expectOpenTabCount(page, 1); + }); + + await test.step("closing the subagent's sheet uncovers the list", async () => { + await closeSheet(page, child.title); + await expectSubagentRowVisible(page, child.id); + }); + + await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => { + await page.getByTestId(`subagents-track-row-${child.id}`).click(); + await expectSheetAtSnap(page, child.title); + await openSheetAsTab(page, child.title); + await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0); + await expectOpenTabCount(page, 2); + await expectComposerVisible(page); + }); + + await test.step("the list the subagent was opened from closed with its tab", async () => { + await selectTabFromSwitcher(page, "Sheet parent"); + await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0); + }); + + await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => { + await page + .getByRole("button", { name: /Switch tabs/ }) + .filter({ visible: true }) + .click(); + await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible(); + const menuBase = `workspace-tab-menu-agent_${child.id}`; + await page.getByTestId(`${menuBase}-trigger`).click(); + await page.getByTestId(`${menuBase}-close`).click(); + await expectOpenTabCount(page, 1); + // Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is + // tapped once the row menu's own sheet has gone. + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1); + await tapSheetBackdrop(page); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + + await openSubagentFromCompactTrack(page, child.id); + await expectSheetAtSnap(page, child.title); + await closeSheet(page, child.title); + await expectSubagentRowVisible(page, child.id); + await tapSheetBackdrop(page); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + }); + + await test.step("a sheet with unsaved edits closes only through the guarded close", async () => { + await page.getByText(FILE_LINK, { exact: true }).click(); + const fileSheet = sheet(page, "linked.ts"); + await expect(fileSheet).toBeVisible(); + // Hold the autosave so the edit stays unsaved. + gate.holdNextClientRequest("fs.file.write.request"); + await replaceSheetFileText(page, "linked.ts", "edited in the sheet"); + await gate.waitForHeldClientRequest(); + + await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page)); + await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet"); + await expectDeclinedDiscardPrompt(page, () => + sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(), + ); + await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet"); + + gate.releaseHeldClientRequest(); + await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet"); + }); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index d30e69b18c..d83046d8b2 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -9,7 +9,12 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; -import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar"; +import { + closeMobileAgentSidebar, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + openMobileAgentSidebar, +} from "../support/helpers/sidebar"; import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace"; const TEST_COMMANDS = [ @@ -96,16 +101,6 @@ interface PopoverFrameRecorderWindow extends Window { __stopComposerAutocompleteFrameRecorder?: () => void; } -async function getTopTestIdAtPoint(page: Page, x: number, y: number) { - return page.evaluate( - ([pointX, pointY]) => { - const element = document.elementFromPoint(pointX, pointY); - return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null; - }, - [x, y], - ); -} - async function installListCommandsStub(page: Page): Promise { await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); @@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => { timeout: 30_000, }); - await page.getByRole("button", { name: "Open menu" }).click(); + // The sidebar covers the composer, so the composer gives up its popover while the + // sidebar is open and brings it back once the sidebar closes. + await openMobileAgentSidebar(page); await expectMobileAgentSidebarVisible(page); + await expect(popover).toHaveCount(0); - const popoverBox = await popover.boundingBox(); - expect(popoverBox).not.toBeNull(); - - const topTestId = await getTopTestIdAtPoint( - page, - popoverBox!.x + popoverBox!.width / 2, - popoverBox!.y + popoverBox!.height / 2, - ); - - expect(topTestId).not.toBe("composer-autocomplete-popover"); + await closeMobileAgentSidebar(page); + await expectMobileAgentSidebarHidden(page); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible(); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 455c58180a..00705faccd 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -1,7 +1,14 @@ +import type { Locator } from "@playwright/test"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { gateNextAgentMessage } from "../support/helpers/agent-message-gate"; +import { + fillComposerDraft, + expectComposerDraft, + attachFileFromMenu, +} from "../support/helpers/composer"; import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs"; const APP_SETTINGS_KEY = "@paseo:app-settings"; @@ -26,14 +33,19 @@ async function openComposerDiff(page: Page) { await composerChangesPill(page).click(); } -async function seedChangedAgent(repoPrefix: string) { +async function seedChangedAgent( + repoPrefix: string, + featureValues?: Record, + files: Array<{ path: string; content: string }> = [], +) { const workspace = await seedMockAgentWorkspace({ repoPrefix, + featureValues, title: "Composer diff stat", repo: { withRemote: true, // Exclude the local remote before the daemon starts observing this checkout. - files: [{ path: ".gitignore", content: "/remote.git/\n" }], + files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files], }, }); try { @@ -156,3 +168,691 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan await workspace.cleanup(); } }); + +async function tapFirstAddedDiffLine( + page: Page, + surface: Locator = page.getByTestId("working-diff-panel"), +) { + const body = surface.getByTestId("diff-file-0-body"); + await expect(body).toBeVisible({ timeout: 30000 }); + const bounds = await body.boundingBox(); + if (!bounds) throw new Error("Expanded diff body has no bounds"); + const fontSize = await surface + .getByTestId("git-diff-canvas") + .evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)); + await page.touchscreen.tap(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5); +} + +async function openReviewDiff(page: Page, workspace: Awaited>) { + await page.setViewportSize({ width: 1400, height: 900 }); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId }); + await expect(composerChangesPill(page)).toBeVisible({ timeout: 30000 }); + if (await page.getByTestId("workspace-tab-working_diff").count()) { + await selectDiffTab(page); + } else { + await revealComposerChangesInExplorer(page); + await openComposerDiff(page); + } + await expect(page.getByTestId("diff-file-0-body")).toBeVisible(); +} + +async function saveReviewComment(page: Page, body: string) { + await page.getByRole("textbox", { name: "Review comment" }).fill(body); + await page.getByRole("button", { name: "Save review comment", exact: true }).click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); +} + +async function cancelReviewComment(page: Page) { + await page.getByRole("button", { name: "Cancel review comment", exact: true }).click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); +} + +test.describe("review comments", () => { + test.use({ hasTouch: true }); + + test("compact review comments open a sheet and preserve saved comments through save, edit, cancel and reload", async ({ + page, + }) => { + const workspace = await seedChangedAgent("compact-review-comment-"); + try { + await test.step("Open a compact sheet with reachable input and actions", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await tapFirstAddedDiffLine(page); + await expectCompactCommentSheet(page); + }); + await test.step("Save, discard an edit, then save an updated comment", async () => { + await saveReviewComment(page, " Please simplify this. "); + await expectSavedReview(page, "Please simplify this."); + await editSavedReview(page, "Please simplify this."); + await fillReviewComment(page, "Discard this edit"); + await cancelReviewComment(page); + await expectSavedReview(page, "Please simplify this."); + await editSavedReview(page, "Please simplify this."); + await saveReviewComment(page, "Updated comment"); + await expectSavedReview(page, "Updated comment"); + }); + await test.step("Discard a new comment and retain the saved one after reload", async () => { + await tapFirstAddedDiffLine(page); + await fillReviewComment(page, "Discard new comment"); + await cancelReviewComment(page); + await expectReviewAbsent(page, "Discard new comment"); + await reloadReview(page); + await expectSavedReview(page, "Updated comment"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("wide review comments retain inline editing", async ({ page }) => { + const workspace = await seedChangedAgent("wide-review-comment-"); + try { + await test.step("Save and cancel an inline edit on a wide screen", async () => { + await openReviewDiff(page, workspace); + await tapFirstAddedDiffLine(page); + await expectInlineCommentEditor(page); + await saveReviewComment(page, "Wide comment"); + await expectSavedReview(page, "Wide comment"); + await editSavedReview(page, "Wide comment"); + await expectInlineCommentEditor(page); + await cancelReviewComment(page); + await expectSavedReview(page, "Wide comment"); + }); + await test.step("The existing composer still sends the review", async () => { + await sendReviewThroughComposer(page, workspace.agentId); + await selectDiffTab(page); + await expectReviewAbsent(page, "Wide comment"); + }); + } finally { + await workspace.cleanup(); + } + }); +}); + +async function sendReviewThroughComposer(page: Page, agentId: string) { + await page.getByTestId(`workspace-tab-agent_${agentId}`).click(); + await expect(page.getByTestId("composer-review-attachment-pill")).toBeVisible(); + await page.getByRole("textbox", { name: "Message agent..." }).fill("Address this review"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect(page.getByText("Address this review", { exact: true }).first()).toBeVisible(); + await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0); +} + +async function addSavedReview( + page: Page, + body: string, + surface: Locator = page.getByTestId("working-diff-panel"), +) { + await tapFirstAddedDiffLine(page, surface); + await saveReviewComment(page, body); + await expectSavedReview(page, body, surface); +} + +async function expectSavedReview( + page: Page, + body: string, + surface: Locator = page.getByTestId("working-diff-panel"), +) { + await expect(surface.getByText(body, { exact: true })).toBeVisible(); +} + +async function editSavedReview(page: Page, expectedBody: string) { + await page + .getByTestId("working-diff-panel") + .getByRole("button", { name: "Edit review comment", exact: true }) + .click(); + await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(expectedBody); +} + +async function sendFeedback(page: Page, count: number) { + await page.getByRole("button", { name: `Send feedback (${count})`, exact: true }).click(); +} + +async function expectFeedbackSent(page: Page, recipient: string) { + await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible(); + await expect(page.getByTestId("working-diff-panel")).toBeVisible(); +} + +test.describe("send review feedback", () => { + test.use({ hasTouch: true }); + + test("compact feedback sends directly to the only workspace agent and preserves its composer draft", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-feedback-single-"); + try { + await test.step("Save a review without changing the chat draft", async () => { + await openReviewDiff(page, workspace); + await selectAgentTab(page, workspace.agentId); + await fillComposerDraft(page, "Keep this thought"); + await attachFileFromMenu(page, { + name: "unrelated.txt", + mimeType: "text/plain", + buffer: Buffer.from("Unrelated draft attachment"), + }); + await selectDiffTab(page); + await showCompactLayout(page); + await addSavedReview(page, "Please simplify this."); + }); + await test.step("Send directly and stay on the diff", async () => { + await sendFeedback(page, 1); + await expectFeedbackSent(page, "Composer diff stat"); + await expectReviewAbsent(page, "Please simplify this."); + await captureFeedback(page, "single"); + }); + await test.step("Return to the untouched composer draft", async () => { + await showWideLayout(page); + await selectAgentTab(page, workspace.agentId); + await expectComposerDraft(page, "Keep this thought"); + await expect(page.getByText("unrelated.txt", { exact: true })).toBeVisible(); + await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0); + }); + } finally { + await workspace.cleanup(); + } + }); + test("feedback pending ownership follows the draft across Diff and compact Changes", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-shared-pending-"); + const gate = await gateNextAgentMessage(page); + try { + await test.step("Send feedback from the Diff tab and hold its real request", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await addSavedReview(page, "One draft across surfaces"); + await sendFeedback(page, 1); + await gate.waitForRequest(); + }); + await test.step("The compact Changes surface observes the same pending operation", async () => { + await revealCompactChanges(page); + await expect( + compactReviewSurface(page).getByRole("button", { + name: "Sending feedback (1)", + exact: true, + }), + ).toBeDisabled(); + expect(gate.requestCount()).toBe(1); + gate.accept(); + await expect( + compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", { + exact: true, + }), + ).toBeVisible(); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("an unsaved review survives presentation changes and acknowledgement before Save", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-editor-ownership-"); + const gate = await gateNextAgentMessage(page); + try { + await test.step("Carry unsaved inline text into the compact sheet and back", async () => { + await openReviewDiff(page, workspace); + await tapFirstAddedDiffLine(page); + await fillReviewComment(page, "Body across presentations"); + await showCompactLayout(page); + await expectReviewEditorBody(page, "Body across presentations"); + await showWideLayout(page); + await expectReviewEditorBody(page, "Body across presentations"); + await saveReviewComment(page, "Body across presentations"); + }); + await test.step("Keep an open edit saveable after acknowledgement removes its original", async () => { + await sendFeedback(page, 1); + await gate.waitForRequest(); + await editSavedReview(page, "Body across presentations"); + await fillReviewComment(page, "Keep this unsaved edit"); + gate.accept(); + await expectFeedbackSent(page, "Composer diff stat"); + await expectReviewEditorBody(page, "Keep this unsaved edit"); + await showCompactLayout(page); + await expectReviewEditorBody(page, "Keep this unsaved edit"); + await saveReviewComment(page, "Keep this unsaved edit"); + await expectSavedReview(page, "Keep this unsaved edit"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("saved feedback preserves navigation across multiple diff files", async ({ page }) => { + const workspace = await seedMultiFileReview(); + try { + await test.step("Save compact feedback and retain both floating actions", async () => { + await openCompactReviewExplorer(page, workspace); + await addSavedReview(page, "Keep file navigation available", compactReviewSurface(page)); + await expectReviewNavigationReachable(page); + }); + await test.step("Jump to another file without covering the feedback action", async () => { + await jumpToReviewFile(page, "z-last.ts"); + await scrollReviewToLastLine(page); + await expectReviewNavigationReachable(page); + await captureFeedback(page, "navigation-compact"); + }); + await test.step("Wide file navigation and feedback remain reachable", async () => { + await openReviewDiff(page, workspace); + await navigateWideReviewFile(page, "README.md"); + await expectFeedbackReachable(page); + await expectSavedReview(page, "Keep file navigation available"); + await captureFeedback(page, "navigation-wide"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("feedback sends only matching comments and preserves orphaned comments", async ({ + page, + }) => { + const workspace = await seedMultiFileReview(); + try { + await test.step("A saved comment whose diff disappears has no sendable context", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await addSavedReview(page, "Retain this orphan"); + await setReadmeDiff(workspace, false); + await expectUnmatchedFeedback(page); + }); + await test.step("Send the matching subset with an accurate count", async () => { + await addSavedReview(page, "Send this matching comment"); + await sendFeedback(page, 1); + await expectFeedbackSent(page, "Composer diff stat"); + await expectReviewAbsent(page, "Send this matching comment"); + await expectUnmatchedFeedback(page); + }); + await test.step("Restoring the missing diff reveals the unsent comment", async () => { + await setReadmeDiff(workspace, true); + await expectSavedReview(page, "Retain this orphan"); + await expectFeedbackReachable(page); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("zero open agent tabs retain feedback with a disabled reason", async ({ page }) => { + const workspace = await seedChangedAgent("review-feedback-zero-"); + try { + await test.step("Close the agent tab while keeping the review", async () => { + await openReviewDiff(page, workspace); + await addSavedReview(page, "Keep until an agent is opened"); + await closeAgentTab(page, workspace.agentId); + }); + await test.step("The disabled action explains why in wide and compact layouts", async () => { + await expectFeedbackUnavailable(page); + await captureFeedback(page, "zero-wide"); + await showCompactLayout(page); + await expectFeedbackUnavailable(page); + await expectSavedReview(page, "Keep until an agent is opened"); + await captureFeedback(page, "zero-compact"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("only open tabs in this workspace appear in compact and wide recipient menus", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-feedback-multiple-"); + const other = await seedChangedAgent("review-feedback-other-"); + try { + const second = await createReviewRecipient(workspace, "Second reviewer"); + const historical = await createReviewRecipient(workspace, "Closed historical agent"); + await test.step("Open two local tabs and a tab in another workspace", async () => { + await openReviewDiff(page, other); + await addSavedReview(page, "Other workspace review"); + await openReviewDiff(page, workspace); + await closeAgentTab(page, historical.id); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: second.id }); + await selectDiffTab(page); + await addSavedReview(page, "Review for second agent"); + }); + await test.step("The wide menu includes only local open agents", async () => { + await captureWideFeedbackAppearance(page); + }); + await test.step("Escape returns keyboard focus to feedback so the chooser can reopen", async () => { + await openFeedbackWithKeyboard(page); + await dismissRecipientMenu(page); + await expectFeedbackFocused(page); + await reopenFeedbackWithKeyboard(page); + await dismissRecipientMenu(page); + await expectFeedbackFocused(page); + }); + await test.step("Choose the second agent in the compact menu and stay on the diff", async () => { + await showCompactLayout(page); + await captureFeedback(page, "cta-compact"); + await sendFeedback(page, 1); + await expectRecipientMenu(page); + await captureFeedback(page, "menu-compact"); + await chooseFeedbackRecipient(page, "Second reviewer"); + await expectFeedbackSent(page, "Second reviewer"); + await expectReviewAbsent(page, "Review for second agent"); + }); + await test.step("The other workspace's saved review remains", async () => { + await openReviewDiff(page, other); + await expectSavedReview(page, "Other workspace review"); + }); + } finally { + await workspace.cleanup(); + await other.cleanup(); + } + }); + + test("failed feedback remains editable and retries through the real provider", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-feedback-retry-", { mockPromptRejections: 1 }); + try { + await test.step("A provider rejection retains the saved review", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await addSavedReview(page, "Initial feedback"); + await sendFeedback(page, 1); + await expectFeedbackFailure(page); + await expectSavedReview(page, "Initial feedback"); + await captureFeedback(page, "failure"); + }); + await test.step("Edit the retained feedback and retry successfully", async () => { + await editSavedReview(page, "Initial feedback"); + await saveReviewComment(page, "Corrected feedback"); + await sendFeedback(page, 1); + await expectFeedbackSent(page, "Composer diff stat"); + await expectReviewAbsent(page, "Corrected feedback"); + }); + } finally { + await workspace.cleanup(); + } + }); + + test("pending feedback blocks repeat submission and preserves edits and additions", async ({ + page, + }) => { + const workspace = await seedChangedAgent("review-feedback-pending-"); + const gate = await gateNextAgentMessage(page); + try { + await test.step("Hold the real request while feedback is pending", async () => { + await openReviewDiff(page, workspace); + await showCompactLayout(page); + await addSavedReview(page, "Sent version"); + await sendFeedback(page, 1); + const request = await gate.waitForRequest(); + expect(request.agentId).toBe(workspace.agentId); + expect(request.text).toBe("Please address this code review."); + expect(request.attachments).toMatchObject([ + { + type: "review", + cwd: workspace.cwd, + comments: [ + { + filePath: "README.md", + lineNumber: 2, + body: "Sent version", + context: { targetLine: { content: "export const one = 1;", newLineNumber: 2 } }, + }, + ], + }, + ]); + await expectFeedbackPending(page); + }); + await test.step("Save an edit and another comment before acknowledgement", async () => { + await editSavedReview(page, "Sent version"); + await saveReviewComment(page, "Edited during send"); + await addSavedReview(page, "Added during send"); + await expectFeedbackPending(page); + expect(gate.requestCount()).toBe(1); + await captureFeedback(page, "pending"); + }); + await test.step("A real acknowledgement preserves both new versions", async () => { + gate.accept(); + await expectFeedbackSent(page, "Composer diff stat"); + await expectSavedReview(page, "Edited during send"); + await expectSavedReview(page, "Added during send"); + await expectReviewAbsent(page, "Sent version"); + }); + } finally { + await workspace.cleanup(); + } + }); +}); + +async function showCompactLayout(page: Page) { + await page.setViewportSize({ width: 390, height: 844 }); +} +async function showWideLayout(page: Page) { + await page.setViewportSize({ width: 1400, height: 900 }); +} +async function selectAgentTab(page: Page, agentId: string) { + await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ position: { x: 12, y: 13 } }); +} +async function selectDiffTab(page: Page) { + await page.getByTestId("workspace-tab-working_diff").click({ position: { x: 12, y: 13 } }); +} +async function fillReviewComment(page: Page, body: string) { + await page.getByRole("textbox", { name: "Review comment" }).fill(body); +} +async function expectReviewAbsent(page: Page, body: string) { + await expect(page.getByTestId("working-diff-panel").getByText(body, { exact: true })).toHaveCount( + 0, + ); +} +async function reloadReview(page: Page) { + await page.reload(); + await expect(page.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30000 }); +} +async function expectInlineCommentEditor(page: Page) { + await expect(page.getByTestId("inline-review-editor")).toBeVisible(); + await expect(page.getByTestId("review-comment-sheet")).toHaveCount(0); +} +async function expectCompactCommentSheet(page: Page) { + await expect(page.getByTestId("review-comment-sheet")).toBeVisible(); + await expect(page.getByText("README.md · +2", { exact: true })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Review comment" })).toBeInViewport({ ratio: 1 }); + for (const action of ["Save", "Cancel"]) + await expect( + page.getByRole("button", { name: `${action} review comment`, exact: true }), + ).toBeInViewport({ ratio: 1 }); + await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Save review comment", exact: true }), + ).toBeDisabled(); +} +function feedbackButton(page: Page) { + return page.getByRole("button", { name: "Send feedback (1)", exact: true }); +} +async function openFeedbackWithKeyboard(page: Page) { + await feedbackButton(page).focus(); + await expectFeedbackFocused(page); + await reopenFeedbackWithKeyboard(page); +} +async function reopenFeedbackWithKeyboard(page: Page) { + await page.keyboard.press("Enter"); + await expectRecipientMenu(page); + const recipient = page.getByRole("menuitem", { name: "Composer diff stat", exact: true }); + await recipient.focus(); + await expect(recipient).toBeFocused(); +} +async function expectFeedbackFocused(page: Page) { + await expect(feedbackButton(page)).toBeFocused(); +} + +async function captureWideFeedbackAppearance(page: Page) { + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expectSavedReview(page, "Review for second agent"); + await captureFeedback(page, `cta-wide-${colorScheme}-closed`); + await sendFeedback(page, 1); + await expectRecipientMenu(page); + await captureFeedback(page, `cta-wide-${colorScheme}-open`); + await dismissRecipientMenu(page); + } + await page.emulateMedia({ colorScheme: "light" }); +} + +async function captureFeedback(page: Page, name: string) { + await page.screenshot({ path: `/tmp/phase2-feedback-${name}.png` }); +} + +async function closeAgentTab(page: Page, agentId: string) { + await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ button: "right" }); + await page.getByTestId(`workspace-tab-context-agent_${agentId}-close`).click(); + await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0); +} +async function expectFeedbackUnavailable(page: Page) { + await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeDisabled(); + await expect( + page.getByText("Open an agent tab in this workspace to send feedback.", { exact: true }), + ).toBeVisible(); +} +async function createReviewRecipient( + workspace: Awaited>, + title: string, +) { + return workspace.client.createAgent({ + provider: "mock", + cwd: workspace.cwd, + workspaceId: workspace.workspaceId, + title, + modeId: "load-test", + model: "e2e-fast-stream", + }); +} +async function expectRecipientMenu(page: Page) { + for (const name of ["Composer diff stat", "Second reviewer"]) { + await expect(page.getByRole("menuitem", { name, exact: true })).toBeInViewport({ ratio: 1 }); + } + await expect(page.getByRole("menuitem")).toHaveCount(2); +} +async function dismissRecipientMenu(page: Page) { + await page.keyboard.press("Escape"); + await expect(page.getByRole("menuitem")).toHaveCount(0); +} +async function chooseFeedbackRecipient(page: Page, name: string) { + await page.getByRole("menuitem", { name, exact: true }).click(); +} +async function expectFeedbackFailure(page: Page) { + await expect( + page.getByText("Requested mock prompt rejection", { exact: true }).first(), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeEnabled(); +} +async function expectFeedbackPending(page: Page, count = 1) { + await expect( + page.getByRole("button", { name: `Sending feedback (${count})`, exact: true }), + ).toBeDisabled(); +} + +async function seedMultiFileReview() { + const workspace = await seedChangedAgent("review-feedback-navigation-", undefined, [ + { path: "z-last.ts", content: "export const final = 0;\n" }, + ]); + await writeFile( + path.join(workspace.cwd, "z-last.ts"), + Array.from({ length: 120 }, (_, index) => `export const value${index} = ${index};`).join("\n") + + "\n", + ); + await workspace.client.checkoutRefresh(workspace.cwd); + return workspace; +} +async function expectFeedbackReachable(page: Page) { + await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeInViewport( + { ratio: 1 }, + ); +} +async function expectReviewNavigationReachable(page: Page) { + await expectFeedbackReachable(page); + const jump = page.getByRole("button", { name: "Jump to file", exact: true }); + await expect(jump).toBeInViewport({ ratio: 1 }); + const navigationBounds = await jump.boundingBox(); + const feedbackBounds = await page + .getByRole("button", { name: "Send feedback (1)", exact: true }) + .boundingBox(); + if (!navigationBounds || !feedbackBounds) throw new Error("Review actions have no bounds"); + expect(feedbackBounds.y + feedbackBounds.height).toBeLessThanOrEqual(navigationBounds.y); +} +async function jumpToReviewFile(page: Page, name: string) { + await page.getByRole("button", { name: "Jump to file", exact: true }).click(); + const sheet = page.getByTestId("changes-jump-to-file-sheet"); + await page + .getByTestId("changes-file-tree") + .filter({ visible: true }) + .getByText(name, { exact: true }) + .click(); + await expect(sheet).toHaveCount(0); + await expect(reviewFileHeader(compactReviewSurface(page), name)).toBeInViewport(); +} +async function navigateWideReviewFile(page: Page, name: string) { + const explorer = await ensureExplorerSidebar(page); + await explorer.getByTestId("changes-tree-panel").getByText(name, { exact: true }).click(); + await expect(reviewFileHeader(page.getByTestId("working-diff-panel"), name)).toBeInViewport(); +} + +async function setReadmeDiff( + workspace: Awaited>, + changed: boolean, +) { + await writeFile( + path.join(workspace.cwd, "README.md"), + changed ? "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n" : "# Temp Repo\n", + ); + await workspace.client.checkoutRefresh(workspace.cwd); +} +async function expectUnmatchedFeedback(page: Page) { + await expect(page.getByRole("button", { name: "Send feedback (0)", exact: true })).toBeDisabled(); + await expect( + page.getByText("Saved comments no longer match this diff.", { exact: true }), + ).toBeVisible(); +} + +function compactReviewSurface(page: Page) { + return page.getByTestId("explorer-content-area").filter({ visible: true }); +} +async function openCompactReviewExplorer( + page: Page, + workspace: Awaited>, +) { + await showWideLayout(page); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId }); + await selectAgentTab(page, workspace.agentId); + await showCompactLayout(page); + await composerChangesPill(page).click(); + await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible(); +} + +function reviewFileHeader(surface: Locator, name: string) { + return surface.locator(`[data-diff-header-path="${name}"]`).getByTestId(/^diff-file-\d+$/); +} + +async function scrollReviewToLastLine(page: Page) { + const surface = compactReviewSurface(page); + const bounds = await surface.boundingBox(); + if (!bounds) throw new Error("Compact review has no bounds"); + await page.mouse.move(bounds.x + 120, bounds.y + 200); + await page.mouse.wheel(0, 10000); + await expect + .poll(async () => { + const body = await surface.getByTestId("diff-file-1-body").boundingBox(); + const feedback = await page + .getByRole("button", { name: "Send feedback (1)", exact: true }) + .boundingBox(); + if (!body || !feedback) return false; + return body.y + body.height <= feedback.y; + }) + .toBe(true); +} + +async function revealCompactChanges(page: Page) { + await page.getByTestId("workspace-explorer-toggle").first().click(); + await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click(); + await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible(); +} +async function expectReviewEditorBody(page: Page, body: string) { + await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue( + body, + ); +} diff --git a/packages/app/e2e/browser/composer-email-mentions.spec.ts b/packages/app/e2e/browser/composer-email-mentions.spec.ts new file mode 100644 index 0000000000..283fca26ca --- /dev/null +++ b/packages/app/e2e/browser/composer-email-mentions.spec.ts @@ -0,0 +1,104 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { + composerLocator, + expectComposerDraft, + expectComposerVisible, + fillComposerDraft, + enterComposerFullscreen, + exitComposerFullscreen, + resizeComposerViewport, + selectComposerDraftRange, +} from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +// The popover has no semantic role; its rows are React Native Pressables. +function fileSuggestions(page: Page) { + return page.getByTestId("composer-autocomplete-popover"); +} + +async function selectReadmeMention(page: Page): Promise { + await fillComposerDraft(page, "@README"); + await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible(); + await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, '"README.md"'); +} + +async function sendEmailIntact(page: Page, email: string): Promise { + await fillComposerDraft(page, email); + await expect(fileSuggestions(page)).not.toBeVisible(); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, ""); + await expect(page.getByText(email, { exact: true })).toBeVisible(); +} + +for (const email of [ + "user <12345+user@noreply.example.com>", + "alice@example.com", + "noreply+@example.com", +]) { + test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-email-", + title: "Email mention regression", + repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] }, + }); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await test.step("complete a workspace file mention with Enter", async () => { + await selectReadmeMention(page); + }); + await test.step("send an email without offering or inserting workspace files", async () => { + await sendEmailIntact(page, email); + }); + } finally { + await agent.cleanup(); + } + }); +} + +test("fullscreen mention keys edit the draft while inline keys still complete files", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-mention-", + title: "Fullscreen mention keys", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const draft = "First line\nSecond line\n@README"; + await fillComposerDraft(page, draft); + await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible(); + await enterComposerFullscreen(page); + await expect(fileSuggestions(page)).not.toBeVisible(); + await selectComposerDraftRange(page, draft.length, draft.length); + await composerLocator(page).press("ArrowUp"); + await expect + .poll(() => + composerLocator(page).evaluate((element) => { + const input = element as HTMLTextAreaElement; + return input.selectionStart === input.selectionEnd && input.selectionStart < 23; + }), + ) + .toBe(true); + await selectComposerDraftRange(page, draft.length, draft.length); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, `${draft}\n`); + await exitComposerFullscreen(page); + await selectReadmeMention(page); + const commandDraft = "/cl\nSecond line\nThird line"; + await fillComposerDraft(page, commandDraft); + await selectComposerDraftRange(page, 2, 2); + await composerLocator(page).press("ArrowRight"); + await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible(); + await enterComposerFullscreen(page); + await expect(fileSuggestions(page)).not.toBeVisible(); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, "/cl\n\nSecond line\nThird line"); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 897e536340..cbc710a94a 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,12 +1,30 @@ -import { test } from "../support/fixtures"; +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({ @@ -27,3 +45,124 @@ test("submitting a message leaves the composer ready for the next message", asyn await agent.cleanup(); } }); + +test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-open-", + title: "Composer focus on open", + }); + + try { + await openAgentRoute(page, agent); + await expectComposerFocused(page); + } finally { + await agent.cleanup(); + } +}); + +async function waitForAnimationFrames(page: Page, count: number): Promise { + await page.evaluate(async (frames) => { + for (let frame = 0; frame < frames; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }, count); +} + +test.describe("touch screen wide enough for the desktop layout", () => { + // A tablet or an unfolded foldable gets the desktop layout, but focusing the + // composer there raises the on-screen keyboard over the conversation. + test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true }); + + test("opening an agent leaves the composer unfocused", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-touch-", + title: "Composer focus on touch", + }); + + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + expect( + await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches), + ).toBe(false); + // Autofocus makes its first attempt on the next animation frame. + await waitForAnimationFrames(page, 4); + await expectComposerNotFocused(page); + } finally { + await agent.cleanup(); + } + }); +}); + +const COMPACT_PANELS = [ + { + name: "agent list", + open: swipeOpenMobileAgentSidebar, + expectOpen: expectMobileAgentSidebarVisible, + close: closeMobileAgentSidebar, + expectClosed: expectMobileAgentSidebarHidden, + }, + { + name: "Explorer", + open: swipeOpenMobileExplorer, + expectOpen: expectMobileExplorerVisible, + close: closeMobileExplorer, + expectClosed: expectMobileExplorerHidden, + }, +] as const; + +for (const panel of COMPACT_PANELS) { + test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-panel-", + title: `Composer focus under ${panel.name}`, + }); + const draft = "Line one\nLine two\nLine three"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + + await test.step("an untouched editor stays untouched", async () => { + await expectComposerNotFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + }); + + await test.step("an inline editor blurred under the panel stays blurred", async () => { + await composerLocator(page).fill(draft); + await composerLocator(page).click(); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerDraft(page, draft); + }); + + await test.step("a fullscreen editor blurred under the panel stays blurred", async () => { + await composerLocator(page).click(); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerFullscreenControls(page); + await expectComposerDraft(page, draft); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + }); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 024446967f..8e1a527f0f 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -1,3 +1,5 @@ +import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { seedWorkspace } from "../support/helpers/seed-client"; import { expect, test, type Page } from "../support/fixtures"; import { expectNearBottom, @@ -5,8 +7,38 @@ import { waitForScrollableChat, } from "../support/helpers/agent-bottom-anchor"; import { awaitAssistantMessage } from "../support/helpers/agent-stream"; -import { composerLocator, expectComposerVisible } from "../support/helpers/composer"; +import { + attachComposerTestImage, + pasteComposerTestImage, + enterComposerFullscreen, + exitComposerFullscreen, + resizeComposerViewport, + selectComposerDraftRange, + expectComposerDraftSelection, + expectComposerFullscreenControls, + sendComposerWithButton, + queueComposerWithButton, + configureComposerQueueSend, + activateRetainedComposerTab, + switchComposerTabWithKeyboard, + expectWorkspaceHeaderHidden, + expectWorkspaceHeaderVisible, + recordVisibleComposerGeometry, + expectNoCollapsedVisibleComposer, + expectAttachmentPill, + expectQueuedMessageButton, + startRunningMockAgent, + composerLocator, + expectComposerVisible, + expectComposerFullscreenFitsVisibleViewport, +} from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + closeMobileAgentSidebar, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + swipeOpenMobileAgentSidebar, +} from "../support/helpers/sidebar"; async function composerHeight(page: Page): Promise { return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height); @@ -133,3 +165,471 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } await agent.cleanup(); } }); + +/** Freeze the page's timers and animation frames until `page.clock.resume()`. */ +async function pausePageClock(page: Page): Promise { + const now = Date.now(); + await page.clock.install({ time: now }); + // The installed clock advances between protocol calls; pause at a future time. + await page.clock.pauseAt(now + 60_000); +} + +/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */ +async function expectFullscreenErrorAboveSend(page: Page): Promise { + const frame = page.getByTestId("composer-fullscreen"); + const alertBox = (await frame.getByRole("alert").boundingBox())!; + const sendBox = (await page + .getByRole("button", { name: "Send message", exact: true }) + .boundingBox())!; + const editorBox = (await composerLocator(page).boundingBox())!; + expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height); + expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y); +} + +async function writeCompactDraft(page: Page, text: string): Promise { + await composerLocator(page).fill(text); + await expect(composerLocator(page)).toHaveValue(text); +} + +test("compact long drafts stop growing before the header", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-cap-", + title: "Composer cap", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "A long draft line\n".repeat(40)); + await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160); + } finally { + await agent.cleanup(); + } +}); + +test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-", + title: "Fullscreen composer", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await test.step("the affordance appears after two rendered lines, including soft wraps", async () => { + await attachComposerTestImage(page); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await writeCompactDraft(page, "First line\nSecond line"); + await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0); + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible(); + await writeCompactDraft( + page, + "A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat( + 4, + ), + ); + await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible(); + }); + await test.step("fullscreen covers the header and keeps the live draft and selection", async () => { + await selectComposerDraftRange(page, 4, 17); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectWorkspaceHeaderHidden(page); + await expectComposerDraftSelection(page, 4, 17); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line"); + await selectComposerDraftRange(page, 3, 12); + }); + await test.step("exit restores the inline composer with the fullscreen edits", async () => { + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + await expect(composerLocator(page)).toHaveValue( + "Edited fullscreen draft\nSecond line\nThird line", + ); + await expectComposerDraftSelection(page, 3, 12); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await expect(composerLocator(page)).toBeFocused(); + }); + await test.step("sending inline after a fullscreen edit clears the visible editor", async () => { + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await writeCompactDraft(page, "Typed after the fullscreen cycle"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + }); + await test.step("sending inside fullscreen returns to chat and clears the editor", async () => { + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await writeCompactDraft(page, "Short draft"); + await expectComposerFullscreenControls(page); + await selectComposerDraftRange(page, 0, 5); + await resizeComposerViewport(page, "landscape"); + await expectComposerFullscreenControls(page); + await expectComposerDraftSelection(page, 0, 5); + await expect(composerLocator(page)).toHaveValue("Short draft"); + await expect(composerLocator(page)).toBeFocused(); + await resizeComposerViewport(page, "portrait"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expectWorkspaceHeaderVisible(page); + await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0); + }); + await test.step("typing and sending keep working after leaving fullscreen again", async () => { + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await writeCompactDraft(page, "Typed inside fullscreen"); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen"); + await writeCompactDraft(page, "Typed inside fullscreen, finished inline"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await writeCompactDraft(page, "One more inline message"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + }); + } finally { + await agent.cleanup(); + } +}); + +test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-remount-caret-", + title: "Remount caret", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "Short draft"); + await selectComposerDraftRange(page, 5, 5); + // The draft owner publishes web edits after the next paint. A rotation inside that + // window remounts the editor before the edit has reached the draft store; the paused + // clock holds the page there instead of racing the frame. + await pausePageClock(page); + await composerLocator(page).press("X"); + await resizeComposerViewport(page, "landscape"); + await page.clock.resume(); + await expect(composerLocator(page)).toHaveValue("ShortX draft"); + await expectComposerDraftSelection(page, 6, 6); + await expect(composerLocator(page)).toBeFocused(); + await composerLocator(page).press("Y"); + await expect(composerLocator(page)).toHaveValue("ShortXY draft"); + } finally { + await agent.cleanup(); + } +}); + +test("fullscreen submit rejection retains the editable draft and supports retry", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-retry-", + title: "Fullscreen retry", + featureValues: { mockPromptRejections: 1 }, + }); + const draft = "First line\nSecond line\nThird line"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, draft); + await enterComposerFullscreen(page); + await sendComposerWithButton(page); + await expectWorkspaceHeaderVisible(page); + await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection"); + await enterComposerFullscreen(page); + await expectFullscreenErrorAboveSend(page); + await expect(composerLocator(page)).toHaveValue(draft); + await expect(composerLocator(page)).toBeEditable(); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expectWorkspaceHeaderVisible(page); + } finally { + await agent.cleanup(); + } +}); + +async function blurComposer(page: Page): Promise { + await composerLocator(page).blur(); + await expect(composerLocator(page)).not.toBeFocused(); +} + +/** Mid-band at the given height and near the editor's bottom edge. */ +async function reservedBandPoints( + page: Page, + bandX: number, + topY: number, +): Promise> { + const editor = (await composerLocator(page).boundingBox())!; + return [ + { x: bandX, y: topY }, + { x: bandX, y: editor.y + editor.height - 6 }, + ]; +} + +test("the reserved band beside the inline editor focuses it", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-band-focus-", + title: "Composer band focus", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true }); + const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true }); + const longDraft = "Band first line\nBand second line\nBand third line"; + await writeCompactDraft(page, longDraft); + await expect(fullscreenButton).toBeVisible(); + const button = (await fullscreenButton.boundingBox())!; + const bandX = button.x + button.width / 2; + + await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => { + for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) { + await blurComposer(page); + await page.mouse.click(point.x, point.y); + await expect(composerLocator(page)).toBeFocused(); + await expect(composerLocator(page)).toHaveValue(longDraft); + await expect(exitButton).toHaveCount(0); + } + }); + + await test.step("the button itself still toggles fullscreen", async () => { + await fullscreenButton.click(); + await expectComposerFullscreenControls(page); + await exitComposerFullscreen(page); + await expect(exitButton).toHaveCount(0); + await expect(composerLocator(page)).toHaveValue(longDraft); + }); + + await test.step("with no fullscreen button, the whole band still focuses the editor", async () => { + await writeCompactDraft(page, "Short"); + await expect(fullscreenButton).toHaveCount(0); + const editor = (await composerLocator(page).boundingBox())!; + for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) { + await blurComposer(page); + await page.mouse.click(point.x, point.y); + await expect(composerLocator(page)).toBeFocused(); + await expect(composerLocator(page)).toHaveValue("Short"); + await expect(exitButton).toHaveCount(0); + } + }); + } finally { + await agent.cleanup(); + } +}); + +test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({ + page, +}) => { + await configureComposerQueueSend(page); + await resizeComposerViewport(page, "portrait"); + const agent = await startRunningMockAgent(page, { + prefix: "composer-fullscreen-queue-", + model: "one-minute-stream", + prompt: "Stay running for fullscreen queue test.", + }); + try { + await writeCompactDraft(page, "Queued first line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await queueComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expectWorkspaceHeaderVisible(page); + await expectQueuedMessageButton(page); + } finally { + await agent.cleanup(); + } +}); + +test("image paste stays attached to the active editor across fullscreen entry and exit", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-paste-remount-", + title: "Composer paste", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + expect(await pasteComposerTestImage(page)).toBe(true); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1); + await enterComposerFullscreen(page); + expect(await pasteComposerTestImage(page)).toBe(true); + await exitComposerFullscreen(page); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2); + expect(await pasteComposerTestImage(page)).toBe(true); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3); + } finally { + await agent.cleanup(); + } +}); + +test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" }); + try { + const first = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "First retained chat", + }); + const second = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Second retained chat", + }); + await openWorkspaceWithAgents(page, [first, second]); + await resizeComposerViewport(page, "portrait"); + const draft = "Live first line\nLive second line\nLive third line"; + await writeCompactDraft(page, draft); + await enterComposerFullscreen(page); + await expectWorkspaceHeaderHidden(page); + await selectComposerDraftRange(page, 4, 17); + // Fullscreen covers the compact header, so a phone leaves the tab by exiting first; + // a desktop-width window still has the tab shortcut. + await resizeComposerViewport(page, "landscape"); + await switchComposerTabWithKeyboard(page, "previous"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + // The exit action belongs to the active pane: Escape here leaves the hidden tab alone. + await composerLocator(page).press("Escape"); + await switchComposerTabWithKeyboard(page, "next"); + await expectComposerFullscreenControls(page); + await switchComposerTabWithKeyboard(page, "previous"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await resizeComposerViewport(page, "portrait"); + await expectWorkspaceHeaderVisible(page); + await writeCompactDraft(page, "Other tab draft"); + await shrinkVisibleViewport(page); + await activateRetainedComposerTab(page, second.title); + await expectComposerFullscreenControls(page); + await expectWorkspaceHeaderHidden(page); + await expect(composerLocator(page)).toHaveValue(draft); + await expectComposerFullscreenFitsVisibleViewport(page); + await expectComposerDraftSelection(page, 4, 17); + await expect(composerLocator(page)).toBeFocused(); + expect(await pasteComposerTestImage(page)).toBe(true); + await exitComposerFullscreen(page); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + } finally { + await workspace.cleanup(); + } +}); + +test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-sidebar-", + title: "Fullscreen under sidebar", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + const draft = "Line one\nLine two\nLine three"; + await writeCompactDraft(page, draft); + await enterComposerFullscreen(page); + await expectWorkspaceHeaderHidden(page); + // Fullscreen hides the header's menu button; the edge swipe still opens the list over it. + await swipeOpenMobileAgentSidebar(page); + await expectMobileAgentSidebarVisible(page); + // The list covers the pane, so the editor's exit action is not armed. + await page.keyboard.press("Escape"); + await closeMobileAgentSidebar(page); + await expectMobileAgentSidebarHidden(page); + await expectComposerFullscreenControls(page); + await expect(composerLocator(page)).toHaveValue(draft); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + } finally { + await agent.cleanup(); + } +}); + +test("retained inline composers keep usable capacity through hidden tab geometry", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" }); + try { + const first = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "First capacity chat", + }); + const second = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Second capacity chat", + }); + await openWorkspaceWithAgents(page, [first, second]); + await resizeComposerViewport(page, "portrait"); + const draft = "Retained long line\n".repeat(40); + await writeCompactDraft(page, draft); + await recordVisibleComposerGeometry(page); + await activateRetainedComposerTab(page, first.title); + await writeCompactDraft(page, "Other visible draft"); + await activateRetainedComposerTab(page, second.title); + await expect(composerLocator(page)).toHaveValue(draft); + await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible(); + await expectNoCollapsedVisibleComposer(page); + } finally { + await workspace.cleanup(); + } +}); + +async function shrinkVisibleViewport(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 }); + await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422); + await cdp.detach(); +} + +async function panVisibleViewport(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: 4, y: 300 }], + }); + for (let y = 290; y >= 100; y -= 10) { + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: 4, y }], + }); + } + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0); + await cdp.detach(); +} + +test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-visible-viewport-", + title: "Visible viewport composer", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await expectComposerFullscreenFitsVisibleViewport(page); + await shrinkVisibleViewport(page); + await expectComposerFullscreenFitsVisibleViewport(page); + await panVisibleViewport(page); + await expectComposerFullscreenFitsVisibleViewport(page); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index c7868ac94e..5d024de761 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -1,7 +1,46 @@ -import type { Locator } from "@playwright/test"; -import { expect, test } from "../support/fixtures"; -import { expectComposerVisible } from "../support/helpers/composer"; +import type { Locator, Page } from "@playwright/test"; +import { expect, test as base } from "../support/fixtures"; +import { + composerLocator, + expectComposerVisible, + submitMessageWithButton, +} from "../support/helpers/composer"; +import { + type AgentUsageScript, + closeContextWindowSheet, + contextWindowDetails, + expectRefreshButtons, + expiredLogin, + gate, + hoverContextWindowMeter, + leaveContextWindowMeter, + type MockAgentSession, + moveOntoContextWindowDetails, + onPersonalLogin, + onWorkLogin, + openAgent, + pressContextWindowMeter, + qaScreenshot, + refreshUsageCard, + reloadAgent, + scriptAgentUsage, + seedAgentWithContextWindow, + usageCard, +} 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) => { + const agent = await seedAgentWithContextWindow(); + try { + await provide(agent); + } finally { + await agent.cleanup(); + } + }, +}); // Where the progress arc is painted, as its centroid relative to the ring's centre in pixels. // Reads the rendered pixels, so any rotation that does not reach the screen counts as none. @@ -69,3 +108,348 @@ test.describe("context window meter", () => { } }); }); + +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; + +/** Two reports on the agent's login stream in; the details show each card as it arrives. */ +async function expectCardsToStreamIn( + page: Page, + usage: AgentUsageScript, + openDetails: OpenDetails, + shot: Shot, +): Promise { + const [claude, codex] = claudeAndCodexReports(); + const first = gate(); + const second = gate(); + usage.answerNext({ + stream: [first.promise, onWorkLogin(claude!), second.promise, onWorkLogin(codex!)], + }); + const details = await openDetails(page); + await expect(details.getByText("Loading usage...", { exact: true })).toBeVisible(); + await shot("loading"); + + first.open(); + await expect( + usageCard(details, "claude:work").getByText("work@example.com", { exact: true }), + ).toBeVisible(); + await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0); + await expect(usageCard(details, "codex:work")).toHaveCount(0); + await shot("streaming"); + + second.open(); + await expect( + usageCard(details, "codex:work").getByText("Session", { exact: true }), + ).toBeVisible(); + // 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); + return details; +} + +async function expectFailedRequestSentence( + page: Page, + usage: AgentUsageScript, + openDetails: OpenDetails, + shot: Shot, +): Promise { + usage.answerNext({ error: "Unknown agent" }); + await reloadAgent(page); + const details = await openDetails(page); + await expect( + details.getByText("Unable to load usage: Unknown agent", { exact: true }), + ).toBeVisible(); + await shot("error"); +} + +async function expectOnlyContextWindowWithoutUsage( + page: Page, + usage: AgentUsageScript, + openDetails: OpenDetails, + shot: Shot, +): Promise { + usage.stopReportingUsage(); + await reloadAgent(page); + const details = await openDetails(page); + await expect(details.getByText("25% used", { exact: true })).toBeVisible(); + await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0); + await expect(details.getByText("work@example.com", { exact: true })).toHaveCount(0); + await shot("unsupported"); +} + +for (const theme of ["light", "dark"] as const) { + 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, `hover-card-desktop-${theme}-${state}`); + + 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(); + usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] }); + const closedEarly = await hoverContextWindowMeter(page); + await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible(); + await leaveContextWindowMeter(page); + + const resumed = gate(); + const finished = gate(); + usage.answerNext({ + stream: [resumed.promise, onPersonalLogin(claude!), finished.promise], + }); + 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(card, "claude:personal").getByText("personal@example.com", { exact: true }), + ).toBeVisible(); + 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 card = await hoverContextWindowMeter(page); + await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); + await shot("problem"); + }); + + await test.step("a failed request says so in a sentence", async () => { + await expectFailedRequestSentence(page, usage, hoverContextWindowMeter, shot); + }); + + 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(6); + }); + }); + + test(`the context window sheet shows the agent's usage and refreshes it (${theme})`, async ({ + page, + agent, + }) => { + test.setTimeout(240_000); + const [claude] = claudeAndCodexReports(); + const usage = await scriptAgentUsage(page); + await page.emulateMedia({ colorScheme: theme }); + await page.setViewportSize(COMPACT); + await openAgent(page, agent); + const shot: Shot = (state) => qaScreenshot(page, `sheet-compact-${theme}-${state}`); + + 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 = contextWindowDetails(page); + await refreshUsageCard(sheet, "Claude"); + await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); + await shot("problem"); + }); + + await test.step("Close dismisses the sheet", async () => { + await closeContextWindowSheet(page); + }); + + await test.step("a failed request says so in a sentence", async () => { + await expectFailedRequestSentence(page, usage, pressContextWindowMeter, shot); + }); + + await test.step("a host without usage reports shows only the context window", async () => { + await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); + 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/empty-pane-keyboard.spec.ts b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts index 96aac4d0d7..46dc4cf47f 100644 --- a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts +++ b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts @@ -30,7 +30,7 @@ test.describe("New tab keyboard launcher", () => { await waitForWorkspaceTabsVisible(page); const explorer = await openExplorerWithKeyboard(page); - await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible(); + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible(); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/browser/explorer-pane-placement.spec.ts b/packages/app/e2e/browser/explorer-pane-placement.spec.ts index de0eafe677..55f96cc0cf 100644 --- a/packages/app/e2e/browser/explorer-pane-placement.spec.ts +++ b/packages/app/e2e/browser/explorer-pane-placement.spec.ts @@ -25,13 +25,16 @@ function draftTabChip(page: Page): Locator { return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }); } -/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */ +/** Scope the shared tab row to the Explorer dock. */ function explorerTabRow(page: Page): Locator { - return visible(page, "explorer-sidebar-tab-rail"); + return visible(page, "workspace-explorer-sidebar").getByTestId("workspace-tabs-row"); } function mainTabRow(page: Page): Locator { - return visible(page, "workspace-tabs-row"); + return page + .locator('[data-testid^="workspace-pane-"]') + .getByTestId("workspace-tabs-row") + .filter({ visible: true }); } async function selectExplorerChanges(page: Page): Promise { @@ -173,7 +176,7 @@ test.describe("explorer pane tab placement", () => { contentType: "image/png", }); // The split must have taken: agent pane + New main. Explorer keeps its own rail. - await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 }); + await expect(mainTabRow(page)).toHaveCount(2, { timeout: 10_000 }); await expect(explorerTabRow(page)).toHaveCount(1); await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible(); }); @@ -233,14 +236,14 @@ async function closeOnlyDraft(page: Page): Promise { } async function moveOnlyDraftIntoRightSplit(page: Page): Promise { - await page.getByRole("button", { name: "More actions", exact: true }).click(); - await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click(); + await mainTabRow(page).getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: /^Split pane right/ }).click(); const target = await emptyPaneBox(page); await dragChipTo(page, draftTabChip(page), { x: target.x + target.width / 2, y: target.y + target.height / 2, }); - await expect(visible(page, "workspace-tabs-row")).toHaveCount(1); + await expect(mainTabRow(page)).toHaveCount(1); } async function expectNewLauncher(page: Page): Promise { diff --git a/packages/app/e2e/browser/explorer-plugin-menu.spec.ts b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts index 8dc515ef74..a871718b88 100644 --- a/packages/app/e2e/browser/explorer-plugin-menu.spec.ts +++ b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts @@ -3,20 +3,20 @@ import { withExplorerPanelPlugins, expectWorkspacePanelsInExplorerLauncher, openWorkspacePanelFromExplorerMenu, - expectIndependentExplorerPanelToggles, + expectIndependentExplorerPanelTabs, expectExplorerPanelWithFocusedAgent, } from "../support/helpers/explorer-plugin-menu"; -test("Explorer toggles workspace panels independently without inheriting agent context", async ({ +test("Explorer opens and closes workspace panel tabs independently without inheriting agent context", async ({ page, }, testInfo) => { await withExplorerPanelPlugins(page, async (workspace) => { - await test.step("the New Tab launcher offers compatible workspace panels", () => + await test.step("the + menu offers compatible workspace panels", () => expectWorkspacePanelsInExplorerLauncher(page)); - await test.step("the rail menu offers the same workspace panels", () => + await test.step("the + menu opens a workspace panel in Explorer", () => openWorkspacePanelFromExplorerMenu(page, workspace, testInfo)); await test.step("different panels and plugins keep independent selection and closing", () => - expectIndependentExplorerPanelToggles(page, workspace)); + expectIndependentExplorerPanelTabs(page, workspace)); await test.step("focusing an agent does not add agent panels to Explorer", () => expectExplorerPanelWithFocusedAgent(page, workspace, testInfo)); }); diff --git a/packages/app/e2e/browser/explorer-sidebar.spec.ts b/packages/app/e2e/browser/explorer-sidebar.spec.ts index 0d8db71435..c8536c884a 100644 --- a/packages/app/e2e/browser/explorer-sidebar.spec.ts +++ b/packages/app/e2e/browser/explorer-sidebar.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "../support/fixtures"; -import { gotoWorkspace } from "../support/helpers/launcher"; +import { + gotoWorkspace, + pressNewTabShortcut, + expectTabTitleFits, +} from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; import { ensureExplorerSidebar, @@ -11,6 +15,25 @@ function explorerSidebar(page: Parameters[0]) { return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); } +async function expectExplorerActiveTabForeground( + page: Parameters[0], +) { + await openFilesPanel(page); + const main = page.getByTestId("workspace-pane-main"); + await main.hover(); + const activeMainLabel = main + .getByTestId("workspace-tabs-row") + .locator('[aria-selected="true"]') + .getByText("New tab", { exact: true }); + const foreground = await activeMainLabel.evaluate((element) => getComputedStyle(element).color); + const activeFiles = explorerSidebar(page).getByRole("button", { + name: "Browse workspace files", + exact: true, + }); + await expect(activeFiles).toHaveAttribute("aria-selected", "true"); + await expect(activeFiles.getByText("Files", { exact: true })).toHaveCSS("color", foreground); +} + test.describe("Explorer sidebar", () => { test("starts with Files and Changes, switches views, and toggles without changing main", async ({ page, @@ -26,14 +49,14 @@ test.describe("Explorer sidebar", () => { .count(); const explorer = await ensureExplorerSidebar(page); - await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible(); - await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible(); - await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0); + await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible(); + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible(); + await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(1); - await openFilesPanel(page); + await expectExplorerActiveTabForeground(page); await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible(); - await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click(); + await explorer.getByTestId("workspace-tab-changes_tree").click(); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); await page.getByTestId("workspace-explorer-toggle").first().click(); @@ -46,3 +69,181 @@ test.describe("Explorer sidebar", () => { } }); }); + +async function launchExplorerPanel( + page: Parameters[0], + name: string, +) { + const explorer = explorerSidebar(page); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + await page.getByRole("menuitem", { name: new RegExp(`^${name}`) }).click(); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: new RegExp(`^${name}`) })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +async function closeExplorerFilesFromContextMenu( + page: Parameters[0], +) { + const explorer = explorerSidebar(page); + const files = explorer.getByRole("button", { name: "Browse workspace files", exact: true }); + await files.hover(); + await expect(explorer.getByTestId("workspace-files-close")).toHaveCount(0); + await explorer.getByTestId("workspace-tab-changes_tree").hover(); + await expect(explorer.getByTestId("workspace-working-diff-close-changes_tree")).toHaveCount(0); + await files.click({ button: "right", position: { x: 12, y: 13 } }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect(files).toHaveCount(0); +} + +async function expectWorkspaceCloseOnHover(page: Parameters[0]) { + const main = page.getByTestId("workspace-pane-main"); + const tab = main.locator('[data-testid^="workspace-tab-"][aria-selected="true"]'); + const close = main.getByRole("button", { name: "Close", exact: true }); + await explorerSidebar(page).hover(); + await expect(close.locator("..")).toHaveCSS("opacity", "0"); + await tab.hover(); + await expect(close.locator("..")).toHaveCSS("opacity", "1"); +} + +async function closeOtherExplorerTabs(page: Parameters[0]) { + const files = explorerSidebar(page).getByRole("button", { + name: "Browse workspace files", + exact: true, + }); + await files.click({ button: "right", position: { x: 12, y: 13 } }); + const confirmation = page.waitForEvent("dialog").then((dialog) => { + expect(dialog.message()).toContain("close 1 tab"); + return dialog.accept(); + }); + await page.getByRole("menuitem", { name: "Close other tabs", exact: true }).click(); + await confirmation; +} + +test("Explorer keeps Files and Changes close actions in the context menu", async ({ + page, +}, testInfo) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-shared-tabs-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + const main = page.getByTestId("workspace-pane-main"); + const mainTabsBefore = await main.getByTestId("workspace-tabs-row").getByRole("button").count(); + const explorer = await ensureExplorerSidebar(page); + await expectTabTitleFits(page, "Files", { min: 64, max: 90 }); + + await test.step("Explorer's + menu excludes Agent and terminal profiles", async () => { + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0); + await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible(); + const entries = await menu.getByRole("menuitem").allTextContents(); + expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan( + entries.findIndex((entry) => entry.startsWith("Diff")), + ); + await page.keyboard.press("Escape"); + await main.getByTestId("workspace-new-tab-button").click(); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toBeVisible(); + await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + }); + + await test.step("Files and Changes omit X, and + restores Files after context-menu close", async () => { + await expectWorkspaceCloseOnHover(page); + await closeExplorerFilesFromContextMenu(page); + await launchExplorerPanel(page, "Files"); + await expect( + explorer.getByRole("button", { name: "Browse workspace files", exact: true }), + ).toBeVisible(); + await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible(); + }); + + await test.step("standard bulk-close menus affect only Explorer tabs", async () => { + await closeOtherExplorerTabs(page); + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toHaveCount(0); + await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible(); + await expect(main.getByTestId("workspace-tabs-row").getByRole("button")).toHaveCount( + mainTabsBefore, + ); + await launchExplorerPanel(page, "Changes"); + await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); + }); + + await test.step("Cmd+T still belongs to the focused workspace pane", async () => { + await pressNewTabShortcut(page); + await expect( + main.getByTestId("workspace-new-tab-panel").filter({ visible: true }), + ).toBeVisible(); + await expect(explorer.getByTestId("workspace-new-tab-panel")).toHaveCount(0); + }); + + await testInfo.attach("shared-explorer-tabs", { + body: await page.screenshot(), + contentType: "image/png", + }); + } finally { + await workspace.cleanup(); + } +}); + +async function dragAgentIntoExplorer( + page: Parameters[0], + agentId: string, +) { + const source = page + .getByTestId("workspace-pane-main") + .getByTestId(`workspace-tab-agent_${agentId}`); + const destination = explorerSidebar(page).getByTestId("workspace-tab-files"); + const sourceBox = await source.boundingBox(); + const destinationBox = await destination.boundingBox(); + if (!sourceBox || !destinationBox) + throw new Error("Tab drag requires visible source and destination"); + const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 }; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 12, start.y + 4); + await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, { + steps: 20, + }); + await page.mouse.up(); +} + +test("agents can still be dragged into Explorer when absent from its + menu", async ({ + page, +}, testInfo) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + const explorer = await ensureExplorerSidebar(page); + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Dragged agent", + modeId: "load-test", + model: "ten-second-stream", + }); + const main = page.getByTestId("workspace-pane-main"); + await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); + await dragAgentIntoExplorer(page, agent.id); + await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); + await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); + await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await testInfo.attach("agent-in-explorer", { + body: await page.screenshot(), + contentType: "image/png", + }); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts index 9e2e6a2378..95a5a62bc3 100644 --- a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts +++ b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts @@ -64,10 +64,8 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => const toggle = visible(page, "workspace-explorer-toggle").first(); await toggle.click(); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible(); - await expect( - explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"), - ).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-changes_tree")).toBeVisible(); await toggle.click(); await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 }); @@ -102,7 +100,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => await test.step("open the explorer so both sides are showing", async () => { await toggle.click(); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible(); await expect(agentList).toHaveCount(1); }); @@ -116,7 +114,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => await page.keyboard.press(`${modifier}+.`); await expect(agentList).toHaveCount(1, { timeout: 15_000 }); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible(); }); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index d426a03fb4..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,94 @@ 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-", + title: "Relative line references", + initialPrompt: "Show both references", + featureValues: { + mockAssistantResponse: "First `src/router.go:46`. Then `src/router.go:403`.", + }, + }); + try { + await mkdir(path.join(session.cwd, "src")); + await writeFile( + path.join(session.cwd, "src/router.go"), + Array.from({ length: 450 }, (_, n) => `// line ${n + 1}`).join("\n"), + ); + await openAgentRoute(page, session); + for (const line of [46, 403, 46]) { + await page + .getByTestId(`workspace-tab-agent_${session.agentId}`) + .filter({ visible: true }) + .click(); + await page.getByText(`src/router.go:${line}`, { exact: true }).click(); + await expect(page.getByLabel(`Line ${line}, column 1`)).toBeVisible(); + } + await page.screenshot({ path: test.info().outputPath("relative-line-target.png") }); + } finally { + await session.cleanup(); + } + }); + test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({ page, }) => { @@ -279,7 +376,12 @@ test.describe("CodeMirror workspace file editing", () => { await openAgentRoute(page, session); await openWorkspaceFile(page, "target.ts"); - await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2); + await expect( + page + .locator('[data-testid^="workspace-pane-"]') + .getByTestId("workspace-tabs-row") + .filter({ visible: true }), + ).toHaveCount(2); await page .getByTestId(`workspace-tab-agent_${session.agentId}`) @@ -591,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, @@ -722,3 +858,24 @@ test.describe("CodeMirror workspace file editing", () => { await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible(); }); }); + +async function openAssistantMarkdownLink(page: Page, name: string): Promise { + await page.getByRole("link", { name, exact: true }).first().click(); +} + +async function returnToAssistant(page: Page, agentId: string): Promise { + await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click(); +} + +async function openAssistantInlinePath(page: Page, token: string): Promise { + await page.getByText(token, { exact: true }).click(); +} + +async function expectPreviewText(page: Page, text: string): Promise { + await expect( + page + .getByTestId("workspace-file-pane") + .filter({ visible: true }) + .getByText(text, { exact: true }), + ).toBeVisible(); +} diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts index bbc73b797e..faf7285a62 100644 --- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, type Page } from "@playwright/test"; import { test } from "../support/fixtures"; -import { openFileExplorer } from "../support/helpers/file-explorer"; +import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer"; import { openChangesPanel } from "../support/helpers/workspace-tabs"; import { gotoWorkspace } from "../support/helpers/launcher"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; let workspace: SeededWorkspace; @@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) => await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0); await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0); }); + +test("downloads a file over direct TCP with its original filename and bytes", async ({ + page, +}, testInfo) => { + const fileName = "rapport données.bin"; + const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]); + await writeFile(path.join(workspace.workspaceDirectory, fileName), source); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const downloaded = await downloadFileFromExplorer(page, fileName); + const url = new URL(downloaded.url()); + expect(url.protocol).toBe("http:"); + expect(url.port).toBe(getE2EDaemonPort()); + expect(url.pathname).toBe("/api/files/download"); + expect(url.searchParams.get("token")).toBeTruthy(); + expect(downloaded.suggestedFilename()).toBe(fileName); + const savedFile = testInfo.outputPath(fileName); + await downloaded.saveAs(savedFile); + expect(await downloaded.failure()).toBeNull(); + expect(await readFile(savedFile)).toEqual(source); +}); diff --git a/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts b/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts new file mode 100644 index 0000000000..60e13b70a6 --- /dev/null +++ b/packages/app/e2e/browser/hoplite-model-discovery.real.spec.ts @@ -0,0 +1,110 @@ +import path from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { + closeModelPicker, + drillIntoProvider, + openModelPicker, +} from "../support/helpers/agent-profiles"; +import { expectComposerVisible, expectComposerFocused } from "../support/helpers/composer"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { getServerId } from "../support/helpers/server-id"; +import { openSettingsHost, openSettingsHostSection } from "../support/helpers/settings"; + +// Use an existing repository linked to the signed-in user's Hoplite project. +const cwd = process.env.E2E_HOPLITE_CWD ?? path.resolve(__dirname, "../../../.."); + +test.use({ + viewport: { width: 1440, height: 1000 }, + e2eDaemonConfig: { version: 1, agents: { providers: { hoplite: { enabled: true } } } }, +}); + +async function openHopliteSettings(page: Page): Promise { + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await page.getByRole("button", { name: "Hoplite provider details", exact: true }).click(); +} + +async function expectEmptyGlobalCatalog(page: Page, client: DaemonClient): Promise { + const models = await client.listProviderModels("hoplite"); + expect(models.error).toBeNull(); + expect(models.models).toEqual([]); + const snapshot = await client.getProvidersSnapshot(); + expect(snapshot.entries.find((entry) => entry.provider === "hoplite")).toMatchObject({ + status: "ready", + models: [], + }); + await expect(page.getByText("No models detected", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0); +} + +async function refreshGlobalCatalog(page: Page): Promise { + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeEnabled({ + timeout: 120_000, + }); +} + +async function openWorkspaceModels(page: Page, workspaceId: string): Promise { + await gotoWorkspace(page, workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await expectComposerFocused(page); + await openModelPicker(page); + await page.getByTestId("sheet-header-back").click(); + await drillIntoProvider(page, "hoplite"); +} + +test("real Hoplite has an empty global catalog and discovers models in its linked workspace", async ({ + page, + e2eWorkerClient, +}, testInfo) => { + test.setTimeout(180_000); + const client = await connectDaemonClient({ clientIdPrefix: "hoplite-discovery" }); + const created = await e2eWorkerClient.createWorkspace({ + source: { kind: "directory", path: cwd }, + title: "Hoplite discovery QA", + }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + try { + await test.step("global settings succeed with no models, including refresh", async () => { + await openHopliteSettings(page); + await expectEmptyGlobalCatalog(page, client); + await refreshGlobalCatalog(page); + await expectEmptyGlobalCatalog(page, client); + await page.screenshot({ path: testInfo.outputPath("hoplite-global-empty.png") }); + }); + + await test.step("the linked workspace discovers real models", async () => { + const models = await client.listProviderModels("hoplite", { cwd }); + expect(models.error).toBeNull(); + expect(models.models!.length).toBeGreaterThan(0); + await testInfo.attach("workspace-models", { + body: JSON.stringify(models, null, 2), + contentType: "application/json", + }); + await openWorkspaceModels(page, workspace.id); + await expect(page.getByText(models.models![0]!.label, { exact: true }).last()).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("hoplite-workspace-models.png") }); + await closeModelPicker(page); + }); + + await test.step("workspace discovery does not populate the global catalog", async () => { + await openHopliteSettings(page); + await expectEmptyGlobalCatalog(page, client); + await refreshGlobalCatalog(page); + await expectEmptyGlobalCatalog(page, client); + const models = await client.listProviderModels("hoplite", { cwd }); + expect(models.error).toBeNull(); + expect(models.models!.length).toBeGreaterThan(0); + }); + } finally { + await client.close(); + await e2eWorkerClient.removeProject(workspace.projectId); + } +}); diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index 9be6d79418..e9d43ced27 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -337,7 +337,7 @@ test.describe("Tab transitions (no flash)", () => { expect(counts.every((count) => count === initialCount)).toBe(true); expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2); - await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 }); + await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 }); }); test("Terminal transition completes within visual budget", async ({ page }) => { diff --git a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts index e7dbde3bd9..906efcfb42 100644 --- a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts @@ -1,7 +1,14 @@ +import { + composerLocator, + enterComposerFullscreen, + exitComposerFullscreen, + resizeComposerViewport, +} from "../support/helpers/composer"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { + delayBrowserWorkspaceCreatedResponse, expectNewWorkspaceDraft, expectNewWorkspaceProjectSelected, fillNewWorkspaceDraft, @@ -134,3 +141,45 @@ test.describe("New workspace composer draft", () => { } }); }); + +test("fullscreen creation returns inline and keeps submission locked when reopened", async ({ + page, +}) => { + const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" }); + const delay = await delayBrowserWorkspaceCreatedResponse(page); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, project); + await resizeComposerViewport(page, "portrait"); + await fillNewWorkspaceDraft(page, DRAFT); + await enterComposerFullscreen(page); + await page.getByRole("button", { name: "Create", exact: true }).click(); + await delay.waitForCreateRequest(); + + await test.step("submission returns inline with the original draft and spinner", async () => { + await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0); + await expect(composerLocator(page)).toHaveValue(DRAFT); + await expect(composerLocator(page)).not.toBeEditable(); + await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled(); + await expect(page.getByRole("progressbar")).toBeVisible(); + }); + await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => { + await enterComposerFullscreen(page); + await expect(page.getByTestId("composer-fullscreen")).toBeVisible(); + await expect(composerLocator(page)).toHaveValue(DRAFT); + await expect(composerLocator(page)).not.toBeEditable(); + await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled(); + await expect(page.getByRole("progressbar")).toBeVisible(); + await exitComposerFullscreen(page); + }); + delay.release(); + await page.waitForURL((url) => url.pathname.includes("/workspace/")); + await expect(composerLocator(page)).toHaveValue(""); + await expect(composerLocator(page)).toBeEditable(); + expect(delay.agentRequests).toHaveLength(1); + } finally { + delay.release(); + await project.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts index d4444d6f22..22b2b8a6c5 100644 --- a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts +++ b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts @@ -6,6 +6,15 @@ import { selectWorkspaceIsolation, } from "../support/helpers/new-workspace"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + composerLocator, + expectComposerVisible, + fillComposerDraft, + enterComposerFullscreen, + resizeComposerViewport, + expectComposerFullscreenControls, +} from "../support/helpers/composer"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; type WebSocketMessage = string | Buffer; @@ -196,3 +205,52 @@ test.describe("New Workspace dictation submit", () => { } }); }); + +async function startDictationWithShortcut(page: Page): Promise { + await composerLocator(page).press("Control+d"); +} + +test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "fullscreen-dictation-", + title: "Fullscreen dictation shortcut", + }); + await installSyntheticMicrophone(page); + const harness = await installDictationFailureHarness(page); + const draft = "First line\nSecond line\nThird line"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await fillComposerDraft(page, draft); + await enterComposerFullscreen(page); + await resizeComposerViewport(page, "landscape"); + await expectComposerFullscreenControls(page); + await startDictationWithShortcut(page); + await harness.waitForAudio(); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Insert transcription", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Insert transcription and send", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Cancel dictation", exact: true }).click(); + await expect(composerLocator(page)).toBeEditable(); + await expect(composerLocator(page)).toHaveValue(draft); + await resizeComposerViewport(page, "portrait"); + await enterComposerFullscreen(page); + await resizeComposerViewport(page, "landscape"); + await composerLocator(page).press("Control+Shift+d"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await expect( + page.getByText("Realtime voice is disabled in this test.", { exact: true }), + ).toBeVisible(); + await expect(composerLocator(page)).toHaveValue(draft); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts index 16b277b537..d393966ee8 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -1,7 +1,18 @@ -import { expect, test } from "../support/fixtures"; +import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -import { openNewWorkspaceComposer } from "../support/helpers/new-workspace"; +import { + expectNewWorkspaceDraft, + fillNewWorkspaceDraft, + openNewWorkspaceComposer, +} from "../support/helpers/new-workspace"; +import { + composerLocator, + enterComposerFullscreen, + exitComposerFullscreen, + expectComposerFullscreenControls, + resizeComposerViewport, +} from "../support/helpers/composer"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { expectTerminalLaunchSelected, @@ -9,13 +20,12 @@ import { expectTerminalPreviewCommand, expectWorkspaceOpensWithTerminalTab, fillTerminalPrompt, - seedTerminalProfiles, + launchTrigger, selectLaunchOption, submitTerminalLaunch, terminalLaunchSubmit, terminalPromptInput, type TerminalProfile, - type TerminalProfileSeed, } from "../support/helpers/new-workspace-launch"; // Someone who wants a terminal agent instead of a chat: they pick a profile @@ -42,17 +52,18 @@ const BARE_PROFILE: TerminalProfile = { args: ["-c", "echo bare-launch-static-line; exec cat"], }; +test.use({ + e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } }, +}); + test.describe("New workspace: launching a terminal", () => { let workspace: SeededWorkspace; - let profileSeed: TerminalProfileSeed; test.beforeEach(async () => { workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" }); - profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]); }); test.afterEach(async () => { - await profileSeed.restore(); await workspace?.cleanup(); }); @@ -97,4 +108,165 @@ test.describe("New workspace: launching a terminal", () => { await expectTerminalOutputContains(page, "bare-launch-static-line"); }); }); + + test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({ + page, + }) => { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + await page.setViewportSize({ width: 390, height: 844 }); + const prompt = "First replacement line\nSecond replacement line\nThird replacement line"; + const selectProfile = async (profile: TerminalProfile) => { + await launchTrigger(page).click(); + const option = page.getByRole("menuitem").filter({ hasText: profile.name }); + await option.click(); + await expect(option).not.toBeVisible(); + }; + const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true }); + + await test.step("restore a multiline prompt after a read-only profile", async () => { + await selectProfile(PROMPT_PROFILE); + await fillTerminalPrompt(page, prompt); + await expect(fullscreen).toBeVisible(); + await selectProfile(BARE_PROFILE); + await expect(terminalPromptInput(page)).toHaveCount(0); + await selectProfile(PROMPT_PROFILE); + await expect(terminalPromptInput(page)).toHaveValue(prompt); + await expect(fullscreen).toBeVisible(); + }); + + await test.step("restore the locally edited prompt and measure subsequent typing", async () => { + await fillTerminalPrompt(page, "Short draft"); + await expect(fullscreen).toHaveCount(0); + await selectProfile(BARE_PROFILE); + await selectProfile(PROMPT_PROFILE); + await expect(terminalPromptInput(page)).toHaveValue("Short draft"); + await expect(fullscreen).toHaveCount(0); + await terminalPromptInput(page).press("End"); + await terminalPromptInput(page).press("Shift+Enter"); + await terminalPromptInput(page).pressSequentially("Second"); + await terminalPromptInput(page).press("Shift+Enter"); + await terminalPromptInput(page).pressSequentially("Third"); + await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird"); + await expect(fullscreen).toBeVisible(); + }); + }); +}); + +async function expectNewWorkspaceHeaderHidden(page: Page): Promise { + await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0); +} + +async function expectNewWorkspaceHeaderVisible(page: Page): Promise { + await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible(); +} + +/** + * Fullscreen fills the content area top to bottom and out to the right edge (a pinned + * sidebar keeps its column), covers the form, and shows only exit and the mode's submit. + */ +async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise { + const frame = page.getByTestId("composer-fullscreen"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible(); + await expect(frame.getByRole("button")).toHaveCount(2); + await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible(); + await expectNewWorkspaceHeaderHidden(page); + const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!; + await expect + .poll(() => + frame.evaluate( + (element, point) => { + const viewport = window.visualViewport!; + const box = element.getBoundingClientRect(); + const covering = document.elementFromPoint(point.x, point.y); + return { + fillsHeight: + Math.abs(box.top - viewport.offsetTop) < 1 && + Math.abs(box.height - viewport.height) < 1, + reachesRightEdge: + Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 && + box.left >= viewport.offsetLeft, + coversForm: element.contains(covering), + }; + }, + { x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 }, + ), + ) + .toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true }); +} + +test.describe("New workspace: fullscreen composer across the compact breakpoint", () => { + let workspace: SeededWorkspace; + + test.beforeEach(async () => { + workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" }); + }); + + test.afterEach(async () => { + await workspace?.cleanup(); + }); + + test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({ + page, + }) => { + test.setTimeout(90_000); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + const projectPicker = page.getByRole("button", { name: "Workspace project" }); + + await test.step("chat: fullscreen covers the centered form and exits back into it", async () => { + await resizeComposerViewport(page, "portrait"); + const draft = "Chat first line\nChat second line\nChat third line"; + await fillNewWorkspaceDraft(page, draft); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectNewWorkspaceHeaderHidden(page); + await resizeComposerViewport(page, "landscape"); + await expectComposerFullscreenControls(page); + await expectNewWorkspaceFullscreen(page, "Create"); + // The desktop form's own import button is dropped from the fullscreen slot. + await expect( + page.getByTestId("new-workspace-import-session").filter({ visible: true }), + ).toHaveCount(0); + await composerLocator(page).press("End"); + await composerLocator(page).pressSequentially(" wide"); + await exitComposerFullscreen(page); + await expectNewWorkspaceHeaderVisible(page); + await expect(projectPicker).toBeVisible(); + await expect( + page.getByTestId("new-workspace-import-session").filter({ visible: true }), + ).toBeVisible(); + await expectNewWorkspaceDraft(page, `${draft} wide`); + }); + + // Terminal mode and its prompt are screen state, and the shell remounts the screen + // at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone + // layout only. + await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => { + await resizeComposerViewport(page, "portrait"); + // The compact launch control opens a sheet, not the desktop menu. + await launchTrigger(page).click(); + const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name }); + await option.click(); + await expect(option).not.toBeVisible(); + await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); + const prompt = "Prompt first line\nPrompt second line\nPrompt third line"; + await fillTerminalPrompt(page, prompt); + await enterComposerFullscreen(page); + await expect(terminalPromptInput(page)).toBeVisible(); + await expectNewWorkspaceFullscreen(page, "Launch"); + await exitComposerFullscreen(page); + await expectNewWorkspaceHeaderVisible(page); + await expect(projectPicker).toBeVisible(); + await expect(terminalPromptInput(page)).toHaveValue(prompt); + }); + }); }); diff --git a/packages/app/e2e/browser/pair-device-relay.spec.ts b/packages/app/e2e/browser/pair-device-relay.spec.ts index 8c4e306a1e..dbd3814d38 100644 --- a/packages/app/e2e/browser/pair-device-relay.spec.ts +++ b/packages/app/e2e/browser/pair-device-relay.spec.ts @@ -1,6 +1,8 @@ import { test } from "../support/fixtures"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import { + expectPairingOffer, + expectCompletePairingQr, expectRelayConsent, openPairDeviceModal, preparePairingHost, @@ -18,3 +20,20 @@ test("opens relay consent in browser web", async ({ page }) => { await daemon.close(); } }); + +test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => { + const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", { + mutableRelay: { enabled: true }, + }); + try { + await preparePairingHost(page, daemon); + await page.setViewportSize({ width: 320, height: 568 }); + await openPairDeviceModal(page); + await expectPairingOffer(page); + + await expectCompletePairingQr(page); + await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") }); + } finally { + await daemon.close(); + } +}); diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index bd53c59272..84d786033a 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -1,5 +1,5 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; @@ -36,9 +36,18 @@ function ModalBody({ onSaved }) { ; } -function Surface() { +function Surface({ playAudio }) { + const [audioStatus, setAudioStatus] = useState("Audio ready"); + async function play(base64) { + setAudioStatus("Playing audio"); + try { await playAudio({ base64, mimeType: "audio/wav" }); setAudioStatus("Audio finished"); } + catch { setAudioStatus("Audio rejected"); } + } const [open, setOpen] = useState(false); return + {audioStatus} + play(AUDIO_BASE64)}>Play plugin audio + play("UklGRg==")}>Play invalid audio setOpen(true)}> @@ -59,7 +68,7 @@ function Surface() { } export default function contribute(plugin) { - plugin.addSurface("main", Surface); + plugin.addSurface("main", () => ); plugin.addSidebarItem({ id: "main", title: "Host UI", @@ -123,7 +132,11 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), ); - await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE); + const audio = await readFile(path.resolve(__dirname, "../../assets/audio/thinking-tone.wav")); + await writeFile( + path.join(directory, "index.client.tsx"), + `const AUDIO_BASE64 = ${JSON.stringify(audio.toString("base64"))};\n${PLUGIN_SOURCE}`, + ); try { await client.patchDaemonConfig({ pluginsEnabled: true }); @@ -131,6 +144,12 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( await useNonCompactLayout(page); await openHostUiPlugin(page); + await test.step("plugin audio finishes, rejects corrupt files, and recovers", async () => { + await playPluginAudio(page, "Play plugin audio", "Audio finished"); + await playPluginAudio(page, "Play invalid audio", "Audio rejected"); + await playPluginAudio(page, "Play plugin audio", "Audio finished"); + }); + await test.step("non-compact layouts use a centered dialog", async () => { await openPluginModal(page); await expectCenteredPluginDialog(page); @@ -153,3 +172,8 @@ test("plugin modal adapts its presentation and preserves host contexts", async ( await rm(directory, { recursive: true, force: true }); } }); + +async function playPluginAudio(page: Page, button: string, result: string) { + await page.getByRole("button", { name: button, exact: true }).click(); + await expect(page.getByText(result, { exact: true })).toBeVisible(); +} diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 7e32f61350..84b4cc63a2 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,3 +1,4 @@ +import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows( await installPlugin(page, missingSource); await expect(page.getByTestId("plugin-management-feedback")).toContainText( - "Plugin source is neither an existing directory nor a Git URL", + `Plugin directory does not exist: ${missingSource}`, ); await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource); await capturePluginInstallForm(page, testInfo, "compact-error"); @@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) { await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible(); await expect(page.getByLabel("npm-review running")).toHaveCount(0); } + +test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => { + const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n"); + await installPluginWithUntrustedDescription(pluginEnvironment, description); + await gotoAppShell(page); + await openPluginSettings(page); + const text = page.getByText(description, { exact: true }); + await expect(text).toBeVisible(); + // This field must remain a text node, with no Markdown links, images, or HTML children. + expect(await text.evaluate((element) => element.childElementCount)).toBe(0); +}); + +async function installPluginWithUntrustedDescription( + environment: PluginEnvironment, + description: string, +) { + const directory = await createDirectoryPlugin( + environment.directory, + "plain-text-plugin", + description, + "Plain text plugin", + ); + await environment.client.installPluginSource({ source: directory }); +} diff --git a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts index 49c781f84d..900fb42db9 100644 --- a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts +++ b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts @@ -20,6 +20,7 @@ import { import { leaveSettings, openSidebarNavSettings, + seedSidebarFooterPreferences, setFooterItemVisible, } from "../support/helpers/sidebar-nav-settings"; import { seedWorkspace } from "../support/helpers/seed-client"; @@ -154,8 +155,9 @@ test.describe("Plugin sidebar items", () => { page, }) => { test.setTimeout(180_000); - // The Usage item shows only with summary data. + // The Usage item shows only with summary data, once it is turned on. await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); await page.setViewportSize(WIDE); await gotoWorkspace(page, workspaceId); const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys"); 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 11c7cc52b7..4c0162e8d8 100644 --- a/packages/app/e2e/browser/sidebar-help.spec.ts +++ b/packages/app/e2e/browser/sidebar-help.spec.ts @@ -155,6 +155,18 @@ test("keeps diagnostics available from Settings after globalizing the sheet", as test.describe("compact sidebar help", () => { test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + test("footer buttons provide finger-sized targets and accept taps beyond the glyph", async ({ + page, + }) => { + await gotoAppShell(page); + await openCompactSidebar(page); + await expectFingerSizedFooterTargets(page); + await tapHelpNearTargetEdge(page); + await expect(page.getByTestId("sidebar-help-menu")).toBeVisible(); + await page.getByTestId("sidebar-help-diagnostics").click(); + await expectDiagnosticReport(page); + }); + test("offers diagnostics without advertising disabled keyboard shortcuts", async ({ page }) => { await gotoAppShell(page); await page.getByRole("button", { name: "Open menu", exact: true }).click(); @@ -165,3 +177,29 @@ test.describe("compact sidebar help", () => { await expectDiagnosticReport(page); }); }); + +async function openCompactSidebar(page: Page): Promise { + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + 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(); + // 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); + } +} + +async function tapHelpNearTargetEdge(page: Page): Promise { + const button = page.getByRole("button", { name: "Help and support", exact: true }); + await button.tap({ position: { x: 40, y: 40 } }); +} diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index 499b67e0bf..a17480b740 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -34,7 +34,10 @@ import { test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => { test.setTimeout(120_000); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); - await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]); + await seedSidebarFooterPreferences(page, [ + { key: "add-project", visible: false }, + { key: "usage", visible: true }, + ]); await page.setViewportSize({ width: 1440, height: 900 }); await gotoAppShell(page); await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); @@ -188,6 +191,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => { await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); // Keys for the fixed footer icon buttons are ignored. await seedSidebarFooterPreferences(page, [ + { key: "usage", visible: true }, { key: "help", visible: false }, { key: "hosts", visible: false }, { key: "import", visible: false }, 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 b588add807..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-", @@ -572,18 +623,21 @@ test.describe("Half-screen desktop layout", () => { await openFilesPanel(page); const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); - await expect( - page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }), - ).toBeVisible(); + await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible(); await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar"); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); - await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible(); - await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1); + await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + await expect( + page + .locator('[data-testid^="workspace-pane-"]') + .getByTestId("workspace-tabs-row") + .filter({ visible: true }), + ).toHaveCount(1); await explorerToggle.click(); - await expect( - page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }), - ).toHaveCount(0); + await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount( + 0, + ); await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar"); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); } finally { diff --git a/packages/app/e2e/browser/terminal-find.spec.ts b/packages/app/e2e/browser/terminal-find.spec.ts index 00f80ff4cb..91e8e5826a 100644 --- a/packages/app/e2e/browser/terminal-find.spec.ts +++ b/packages/app/e2e/browser/terminal-find.spec.ts @@ -199,6 +199,49 @@ async function quitVim(page: Page) { }); } +interface TerminalChord { + key: string; + bytes: string; +} + +async function openKeyboardCapture(page: Page) { + await test.step("Open a real terminal process that reports received bytes", async () => { + const terminal = await harness.createTerminal({ + name: "Keyboard bytes", + command: process.execPath, + args: [ + "-e", + ` + process.stdin.setRawMode(true); + let received = Buffer.alloc(0); + process.stdin.on('data', chunk => { + received = Buffer.concat([received, chunk]); + process.stdout.write('\\r\\nBYTES:' + received.toString('hex') + ':END\\r\\n'); + }); + console.log('KEYBOARD_READY'); + `, + ], + }); + await harness.openTerminal(page, { terminalId: terminal.id }); + await expect.poll(() => getTerminalBufferText(page)).toContain("KEYBOARD_READY"); + }); +} + +async function expectTerminalChords(page: Page, chords: TerminalChord[]) { + let received = ""; + for (const chord of chords) { + await test.step(`${chord.key} sends ${JSON.stringify(chord.bytes)}`, async () => { + await pressTerminalShortcut(page, chord.key); + // A printable sentinel proves delivery even for chords expected to send nothing. + await input(page).pressSequentially("x"); + received += Buffer.from(chord.bytes + "x").toString("hex"); + await expect.poll(() => getTerminalBufferText(page)).toContain(`BYTES:${received}:END`); + }); + } + await recordTerminalEvidence("keyboard-bytes", await getTerminalBufferText(page)); + await page.screenshot({ path: test.info().outputPath("keyboard-chords.png") }); +} + let harness: TerminalE2EHarness; test.beforeEach(async () => { harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" }); @@ -213,6 +256,28 @@ test.describe("macOS terminal shortcuts", () => { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", }); + test("encodes editing chords without confusing Control and Command", async ({ page }) => { + await openKeyboardCapture(page); + await expectTerminalChords(page, [ + { key: "Meta+ArrowLeft", bytes: "\x01" }, + { key: "Meta+ArrowRight", bytes: "\x05" }, + { key: "Alt+ArrowLeft", bytes: "\x1bb" }, + { key: "Alt+ArrowRight", bytes: "\x1bf" }, + { key: "Meta+Backspace", bytes: "\x15" }, + { key: "Control+c", bytes: "\x03" }, + { key: "Control+a", bytes: "\x01" }, + { key: "Control+e", bytes: "\x05" }, + { key: "Control+v", bytes: "\x16" }, + { key: "Control+f", bytes: "\x06" }, + { key: "ArrowLeft", bytes: "\x1b[D" }, + { key: "ArrowRight", bytes: "\x1b[C" }, + { key: "Alt+Backspace", bytes: "\x1b\x7f" }, + { key: "Control+Alt+ArrowLeft", bytes: "\x1b[1;7D" }, + ]); + await pressTerminalShortcut(page, "Meta+f"); + await expect(query(page)).toBeFocused(); + }); + test("sends Control+f to cat and opens Find with Meta+f", async ({ page }) => { await openControlCharacterTerminal(page); await pressTerminalShortcut(page, "Control+f"); @@ -232,7 +297,40 @@ test.describe("macOS terminal shortcuts", () => { }); }); +async function useLinuxKeyboardPlatform(page: Page) { + // A user agent alone leaves navigator.platform as MacIntel on macOS hosts. + // Emulate both browser identity fields before the app reads its keyboard policy. + 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", + }); +} + +test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }) => { + await useLinuxKeyboardPlatform(page); + await openKeyboardCapture(page); + await expectTerminalChords(page, [ + { key: "Meta+ArrowLeft", bytes: "" }, + { key: "Meta+ArrowRight", bytes: "" }, + { key: "Alt+ArrowLeft", bytes: "\x1b[1;3D" }, + { key: "Alt+ArrowRight", bytes: "\x1b[1;3C" }, + { key: "Meta+Backspace", bytes: "\x7f" }, + { key: "Control+c", bytes: "\x03" }, + { key: "Control+a", bytes: "\x01" }, + { key: "Control+e", bytes: "\x05" }, + { key: "Control+ArrowLeft", bytes: "\x1b[1;5D" }, + { key: "Control+ArrowRight", bytes: "\x1b[1;5C" }, + { key: "Control+Backspace", bytes: "\x08" }, + { key: "Alt+Backspace", bytes: "\x1b\x7f" }, + ]); + await pressTerminalShortcut(page, "Control+f"); + await expect(query(page)).toBeFocused(); +}); + test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => { + await useLinuxKeyboardPlatform(page); await openControlCharacterTerminal(page); await pressTerminalShortcut(page, "Control+f"); await expect(query(page)).toBeFocused(); diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts similarity index 71% rename from packages/app/e2e/browser/usage-screen.spec.ts rename to packages/app/e2e/browser/usage-modal.spec.ts index 2ca4e50830..a923971d09 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -8,6 +8,7 @@ import { gotoAppShell } from "../support/helpers/app"; import { addConnectedHostAndReload } from "../support/helpers/hosts"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import { getServerId } from "../support/helpers/server-id"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { installUsageReportsFixture, type UsageListRequest, @@ -17,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, @@ -57,6 +62,11 @@ test.use({ }, }); +// These tests read the sidebar summary and pin windows, which both need the Usage item on. +test.beforeEach(async ({ page }) => { + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); +}); + function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] { return usage.listRequests().filter((request) => request.forceRefresh); } @@ -90,7 +100,41 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry { }; } -test.describe("usage screen", () => { +function installReversedUsageRefresh(page: Page) { + const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)]; + const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)]; + return installUsageReportsFixture(page, { + lists: [(request) => (request.forceRefresh ? refreshed : initial)], + }); +} + +async function openUsageSummaryAtWidth(page: Page, width: number) { + await page.setViewportSize({ width, height: 900 }); + await gotoAppShell(page); + if (width === 390) await openCompactSidebar(page); + await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]); + await openUsageFromItem(page); +} + +async function expectUsageProviderOrder(page: Page, summary: string[]) { + const group = page.getByTestId(`usage-host-${getServerId()}`); + await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([ + "opencode plan", + "openai plan", + ]); + await expectPinnedUsage(page, summary); +} + +async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) { + const requestCount = usage.listRequests().length; + await refreshAllUsage(page); + await usage.waitForListRequests(requestCount + 1); + const group = page.getByTestId(`usage-host-${getServerId()}`); + await expect(group.getByText("41%", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled(); +} + +test.describe("usage modal", () => { test("opens from the sidebar on the host's reports, with no host filter for one host", async ({ page, }) => { @@ -126,14 +170,13 @@ test.describe("usage screen", () => { }); await gotoAppShell(page); - await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); - await expect(page).toHaveURL(/\/usage$/); + await openUsageFromItem(page); await usage.waitForListRequests(1); const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(hostFilter(page)).toHaveCount(0); - await qaScreenshot(page, "phase7-usage-screen-one-host"); + await qaScreenshot(page, "usage-modal-one-host"); await expect(group.getByText("31%")).toBeVisible(); await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); await expect(group.getByText("Unavailable", { exact: true })).toBeVisible(); @@ -143,6 +186,21 @@ test.describe("usage screen", () => { await expect(group.getByTestId("usage-freshness")).toHaveCount(0); }); + for (const width of [1440, 390]) { + test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({ + page, + }) => { + const usage = await installReversedUsageRefresh(page); + await openUsageSummaryAtWidth(page, width); + await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]); + await qaScreenshot(page, `usage-order-${width}-before-refresh`); + + await refreshUsageAndWaitForReports(page, usage); + await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]); + await qaScreenshot(page, `usage-order-${width}-after-refresh`); + }); + } + test("refreshes one report from its card", async ({ page }) => { test.setTimeout(120_000); const serverId = getServerId(); @@ -168,7 +226,7 @@ test.describe("usage screen", () => { windows: [{ id: "weekly", label: "Weekly", usedPct }], }, }); - // The sidebar summary and the screen each load reports; only a card's Refresh forces one. + // The sidebar summary and the modal each load reports; only a card's Refresh forces one. const usage = await installUsageReportsFixture(page, { lists: [ (request) => @@ -179,7 +237,7 @@ test.describe("usage screen", () => { }); await gotoAppShell(page); - await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + await openUsageFromItem(page); const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 }); @@ -216,7 +274,9 @@ test.describe("usage screen", () => { }); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/usage"); + await gotoAppShell(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); await usage.waitForListRequests(1); const group = page.getByTestId(`usage-host-${serverId}`); @@ -230,19 +290,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); @@ -265,7 +325,7 @@ test.describe("usage screen", () => { // Nothing picked and no workspace open: the first host. await expectPinnedUsage(page, ["31% Weekly"]); - await usageItem(page).click(); + await openUsageFromItem(page); await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible(); await hostFilter(page).click(); @@ -274,7 +334,7 @@ test.describe("usage screen", () => { page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); await expect(hostFilter(page)).toContainText("Secondary box"); - await qaScreenshot(page, "usage-screen-picked-host"); + await qaScreenshot(page, "usage-modal-picked-host"); await expectPinnedUsage(page, ["12% Weekly"]); // The e2e seed resets the host list on every load, so reopening re-adds the second host. @@ -284,6 +344,7 @@ test.describe("usage screen", () => { port: secondary.port, }); await expectPinnedUsage(page, ["12% Weekly"]); + await openUsageFromItem(page); await expect( page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); @@ -312,16 +373,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, / }); @@ -358,7 +419,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => { try { await page.setViewportSize({ width: 390, height: 844 }); await gotoAppShell(page); - await openUsage(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); const card = page.getByTestId("usage-report-login-journey:account"); await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2); await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1"); @@ -405,7 +467,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) { await page.setViewportSize({ width: 1440, height: 900 }); await gotoAppShell(page); await expect(usageItem(page)).toBeVisible(); - await usageItem(page).click(); + await openUsageFromItem(page); await qaScreenshot(page, `codex-${shape}-card-and-summary`); await expectCodexReportedWindows(page, shape); await qaScreenshot(page, `codex-${shape}-pins`); @@ -420,7 +482,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"; @@ -431,45 +493,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 }]); }); @@ -484,3 +530,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 f42b438067..27fac6691c 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -2,9 +2,15 @@ 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 { + expectFooterItemSetting, expectFooterSeparator, leaveSettings, openSidebarNavSettings, @@ -13,21 +19,29 @@ import { import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, + closeUsage, + closeUsageFromBackdrop, + closeUsageOptions, expectNoUsageItem, - expectOnUsageScreen, expectPinnedUsage, - leaveUsageScreen, + expectSummaryInSidebar, + expectUnpinnableRows, openCompactSidebar, + openUsageFromIcon, + openUsageFromItem, pinRow, + scrollUsageToWindow, + setSummaryInSidebar, openUsageOptions, showUsageAs, togglePin, usageItem, - usageSheet, + usageModal, installTallUsageSource, } from "../support/helpers/usage-sidebar-item"; import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { waitForSettledPosition } from "../support/helpers/sheet-layout"; +import { createTempGitRepo } from "../support/helpers/workspace"; const WIDE = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; @@ -57,32 +71,108 @@ 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 between their first and last windows on ${size}`, async ({ + page, + }) => { + test.setTimeout(120_000); + const source = await installTallUsageSource(); + try { + await page.setViewportSize(viewport); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await showSidebar(page); + await expect(usageItem(page)).toBeInViewport(); + await openUsageFromItem(page); + const content = usageModal(page); + await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); + await waitForSettledPosition(content); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + await qaScreenshot(page, `${size}-usage-scroll-top`); + await scrollUsageToWindow(page, "Window 20"); + await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); + await qaScreenshot(page, `${size}-usage-scroll-bottom`); + await scrollUsageToWindow(page, "Window 1"); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + } finally { + await source.cleanup(); + } + }); + } test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({ page, }) => { @@ -93,18 +183,15 @@ test.describe("Usage item", () => { await gotoAppShell(page); const screen = page.getByTestId(`usage-host-${serverId}`); - await test.step("a fresh device shows default windows, which opens the Usage screen", async () => { - await expect(usageItem(page)).toBeVisible({ timeout: 30_000 }); - await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); - await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); + await test.step("a fresh device shows default windows once the Usage item is on", async () => { + // The Usage item starts off on every layout; turning it on stores the choice. + await expectNoUsageItem(page); await openSidebarNavSettings(page); + await expectFooterItemSetting(page, "usage", false); + await setFooterItemVisible(page, "usage", true); await qaScreenshot(page, "desktop-settings-sidebar-footer"); await page.setViewportSize(COMPACT); - // Phones start with the Usage item off; turning it on stores the choice. - await expect( - page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"), - ).toHaveAttribute("aria-checked", "false"); - await setFooterItemVisible(page, "usage", true); + await expectFooterItemSetting(page, "usage", true); await qaScreenshot(page, "compact-settings-sidebar-footer"); await gotoAppShell(page); await openCompactSidebar(page); @@ -112,8 +199,9 @@ test.describe("Usage item", () => { await qaScreenshot(page, "compact-footer-defaults"); await page.setViewportSize(WIDE); await gotoAppShell(page); - await usageItem(page).click(); - await expectOnUsageScreen(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); + await openUsageFromItem(page); await qaScreenshot(page, "default-pins-card"); await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); @@ -138,17 +226,13 @@ test.describe("Usage item", () => { await expect(usageItem(page)).not.toHaveText("Usage"); await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" }); await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible(); - await qaScreenshot(page, "usage-screen-header", { - kind: "element", - locator: page.getByTestId("page-title").locator(".."), - }); await qaScreenshot(page, "usage-card-refresh", { kind: "element", locator: screen.getByTestId("usage-report-claude:default"), }); }); - await test.step("remaining flips the Usage item and the Usage screen", async () => { + await test.step("remaining flips the Usage item and the Usage modal", async () => { await showUsageAs(page, "remaining"); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/); @@ -160,23 +244,15 @@ test.describe("Usage item", () => { ).toBeVisible(); }); - await test.step("on desktop the Usage item opens the Usage screen", async () => { - await gotoAppShell(page); - await expect(page).not.toHaveURL(/\/usage$/); - await usageItem(page).click(); - await expectOnUsageScreen(page); - await expect(page.getByTestId("usage-expanded")).toHaveCount(0); - }); - await test.step("on a phone the Usage item opens a bottom sheet", async () => { + await closeUsage(page); await page.setViewportSize(COMPACT); - await leaveUsageScreen(page); await openCompactSidebar(page); await expect(usageItem(page)).toBeInViewport(); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await qaScreenshot(page, "compact-footer"); - await usageItem(page).click(); - const sheet = usageSheet(page); + await openUsageFromItem(page); + const sheet = usageModal(page); await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport(); await expect(sheet.getByText("88% left")).toBeVisible(); await expect(pinRow(sheet, "Claude", "Session")).toBeChecked(); @@ -185,57 +261,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 }); }); @@ -244,6 +314,7 @@ test.describe("Usage item", () => { await togglePin(screen, "Codex", "Weekly"); await expectNoUsageItem(page); await page.reload(); + await openUsageFromIcon(page); await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 }); await expectNoUsageItem(page); await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked(); @@ -251,6 +322,7 @@ test.describe("Usage item", () => { await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked(); await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked(); await qaScreenshot(page, "explicit-empty-pins-after-reload"); + await closeUsage(page); }); await test.step("Settings > Sidebar lists the Usage item", async () => { @@ -294,30 +366,52 @@ 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 show and hide the sidebar Usage item", async ({ page }) => { +test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({ + page, +}) => { + const serverId = getServerId(); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); await page.setViewportSize(WIDE); await gotoAppShell(page); - await expect(usageItem(page)).toBeVisible({ timeout: 30_000 }); - await usageItem(page).click(); - await expectOnUsageScreen(page); + const screen = page.getByTestId(`usage-host-${serverId}`); + const claude = screen.getByTestId("usage-report-claude:default"); - await openUsageOptions(page); - const toggle = page.getByTestId("usage-show-in-sidebar").getByRole("switch"); - await expect(toggle).toHaveAttribute("aria-checked", "true"); - await toggle.click(); - await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0); + await test.step("off by default: no Usage item, and rows that do not pin", async () => { + 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-summary-off"); + }); - await page.reload(); - await expectOnUsageScreen(page); - await openUsageOptions(page); - await expect(toggle).toHaveAttribute("aria-checked", "false"); - await toggle.click(); - await expect(usageItem(page)).toBeVisible(); + await test.step("on: the Usage item and the pins appear", async () => { + await setSummaryInSidebar(page, true); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + 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-summary-on"); + }); + + await test.step("off then on again, after a reload, brings back the saved pins", async () => { + await setSummaryInSidebar(page, false); + await expectNoUsageItem(page); + await expectUnpinnableRows(claude); + await page.reload(); + await openUsageFromIcon(page); + await expectSummaryInSidebar(page, false); + await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 }); + await expectUnpinnableRows(claude); + await setSummaryInSidebar(page, true); + await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]); + await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); + await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked(); + }); }); test("released hosts supply source logos through the client conversion", async ({ page }) => { @@ -325,6 +419,7 @@ test("released hosts supply source logos through the client conversion", async ( lists: [() => claudeAndCodexReports()], providerUsageListOnly: true, }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); await page.setViewportSize(WIDE); await gotoAppShell(page); await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); @@ -336,8 +431,7 @@ test("released hosts supply source logos through the client conversion", async ( await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath); await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath); await qaScreenshot(page, "released-host-footer", { kind: "footer" }); - await usageItem(page).click(); - await expectOnUsageScreen(page); + await openUsageFromItem(page); const screen = page.getByTestId(`usage-host-${getServerId()}`); await expect( screen.getByTestId("usage-report-claude").locator("svg path").first(), @@ -345,5 +439,5 @@ test("released hosts supply source logos through the client conversion", async ( await expect( screen.getByTestId("usage-report-codex").locator("svg path").first(), ).toHaveAttribute("d", codexPath); - await qaScreenshot(page, "released-host-usage-screen"); + await qaScreenshot(page, "released-host-usage-modal"); }); diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts new file mode 100644 index 0000000000..4853e9daa4 --- /dev/null +++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts @@ -0,0 +1,74 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { + expectSidebarWorkspaceSelected, + switchWorkspaceViaSidebar, +} from "../support/helpers/workspace-ui"; + +async function readVisibleWorkspaceOrder(page: Page): Promise { + const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ }); + await expect(rows).toHaveCount(12); + return rows.evaluateAll( + (elements, prefix) => + elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)), + `sidebar-workspace-row-${getServerId()}:`, + ); +} + +async function navigateWorkspace( + page: Page, + direction: "next" | "previous", + workspaceId: string, +): Promise { + await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+["); + await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId }); + await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`)); +} + +test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({ + page, +}) => { + test.setTimeout(120_000); + const project = await seedWorkspace({ + repoPrefix: "relative-navigation-", + title: "Workspace 01", + }); + try { + for (let index = 2; index <= 12; index += 1) { + const created = await project.client.createWorkspace({ + source: { kind: "directory", path: project.repoPath }, + title: `Workspace ${String(index).padStart(2, "0")}`, + }); + expect(created.error).toBeNull(); + } + await page.setViewportSize({ width: 1440, height: 1100 }); + await gotoAppShell(page); + const order = await readVisibleWorkspaceOrder(page); + + await test.step("next crosses the ninth workspace and previous returns to it", async () => { + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] }); + await navigateWorkspace(page, "next", order[9]); + await navigateWorkspace(page, "previous", order[8]); + }); + + await test.step("wrapping uses the true first and last visible workspace", async () => { + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] }); + await navigateWorkspace(page, "previous", order[11]); + await navigateWorkspace(page, "next", order[0]); + }); + + await test.step("the ninth digit shortcut still selects the ninth workspace", async () => { + await page.keyboard.press("Alt+9"); + await expectSidebarWorkspaceSelected({ + page, + serverId: getServerId(), + workspaceId: order[8], + }); + }); + } finally { + await project.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 875ec5197d..7fe8ce1814 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -203,6 +203,41 @@ test.describe("Workspace setup streaming", () => { } }); + test("keeps a workspace usable when its setup config cannot be parsed", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-invalid-config-", { + files: [{ path: "paseo.json", content: "{ invalid json\n" }], + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const failed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "failed", + ); + const workspace = await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "workspace-setup-invalid-config", + }); + const failedPayload = await failed; + expect(failedPayload.error).toContain("Failed to parse paseo.json"); + expect(failedPayload.detail.commands).toEqual([]); + + await openHomeWithProject(page, repo.path); + await navigateToWorkspaceViaSidebar(page, workspace.id); + await expectFailedSetupTabSeededInMainPane(page, workspace.id); + await expect(page.getByText(/Failed to parse paseo\.json at/)).toBeVisible(); + await closeSetupTab(page, workspace.id); + await clickNewChat(page); + await expectComposerVisible(page); + await openFileExplorer(page); + await expectExplorerEntryVisible(page, "paseo.json"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + daemonTest("emits a completed empty snapshot when no setup commands exist", async () => { const client = await connectWorkspaceSetupClient(); const repo = await createTempGitRepo("setup-none-"); 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-sidebar-scroll-retention.android.yaml b/packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml new file mode 100644 index 0000000000..dd144b2926 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml @@ -0,0 +1,4 @@ +appId: sh.paseo.debug +--- +- launchApp +- runFlow: ../../../maestro/sidebar-scroll-retention.yaml 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 6e78e0c66b..d4ef8fc2f4 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -52,6 +52,16 @@ export async function expectComposerFocused(page: Page): Promise { await expect(composerInput(page)).toBeFocused(); } +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 }); @@ -59,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); } @@ -306,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 new file mode 100644 index 0000000000..21341c7766 --- /dev/null +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -0,0 +1,180 @@ +import path from "node:path"; +import { expect, type Locator, type Page } from "@playwright/test"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { expectComposerVisible } from "./composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent"; +import { installUsageReportsFixture, type UsageListResponse } from "./usage-reports"; +import { claudeAndCodexReports } from "./usage-sidebar-item"; + +// 32,000 of the mock's 128,000-token window. +const METER_NAME = "Context window 25% used"; + +export type MockAgentSession = Awaited>; + +/** An agent a quarter into its context window. */ +export function seedAgentWithContextWindow(): Promise { + return seedMockAgentWorkspace({ + repoPrefix: "context-window-usage-", + title: "Context window usage e2e", + initialPrompt: "emit 32000 byte file agent stream payload", + }); +} + +export async function openAgent(page: Page, session: MockAgentSession): Promise { + await openAgentRoute(page, session); + await expectComposerVisible(page); +} + +export async function reloadAgent(page: Page): Promise { + // 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 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(contextWindowDetails(page)).toHaveCount(0); +} + +/** Compact screens open the meter's details in a sheet. */ +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 = contextWindowDetails(page); + await sheet.getByRole("button", { name: "Close", exact: true }).click(); + await expect(sheet).toHaveCount(0); +} + +/** 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" }); +} + +/** One report card; cards have no role of their own. */ +export function usageCard(surface: Locator, reportId: string): Locator { + return surface.getByTestId(`usage-report-${reportId}`); +} + +export async function refreshUsageCard(surface: Locator, source: string): Promise { + await surface.getByRole("button", { name: `Refresh ${source}`, exact: true }).click(); +} + +export async function expectRefreshButtons(surface: Locator, count: number): Promise { + await expect(surface.getByRole("button", { name: /^Refresh / })).toHaveCount(count); +} + +/** A report on the agent's own login, which is not the host's default one. */ +export function onWorkLogin(report: UsageReportEntry): UsageReportEntry { + return onLogin(report, "work"); +} + +/** A report on the login an agent resumed under. */ +export function onPersonalLogin(report: UsageReportEntry): UsageReportEntry { + return onLogin(report, "personal"); +} + +function onLogin(report: UsageReportEntry, login: string): UsageReportEntry { + return { + ...report, + id: `${report.sourceId}:${login}`, + account: { label: `${login}@example.com` }, + }; +} + +export function expiredLogin(report: UsageReportEntry): UsageReportEntry { + return { + ...report, + report: { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), + refreshedBy: "claude", + }, + }, + }; +} + +/** Holds a stream at a point until the test opens it, like a slow source. */ +export function gate(): { promise: Promise; open: () => void } { + let open!: () => void; + const promise = new Promise((resolve) => { + open = resolve; + }); + return { promise, open }; +} + +export interface AgentUsageScript { + /** The answer to the next agent usage request or report Refresh. */ + answerNext(response: UsageListResponse): void; + /** From the next reload the host no longer reports usage. */ + stopReportingUsage(): void; + /** The agent of each agent usage request, in order. */ + agentRequests(): string[]; + /** Each scoped agent Refresh, by agent ID. */ + refreshedAgents(): string[]; +} + +/** + * Scripts the host's usage: the host's own list is Claude and Codex on its default login; each + * agent request and each Refresh takes the next scripted answer. + */ +export async function scriptAgentUsage(page: Page): Promise { + let supported = true; + const answers: UsageListResponse[] = []; + const usage = await installUsageReportsFixture(page, { + usageSupported: () => supported, + lists: [ + (request) => { + if (!request.agentId && !request.reportIds) return claudeAndCodexReports(); + const next = answers.shift(); + if (!next) throw new Error("The test scripts every agent usage request."); + return next; + }, + ], + }); + return { + answerNext: (response) => answers.push(response), + stopReportingUsage: () => { + supported = false; + }, + agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []), + refreshedAgents: () => + usage + .listRequests() + .flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])), + }; +} + +/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */ +export async function qaScreenshot(page: Page, name: string): Promise { + const directory = process.env.PASEO_QA_SCREENSHOT_DIR; + if (!directory) return; + // 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/explorer-plugin-menu.ts b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts index 2d13080274..ccccac8570 100644 --- a/packages/app/e2e/support/helpers/explorer-plugin-menu.ts +++ b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts @@ -35,18 +35,14 @@ export default function contribute(client) { async function openExplorerMenu(page: Page) { const explorer = await ensureExplorerSidebar(page); - // The rail's empty background has no accessible role; tab menus are separate. - await explorer.getByTestId("explorer-sidebar-tab-rail").click({ - button: "right", - position: { x: 20, y: 2 }, - }); - const menu = page.getByTestId("explorer-sidebar-tab-configuration"); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); await expect(menu).toBeVisible(); await expect(menu).toHaveCSS("opacity", "1"); return menu; } -async function toggleExplorerView(page: Page, name: string) { +async function launchExplorerView(page: Page, name: string | RegExp) { const menu = await openExplorerMenu(page); await menu.getByRole("menuitem", { name, exact: true }).click(); await expect(menu).not.toBeVisible(); @@ -84,13 +80,12 @@ export async function withExplorerPanelPlugins( export async function expectWorkspacePanelsInExplorerLauncher(page: Page) { const menu = await openExplorerMenu(page); - await menu.getByRole("menuitem", { name: "New tab", exact: true }).click(); - const launcher = page.getByTestId("workspace-explorer-sidebar"); - await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible(); - await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0); - await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount( + await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: "Review main only", exact: true })).toHaveCount( 0, ); + await page.keyboard.press("Escape"); } export async function openWorkspacePanelFromExplorerMenu( @@ -103,67 +98,51 @@ export async function openWorkspacePanelFromExplorerMenu( body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }), contentType: "image/png", }); - await expect(menu.getByRole("menuitem")).toHaveText([ - "New tab", - "Changes", - "Files", - "Other review", - "Other review summary", - "Review", - "Review summary", - ]); - await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute( - "aria-checked", - "false", - ); + await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Other review summary", exact: true }), + ).toBeVisible(); await menu.getByRole("menuitem", { name: "Review", exact: true }).click(); await expect( page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }), ).toBeVisible(); } -export async function expectIndependentExplorerPanelToggles( +export async function expectIndependentExplorerPanelTabs( page: Page, workspace: ExplorerPanelWorkspace, ) { - await toggleExplorerView(page, "Other review"); + await launchExplorerView(page, "Other review"); await expect( page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }), ).toBeVisible(); - await toggleExplorerView(page, "Review summary"); + await launchExplorerView(page, "Review summary"); + const tabs = page.getByTestId("workspace-explorer-sidebar").getByTestId("workspace-tabs-row"); + await expect(tabs.getByRole("button", { name: "Review", exact: true })).toBeVisible(); + await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible(); + await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible(); + await tabs + .getByRole("button", { name: "Review", exact: true }) + .click({ button: "right", position: { x: 12, y: 13 } }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect(tabs.getByRole("button", { name: "Review", exact: true })).toHaveCount(0); + await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible(); + await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible(); + const files = tabs.getByRole("button", { name: "Browse workspace files", exact: true }); + await expect(files).toHaveCount(1); const menu = await openExplorerMenu(page); - await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute( - "aria-checked", - "true", - ); - await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute( - "aria-checked", - "true", - ); - await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute( - "aria-checked", - "true", - ); - await menu.getByRole("menuitem", { name: "Review", exact: true }).click(); - const updated = await openExplorerMenu(page); - await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute( - "aria-checked", - "false", - ); - await expect( - updated.getByRole("menuitem", { name: "Other review", exact: true }), - ).toHaveAttribute("aria-checked", "true"); - await expect( - updated.getByRole("menuitem", { name: "Review summary", exact: true }), - ).toHaveAttribute("aria-checked", "true"); + await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); + await page.keyboard.press("Escape"); + await files.click({ button: "right", position: { x: 12, y: 13 } }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect(files).toHaveCount(0); + await launchExplorerView(page, /^Files/); + await expect(files).toHaveCount(1); + const restoredMenu = await openExplorerMenu(page); + await expect(restoredMenu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); await page.keyboard.press("Escape"); - await toggleExplorerView(page, "Files"); - await toggleExplorerView(page, "Files"); - await expect( - page - .getByTestId("explorer-sidebar-tab-rail") - .getByRole("button", { name: "Browse workspace files", exact: true }), - ).toHaveCount(1); } export async function expectExplorerPanelWithFocusedAgent( 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/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 1702f7fe44..6b810e6d13 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -370,7 +370,7 @@ export async function findAcpCatalogProvider(page: Page, providerName: string): export async function installAcpCatalogProvider(page: Page, providerName: string): Promise { await findAcpCatalogProvider(page, providerName); - await page.getByRole("button", { name: "Add", exact: true }).click(); + await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click(); } export async function expectProviderInstalledInSettings( diff --git a/packages/app/e2e/support/helpers/sheet.ts b/packages/app/e2e/support/helpers/sheet.ts new file mode 100644 index 0000000000..6288c6f9c1 --- /dev/null +++ b/packages/app/e2e/support/helpers/sheet.ts @@ -0,0 +1,99 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** A sheet tab's body, named after the tab it shows. */ +export function sheet(page: Page, label: string): Locator { + return page.getByRole("dialog", { name: label }); +} + +/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */ +export function sheetHeader(page: Page, label: string): Locator { + return page.getByTestId("sheet-tab-header").filter({ hasText: label }); +} + +/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */ +const SHEET_SNAP_FRACTION = 0.92; +/** The header starts below the sheet's drag indicator, inside the handle. */ +const SHEET_INDICATOR_MAX_HEIGHT = 32; + +/** + * The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it + * covers the workspace header; a sheet sunk below it hides content. + */ +export async function expectSheetAtSnap(page: Page, label: string): Promise { + const viewportHeight = page.viewportSize()?.height ?? 0; + const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION)); + const header = sheetHeader(page, label); + const body = sheet(page, label); + await expect + .poll(async () => { + const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]); + if (!headerBox || !bodyBox) return null; + const top = Math.round(headerBox.y); + return { + dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight, + topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT, + }; + }) + .toEqual({ dockedToBottom: true, topAtSnap: true }); +} + +export async function expectOpenTabCount(page: Page, count: number): Promise { + await expect( + page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }), + ).toBeVisible(); +} + +export async function closeSheet(page: Page, label: string): Promise { + await sheetHeader(page, label).getByRole("button", { name: "Close" }).click(); + await expect(sheet(page, label)).toHaveCount(0); +} + +export async function openSheetAsTab(page: Page, label: string): Promise { + await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click(); + await expect(sheet(page, label)).toHaveCount(0); +} + +export async function replaceSheetFileText( + page: Page, + label: string, + content: string, +): Promise { + const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content"); + await editor.click(); + await editor.press("ControlOrMeta+A"); + await editor.pressSequentially(content); +} + +export async function selectTabFromSwitcher(page: Page, label: string): Promise { + await page + .getByRole("button", { name: /Switch tabs/ }) + .filter({ visible: true }) + .click(); + await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible(); + const option = page.getByText(label, { exact: true }).filter({ visible: true }).last(); + await expect(option).toBeVisible(); + await option.click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); +} + +/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */ +export async function tapSheetBackdrop(page: Page): Promise { + await page + .getByRole("button", { name: "Bottom sheet backdrop" }) + .last() + .click({ position: { x: 10, y: 10 } }); +} + +/** Runs a close that hits the unsaved-changes guard, and declines the discard. */ +export async function expectDeclinedDiscardPrompt( + page: Page, + close: () => Promise, +): Promise { + const prompt = page.waitForEvent("dialog").then(async (dialog) => { + const message = dialog.message(); + await dialog.dismiss(); + return message; + }); + await close(); + expect(await prompt).toContain("discard the draft"); +} diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index a8c64f712e..e3eff916e7 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -197,18 +197,29 @@ export async function moveFooterItemUp(page: Page, key: string): Promise { .click(); } +function footerItemSwitch(page: Page, key: string): Locator { + return page.getByTestId("sidebar-nav-section-footer").getByTestId(`sidebar-nav-toggle-${key}`); +} + export async function setFooterItemVisible( page: Page, key: string, visible: boolean, ): Promise { - const toggle = page - .getByTestId("sidebar-nav-section-footer") - .getByTestId(`sidebar-nav-toggle-${key}`); + const toggle = footerItemSwitch(page, key); await toggle.click(); await expect(toggle).toHaveAttribute("aria-checked", String(visible)); } +/** Whether Settings > Sidebar shows a footer item as on. */ +export async function expectFooterItemSetting( + page: Page, + key: string, + visible: boolean, +): Promise { + await expect(footerItemSwitch(page, key)).toHaveAttribute("aria-checked", String(visible)); +} + export async function expectFooterOrder(page: Page, keys: string[]): Promise { await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows"); } @@ -233,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/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts new file mode 100644 index 0000000000..f7b62fd953 --- /dev/null +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -0,0 +1,541 @@ +import { writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { expect, type Page, type TestInfo } from "@playwright/test"; +import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; +import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination"; + +export const scrollCadences = [ + { name: "slow", delta: 160, intervalMs: 50, steps: 1000 }, + { name: "medium", delta: 480, intervalMs: 32, steps: 400 }, + { name: "fast", delta: 1200, intervalMs: 16, steps: 180 }, +] as const; + +type Cadence = (typeof scrollCadences)[number]; +interface RowFrame { + id: string; + top: number; + height: number; +} +export interface ScrollFrame { + at: number; + scrollTop: number; + scrollHeight: number; + viewportHeight: number; + virtualized: boolean; + loading: boolean; + rows: RowFrame[]; + anchor: string | null; + wheelTotal: number; + lastWheelAt: number; + inputFinishedAt: number | null; + imageLoads: number; + mounted: number; + unmounted: number; +} +interface Recording { + frames: ScrollFrame[]; + finishInput(): void; + stop(): void; +} +interface TimelinePage { + direction: string; + count: number; + hasOlder: boolean; + at: number; +} + +/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */ +export async function withVariedTimeline( + run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise, +): Promise { + const timers = new Set>(); + const server = createServer((request, response) => { + const portrait = request.url?.includes("portrait"); + const timer = setTimeout( + () => { + timers.delete(timer); + response.writeHead(200, { + "Content-Type": "image/svg+xml", + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + }); + response.end( + `Delayed ${portrait ? "portrait" : "landscape"}`, + ); + }, + portrait ? 900 : 350, + ); + timers.add(timer); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Image server did not listen"); + const imageUrl = `http://127.0.0.1:${address.port}`; + let agent: MockAgentWorkspace | undefined; + try { + agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-scroll-smoothness-", + title: "Varied timeline scroll reproduction", + featureValues: { + mockAssistantResponses: Array.from({ length: 70 }, (_, turn) => + [ + `## Review of turn ${turn}`, + "A short response followed by blocks with different heights. Read the comparison, inspect the screenshot, and continue to the next turn.", + "| Component | Observation | Next step |\n| --- | --- | --- |\n| Pagination | Older turns arrive together | Keep the visible text anchored |\n| Images | Intrinsic sizes arrive later | Preserve the reading position |\n| Virtualization | Estimated heights become measured heights | Keep wheel input moving upward |", + `![Delayed landscape](${imageUrl}/${turn}/landscape.svg)`, + "```typescript\nconst viewport = { top: 0, height: 800 };\nfor (const row of history) {\n inspect(row, viewport);\n}\n```", + "> Keep reading while content above the viewport changes.", + "- Short item\n- A longer item that wraps when the available width is smaller and adds another height variation\n- Final item", + `![Delayed portrait](${imageUrl}/${turn}/portrait.svg)`, + "Review complete. The next prompt starts a separate turn.", + ].join("\n\n"), + ), + }, + }); + for (let turn = 0; turn < 70; turn += 1) { + await agent.client.sendAgentMessage( + agent.agentId, + `Review turn ${turn}. ${"Compare the image and table with the earlier result. ".repeat(1 + (turn % 4))}`, + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + await run( + agent, + `Review turn 69. ${"Compare the image and table with the earlier result. ".repeat(2)}`, + ); + } finally { + await agent?.cleanup(); + for (const timer of timers) clearTimeout(timer); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +export function observeTimelinePages(page: Page, agentId: string): TimelinePage[] { + const pages: TimelinePage[] = []; + page.on("websocket", (socket) => { + socket.on("framereceived", ({ payload }) => { + const envelope = JSON.parse(String(payload)); + const message = envelope.message; + if (message?.type !== "fetch_agent_timeline_response" || message.payload.agentId !== agentId) + return; + pages.push({ + direction: message.payload.direction, + count: message.payload.entries.length, + hasOlder: message.payload.hasOlder, + at: Date.now(), + }); + }); + }); + return pages; +} + +export async function openOnlyTimelineTail( + page: Page, + agent: MockAgentWorkspace, + newestPrompt: string, + pages: TimelinePage[], +): Promise { + await page.addInitScript(() => Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true)); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, newestPrompt.trim()); + await expect + .poll(() => pages.filter((entry) => entry.direction === "tail").length) + .toBeGreaterThanOrEqual(1); + + expect(pages[0]).toMatchObject({ direction: "tail", hasOlder: true }); + expect(pages[0].count).toBe(40); + expect(pages.filter((entry) => entry.direction === "tail")).toHaveLength(1); + expect( + pages.filter((entry) => entry.direction === "after").every((entry) => entry.count === 0), + ).toBe(true); + await expect(page.getByText(/^Review turn 0\./)).toHaveCount(0); + // Start each cadence after the tail's initial image loads and bottom anchoring. + await page.waitForTimeout(1500); + expect(pages.filter((entry) => entry.direction === "before")).toHaveLength(0); +} + +export async function recordUpwardTraversal( + page: Page, + cadence: Cadence, + testInfo: TestInfo, +): Promise { + const stopTrace = await traceScrollIfRequested(page, testInfo, cadence); + const timeline = page.getByTestId("agent-chat-scroll"); + await timeline.hover(); + await timeline.evaluate((viewport) => { + const scroll = viewport as HTMLElement; + const frames: ScrollFrame[] = []; + let frame = 0; + let wheelTotal = 0; + let lastWheelAt = -Infinity; + let inputFinishedAt: number | null = null; + let imageLoads = 0; + let mounted = scroll.querySelectorAll("[data-history-row-id]").length; + let unmounted = 0; + const collectRows = (node: Node, rows: Set) => { + if (!(node instanceof Element)) return; + if (node.matches("[data-history-row-id]")) rows.add(node); + for (const row of node.querySelectorAll("[data-history-row-id]")) rows.add(row); + }; + const recordMutations = (records: MutationRecord[]) => { + // A newly inserted container and its children can both appear in one + // delivery. Count each DOM row once, including rows removed before paint. + const added = new Set(); + const removed = new Set(); + for (const record of records) { + for (const node of record.addedNodes) collectRows(node, added); + for (const node of record.removedNodes) collectRows(node, removed); + } + mounted += added.size; + unmounted += removed.size; + }; + const mutations = new MutationObserver(recordMutations); + mutations.observe(scroll, { childList: true, subtree: true }); + const wheel = (event: WheelEvent) => { + wheelTotal += -event.deltaY; + lastWheelAt = performance.now(); + }; + const imageLoad = (event: Event) => { + if (event.target instanceof HTMLImageElement) imageLoads += 1; + }; + scroll.addEventListener("wheel", wheel, { passive: true }); + scroll.addEventListener("load", imageLoad, true); + const observed = new Set(); + const capture = (replaceLast: boolean) => { + const rect = scroll.getBoundingClientRect(); + const elements = Array.from(scroll.querySelectorAll("[data-history-row-id]")); + const current = new Set([scroll, ...elements]); + if (scroll.firstElementChild) current.add(scroll.firstElementChild); + for (const element of observed) { + if (!current.has(element)) { + resizeObserver.unobserve(element); + observed.delete(element); + } + } + for (const element of current) { + if (!observed.has(element)) { + resizeObserver.observe(element); + observed.add(element); + } + } + const rows = elements.map((row) => { + const box = row.getBoundingClientRect(); + return { id: row.dataset.historyRowId!, top: box.top - rect.top, height: box.height }; + }); + // Include rows mounted and removed in the same commit, before sampling. + recordMutations(mutations.takeRecords()); + // Preserve the row crossing the reading line. Rows below an expanding + // visible image legitimately move even when the reading position is stable. + const anchor = rows.find((row) => row.top < rect.height && row.top + row.height > 8); + const snapshot: ScrollFrame = { + at: performance.now(), + scrollTop: scroll.scrollTop, + scrollHeight: scroll.scrollHeight, + viewportHeight: rect.height, + rows, + anchor: anchor?.id ?? null, + virtualized: scroll.id.includes("virtualized"), + loading: !!scroll.querySelector('[data-testid="load-older-history-spinner"]'), + wheelTotal, + lastWheelAt, + inputFinishedAt, + imageLoads, + mounted, + unmounted, + }; + if (replaceLast && frames.length > 0) frames[frames.length - 1] = snapshot; + else frames.push(snapshot); + }; + // rAF precedes ResizeObserver. Refresh that frame after resize delivery so + // it represents the final layout, not geometry repaired before the paint. + const resizeObserver = new ResizeObserver(() => capture(true)); + const sample = () => { + capture(false); + frame = requestAnimationFrame(sample); + }; + const recording: Recording = { + frames, + finishInput() { + inputFinishedAt = performance.now(); + }, + stop() { + cancelAnimationFrame(frame); + resizeObserver.disconnect(); + mutations.disconnect(); + scroll.removeEventListener("wheel", wheel); + scroll.removeEventListener("load", imageLoad, true); + }, + }; + Reflect.set(window, "__timelineScrollRecording", recording); + const reset = Reflect.get(window, "__PASEO_RESET_RENDER_PROFILE__"); + if (typeof reset === "function") reset(); + sample(); + }); + // No settling between inputs: exercise wheel input overlapping prepend and measurement. + for (let step = 0; step < cadence.steps; step += 1) { + await page.mouse.wheel(0, -cadence.delta); + await page.waitForTimeout(cadence.intervalMs); + } + await page.evaluate(() => { + (Reflect.get(window, "__timelineScrollRecording") as Recording).finishInput(); + }); + await page.waitForTimeout(1500); + await stopTrace(); + return page.evaluate(() => { + const recording = Reflect.get(window, "__timelineScrollRecording") as Recording; + recording.stop(); + return recording.frames; + }); +} + +export function findScrollJumps(frames: ScrollFrame[]) { + return frames.flatMap((current, index) => { + const previous = frames[index - 1]; + if (!previous?.anchor) return []; + // Wheel input can move the reading line onto an image before it expands. + // Follow that intended row, not text now below the image. + const recent = frames.findLast((frame) => frame.at <= previous.at - 100); + const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0); + const currentRows = new Map(current.rows.map((row) => [row.id, row])); + const anchor = previous.anchor; + const before = currentRows.has(anchor) + ? previous.rows.find((row) => row.id === anchor)! + : previous.rows + .filter((row) => currentRows.has(row.id)) + .sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0]; + if (!before) { + throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`); + } + const after = currentRows.get(before.id)!; + const movement = after.top - before.top; + // A busy main thread can deliver wheel movement hundreds of milliseconds + // after its event. Only assert idle stability after the driver stops input. + const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250; + // scrollTop also includes virtualizer estimate corrections. Locate the row + // entered by wheel input from its viewport movement instead. Its old height + // identifies the reader before a simultaneous image resize takes effect. + const intendedRow = previous.rows.find((row) => { + const next = currentRows.get(row.id); + if (!next || idle || wheelBudget <= 0) return false; + const travel = next.top - row.top; + return ( + travel >= -8 && + travel <= Math.min(wheelBudget, previous.scrollTop) + 32 && + (next.top <= 8 || current.scrollTop === 0) && + next.top + row.height > 8 + ); + }); + const enteredRowResize = intendedRow + ? previous.rows.reduce((growth, row) => { + if (row.top < intendedRow.top || row.top >= before.top) return growth; + const height = currentRows.get(row.id)?.height ?? row.height; + return growth + height - row.height; + }, 0) + : 0; + const readingMovement = movement - enteredRowResize; + const excessForward = readingMovement > wheelBudget + 32; + // Upward wheel input moves the same text DOWN the viewport. A negative move + // is a reversal, independent of legitimate scrollTop compensation on prepend. + if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward) + return []; + return [ + { + frame: index, + at: current.at, + row: before.id, + movement, + idle, + excessForward, + resizedRows: current.rows.flatMap((row) => { + const old = previous.rows.find((candidate) => candidate.id === row.id); + return old && old.height !== row.height + ? [{ id: row.id, before: old.height, after: row.height }] + : []; + }), + loading: current.loading || previous.loading, + virtualized: current.virtualized, + heightChange: current.scrollHeight - previous.scrollHeight, + imageLoads: current.imageLoads - previous.imageLoads, + frameGapMs: current.at - previous.at, + }, + ]; + }); +} + +export async function reportScrollJumps( + page: Page, + testInfo: TestInfo, + frames: ScrollFrame[], + pages: TimelinePage[], +): Promise { + const jumps = findScrollJumps(frames); + const react = await page.evaluate(() => Reflect.get(window, "__PASEO_RENDER_PROFILE__") ?? []); + const gaps = frames + .slice(1) + .map((frame, index) => frame.at - frames[index].at) + .sort((a, b) => a - b); + const summary = { + browserTimeOrigin: await page.evaluate(() => performance.timeOrigin), + frames: frames.length, + pages, + jumpCount: jumps.length, + worstReversePx: Math.max(0, ...jumps.map((jump) => -jump.movement)), + frameGapP95Ms: gaps[Math.floor(gaps.length * 0.95)], + frameGapMaxMs: gaps.at(-1), + imageLoads: frames[frames.length - 1].imageLoads, + mounted: frames[frames.length - 1].mounted, + unmounted: frames[frames.length - 1].unmounted, + jumps, + }; + await writeFile(testInfo.outputPath("scroll-summary.json"), JSON.stringify(summary, null, 2)); + await writeFile(testInfo.outputPath("scroll-frames.json"), JSON.stringify(frames)); + await writeFile(testInfo.outputPath("scroll-react-profile.json"), JSON.stringify(react)); + await testInfo.attach("scroll-summary", { + body: JSON.stringify(summary, null, 2), + contentType: "application/json", + }); + await testInfo.attach("scroll-frames", { + body: JSON.stringify(frames), + contentType: "application/json", + }); + await testInfo.attach("scroll-react-profile", { + body: JSON.stringify(react), + contentType: "application/json", + }); + await attachTimelineScreenshot(page, testInfo, "scroll-end"); + console.log( + `[scroll] ${testInfo.title}: ${JSON.stringify({ ...summary, jumps: jumps.slice(0, 5) })}`, + ); + expect( + pages.filter((entry) => entry.direction === "before").length, + "must exercise multiple older pages", + ).toBeGreaterThanOrEqual(2); + expect( + frames.some((frame) => frame.virtualized), + "must exercise virtualization", + ).toBe(true); + expect(summary.imageLoads, "must exercise image completion during traversal").toBeGreaterThan(0); + expect( + summary.mounted - summary.unmounted, + "row counters must balance against the final DOM", + ).toBe(frames[frames.length - 1].rows.length); + const uniqueRows = new Set(frames.flatMap((frame) => frame.rows.map((row) => row.id))).size; + expect( + summary.mounted, + "prepending must not mount a discarded range before correcting the viewport", + ).toBeLessThanOrEqual(uniqueRows * 1.25); + expect( + jumps, + "visible text must not reverse direction while scrolling upward or move after input stops", + ).toEqual([]); +} + +async function traceScrollIfRequested( + page: Page, + testInfo: TestInfo, + cadence: Cadence, +): Promise<() => Promise> { + if (process.env.PASEO_TIMELINE_SCROLL_TRACE !== cadence.name) return async () => {}; + const cdp = await page.context().newCDPSession(page); + await cdp.send("Tracing.start", { + categories: + "devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-v8.cpu_profiler", + transferMode: "ReturnAsStream", + }); + return async () => { + const completed = new Promise((resolve) => { + cdp.once("Tracing.tracingComplete", ({ stream }) => resolve(stream!)); + }); + await cdp.send("Tracing.end"); + const stream = await completed; + const chunks: string[] = []; + for (;;) { + const chunk = await cdp.send("IO.read", { handle: stream }); + chunks.push(chunk.base64Encoded ? Buffer.from(chunk.data, "base64").toString() : chunk.data); + if (chunk.eof) break; + } + await cdp.send("IO.close", { handle: stream }); + await cdp.detach(); + const path = testInfo.outputPath("scroll-chromium-trace.json"); + await writeFile(path, chunks.join("")); + await testInfo.attach("scroll-chromium-trace", { path, contentType: "application/json" }); + }; +} + +/** Hold real image bytes until the reader has reached its placeholder. */ +export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo): Promise { + let released = false; + const pending = new Set<() => void>(); + const server = createServer((_request, response) => { + const send = () => { + response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "no-store" }); + response.end( + '', + ); + }; + if (released) send(); + else pending.add(send); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Image server did not listen"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "reserved-timeline-image-", + title: "Reserved image geometry", + featureValues: { + mockAssistantResponses: [ + `Before the image.\n\n![Reserved image](http://127.0.0.1:${address.port}/image.svg)\n\nText below the image.`, + ], + }, + }); + try { + await agent.client.sendAgentMessage(agent.agentId, "Show the delayed image"); + await agent.client.waitForFinish(agent.agentId, 15_000); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, "Show the delayed image"); + const image = page.getByRole("img", { name: "Reserved image" }).first(); + await expect(image).toBeVisible(); + const before = await image.boundingBox(); + await attachTimelineScreenshot(page, testInfo, "image-placeholder"); + released = true; + for (const send of pending) send(); + await expect + .poll(() => + image.evaluate((element) => { + const img = + element instanceof HTMLImageElement ? element : element.querySelector("img"); + return img?.naturalWidth; + }), + ) + .toBe(240); + await expect + .poll(async () => { + const box = await image.boundingBox(); + return box ? Math.round((box.width / box.height) * 100) : 0; + }) + .toBe(150); + const after = await image.boundingBox(); + await attachTimelineScreenshot(page, testInfo, "image-loaded"); + expect(before?.height, "reserve the final image height before the HTTP response").toBe( + after?.height, + ); + } finally { + await agent.cleanup(); + } + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +async function attachTimelineScreenshot( + page: Page, + testInfo: TestInfo, + name: string, +): Promise { + const path = testInfo.outputPath(`${name}.png`); + await page.getByTestId("agent-chat-scroll").screenshot({ path }); + await testInfo.attach(name, { path, contentType: "image/png" }); +} diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 4e83541b91..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(); } @@ -101,7 +104,7 @@ import {fetchUsage, discover} from "./server/usage.js"; import {inputSchema} from "./shared/input.js"; export default function contribute(server) { server.registerUsageSource({id: "codex-window-qa", label: "Codex", input: inputSchema, - discover: () => discover({home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}), + discover: (scope) => discover(scope, {home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}), fetch: (input) => fetchUsage(input, async () => Response.json(JSON.parse(await readFile(${JSON.stringify(path.join(directory, "payload.json"))}, "utf8")))), }); return () => {}; diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index b379c27263..86b74c6ea3 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -5,6 +5,7 @@ import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port"; export interface UsageListRequest { forceRefresh: boolean; reportIds?: string[]; + agentId?: string; } export interface UsageReportsFixture { @@ -19,15 +20,25 @@ interface UsageReportsFixtureOptions { * `fetchedAt`, or a different answer to a forced refresh). */ lists?: Array UsageListResponse)>; - /** False simulates a host with no usage reporting capability. */ - usageSupported?: boolean; + /** + * False simulates a host with no usage reporting capability. A function is read each time the + * app connects, so a reload can switch it. + */ + usageSupported?: boolean | (() => boolean); /** Released hosts expose provider.usage.list with no source icons. */ providerUsageListOnly?: boolean; /** The host daemon's port; defaults to the E2E daemon. */ port?: number; } -type UsageListResponse = UsageReportEntry[] | { error: string }; +/** + * A list of reports, a request error, or a stream: reports sent in order, where a promise holds + * the rest of the stream and the response until it settles, like a slow source. + */ +export type UsageListResponse = + | UsageReportEntry[] + | { error: string } + | { stream: Array> }; type WebSocketMessage = string | Buffer; @@ -114,6 +125,8 @@ export async function installUsageReportsFixture( const listRequests: UsageListRequest[] = []; const listCounter = createCounter(); const usageSupported = options.usageSupported ?? true; + const isUsageSupported = () => + typeof usageSupported === "function" ? usageSupported() : usageSupported; const providerUsageListOnly = options.providerUsageListOnly ?? false; const requestType = providerUsageListOnly ? "provider.usage.list.request" @@ -131,6 +144,7 @@ export async function installUsageReportsFixture( const listRequest: UsageListRequest = { forceRefresh: request.forceRefresh === true, reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined, + agentId: typeof request.agentId === "string" ? request.agentId : undefined, }; listRequests.push(listRequest); const scripted = pick(options.lists ?? [[]], listRequests.length - 1); @@ -139,22 +153,51 @@ export async function installUsageReportsFixture( ws.send( JSON.stringify({ type: "session", - message: { - type: "rpc_error", - payload: { - requestId, - requestType, - error: response.error, - code: "transport", - }, - }, + message: providerUsageListOnly + ? { + type: "rpc_error", + payload: { requestId, requestType, error: response.error, code: "transport" }, + } + : { + type: "usage.list_reports.response", + payload: { requestId, error: response.error }, + }, }), ); listCounter.increment(); return; } + const sendUpdate = (report: UsageReportEntry) => + ws.send( + JSON.stringify({ + type: "session", + message: { type: "usage.list_reports.update", payload: { requestId, report } }, + }), + ); + if ("stream" in response) { + void (async () => { + for (const item of response.stream) { + if (item instanceof Promise) await item; + else sendUpdate(item); + } + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "usage.list_reports.response", + payload: { requestId, error: null }, + }, + }), + ); + listCounter.increment(); + })(); + return; + } const ids = Array.isArray(request.reportIds) ? request.reportIds : null; const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response; + if (!providerUsageListOnly) { + for (const report of reports) sendUpdate(report); + } const reply = providerUsageListOnly ? { type: "provider.usage.list.response", @@ -175,7 +218,7 @@ export async function installUsageReportsFixture( })), }, } - : { type: "usage.list_reports.response", payload: { requestId, reports } }; + : { type: "usage.list_reports.response", payload: { requestId, error: null } }; ws.send(JSON.stringify({ type: "session", message: reply })); listCounter.increment(); return; @@ -186,7 +229,7 @@ export async function installUsageReportsFixture( server.onMessage((message) => { const serverInfo = typeof message === "string" - ? withUsageSupportFeature(message, usageSupported, providerUsageListOnly) + ? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly) : null; ws.send(serverInfo ?? message); }); diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 42eebe65e9..95c6a4d689 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". */ @@ -168,6 +165,19 @@ export function pinRow(scope: Locator, source: string, window: string): Locator return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) }); } +/** + * A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable + * but buttons, so no row presses or highlights on hover. + */ +export async function expectUnpinnableRows(card: Locator): Promise { + await expect(card.getByRole("checkbox")).toHaveCount(0); + await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0); + const focusable = await card + .locator("[tabindex]") + .evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length); + expect(focusable).toBe(0); +} + export async function togglePin(scope: Locator, source: string, window: string) { const row = pinRow(scope, source, window); const pinned = await row.isChecked(); @@ -175,13 +185,104 @@ export async function togglePin(scope: Locator, source: string, window: string) await expect(row).toBeChecked({ checked: !pinned }); } -/** Expand the inline Settings row when its controls are folded. */ +/** + * 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 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); +} + +/** Reach a window with the wheel even when other reports follow its card. */ +export async function scrollUsageToWindow(page: Page, label: string): Promise { + const target = await usageModal(page).getByText(label, { exact: true }).boundingBox(); + const viewport = page.viewportSize(); + if (!target || !viewport) throw new Error("Usage window must exist before scrolling to it."); + await scrollUsage(page, target.y + target.height / 2 - viewport.height / 2); +} + +/** The modal's own Close button. */ +export async function closeUsage(page: Page): Promise { + await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click(); + await expect(usageModal(page)).toHaveCount(0); +} + +/** A tap on the backdrop above the bottom sheet. */ +export async function closeUsageFromBackdrop(page: Page): Promise { + const sheet = usageModal(page); + await waitForSettledPosition(sheet); + const bounds = await 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 modal's Settings. */ +export async function setSummaryInSidebar(page: Page, on: boolean): Promise { + await openUsageOptions(page); + await summaryInSidebarSwitch(page).click(); + await expectSummaryInSidebar(page, on); +} + +export async function expectSummaryInSidebar(page: Page, on: boolean): Promise { + await openUsageOptions(page); + await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on }); + await closeUsageOptions(page); +} + +/** 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(); @@ -189,6 +290,7 @@ export async function showUsageAs(page: Page, displayAs: "used" | "remaining") { "aria-selected", "true", ); + await closeUsageOptions(page); } export async function refreshAllUsage(page: Page): Promise { @@ -199,9 +301,3 @@ export async function refreshAllUsage(page: Page): Promise { export async function openCompactSidebar(page: Page): Promise { await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); } - -/** On a phone the Usage screen has a back header instead of the sidebar menu. */ -export async function leaveUsageScreen(page: Page): Promise { - await page.getByRole("button", { name: "Back", exact: true }).first().click(); - await expect(page).not.toHaveURL(/\/usage$/); -} diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts index 9477556823..8ddd507563 100644 --- a/packages/app/e2e/support/helpers/workspace-tabs.ts +++ b/packages/app/e2e/support/helpers/workspace-tabs.ts @@ -89,7 +89,7 @@ async function openExplorerView( ): Promise { const explorer = await ensureExplorerSidebar(page); const tab = explorer.getByTestId(view.tabTestId); - await tab.click(); + await selectWorkspaceTab(tab); await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({ timeout: view.timeout ?? 30_000, }); @@ -97,7 +97,7 @@ async function openExplorerView( export async function openChangesTreePanel(page: Page): Promise { await openExplorerView(page, { - tabTestId: "explorer-sidebar-tab-changes_tree", + tabTestId: "workspace-tab-changes_tree", contentTestId: "changes-tree-panel", }); } @@ -117,7 +117,7 @@ export async function openChangesPanel(page: Page, timeout = 30_000): Promise { await openExplorerView(page, { - tabTestId: "explorer-sidebar-tab-files", + tabTestId: "workspace-tab-files", contentTestId: "file-explorer-tree-scroll", }); } 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/maestro/sidebar-scroll-retention.yaml b/packages/app/maestro/sidebar-scroll-retention.yaml new file mode 100644 index 0000000000..f7ded2e617 --- /dev/null +++ b/packages/app/maestro/sidebar-scroll-retention.yaml @@ -0,0 +1,53 @@ +appId: sh.paseo.debug +--- +# Start in a workspace with the sidebar closed. Both workspace IDs come from +# the connected host: TOP_WORKSPACE is near the top; SCROLLED_WORKSPACE starts +# below the viewport. The list must remain unchanged throughout the flow. +- tapOn: + id: menu-button +- scrollUntilVisible: + element: + id: sidebar-workspace-row-${TOP_WORKSPACE} + direction: UP + timeout: 60000 +- assertNotVisible: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- scrollUntilVisible: + element: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} + direction: DOWN + timeout: 60000 +- waitForAnimationToEnd +- assertVisible: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- assertNotVisible: + id: sidebar-workspace-row-${TOP_WORKSPACE} +- tapOn: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- waitForAnimationToEnd +- assertNotVisible: + id: sidebar-project-workspace-list-scroll +- swipe: + start: "5%,50%" + end: "90%,50%" + duration: 300 +- waitForAnimationToEnd +- assertVisible: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- assertNotVisible: + id: sidebar-workspace-row-${TOP_WORKSPACE} + +# Selecting the current workspace removes workspace switching as a cause. +# Reopening by button removes horizontal movement as a cause. +- tapOn: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- waitForAnimationToEnd +- tapOn: + id: menu-button +- waitForAnimationToEnd +- assertVisible: + id: sidebar-workspace-row-${SCROLLED_WORKSPACE} +- assertNotVisible: + id: sidebar-workspace-row-${TOP_WORKSPACE} +- tapOn: + id: sidebar-close diff --git a/packages/app/package.json b/packages/app/package.json index 2d6f308206..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": { @@ -102,7 +102,7 @@ "fast-deep-equal": "^3.1.3", "htmlparser2": "^12.0.0", "i18next": "^26.3.0", - "lucide-react-native": "^0.546.0", + "lucide-react-native": "^1.50.0", "markdown-it": "^10.0.0", "mermaid": "^11.16.0", "mnemonic-id": "^3.2.7", 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/reading-anchor.test.ts b/packages/app/src/agent-stream/reading-anchor.test.ts new file mode 100644 index 0000000000..74b2bd5bcd --- /dev/null +++ b/packages/app/src/agent-stream/reading-anchor.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { createReadingAnchor } from "./reading-anchor"; + +describe("reading anchor", () => { + const rows = [ + { id: "above", top: 0, height: 500 }, + { id: "reading", top: 500, height: 500 }, + { id: "below", top: 1000, height: 500 }, + ]; + + it("preserves wheel movement during an asynchronous prepend", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect( + anchor.reconcile( + 440, + rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })), + ), + ).toBe(2440); + expect( + anchor.reconcile( + 2280, + rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })), + ), + ).toBe(2280); + }); + + it("ignores growth below the reader and compensates growth above", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect(anchor.reconcile(600, [rows[0]!, rows[1]!, { ...rows[2]!, height: 2000 }])).toBe(600); + expect( + anchor.reconcile(600, [ + { ...rows[0]!, height: 1000 }, + { ...rows[1]!, top: 1000 }, + { ...rows[2]!, top: 1500 }, + ]), + ).toBe(1100); + }); + + it("leaves expansion of the row being read in place", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect( + anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 1000 }, { ...rows[2]!, top: 1500 }]), + ).toBe(600); + }); + + it("keeps the existing reader through prepend measurements until the user scrolls", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(0, [{ id: "reading", top: 48, height: 100 }]); + const prepended = [ + { id: "new", top: 2000, height: 57 }, + { id: "reading", top: 2057, height: 100 }, + ]; + expect(anchor.reconcile(0, prepended)).toBe(2009); + expect(anchor.getRowId()).toBe("reading"); + const measured = [ + { id: "new", top: 2000, height: 100 }, + { id: "reading", top: 2100, height: 100 }, + ]; + expect(anchor.reconcile(2009, measured)).toBe(2052); + expect(anchor.getRowId()).toBe("reading"); + expect(anchor.reconcile(2000, measured, true)).toBe(2000); + expect(anchor.getRowId()).toBe("new"); + }); + + it("retains the reader while the virtualizer initializes its mounted range", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.reconcile(600, [rows[2]!]); + expect(anchor.getRowId()).toBe("reading"); + expect( + anchor.reconcile( + 600, + rows.map((row) => ({ ...row, top: row.top + 2000 })), + ), + ).toBe(2600); + }); + + it("projects a prepend range without consuming the correction before layout commits", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440); + expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440); + expect( + anchor.reconcile( + 440, + rows.map((row) => ({ ...row, top: row.top + 2000 })), + true, + ), + ).toBe(2440); + expect(anchor.getRowId()).toBe("above"); + }); + + it("repicks from user movement before applying a simultaneous layout correction", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.scroll(440); + expect(anchor.getRowId()).toBe("above"); + expect( + anchor.reconcile( + 440, + rows.map((row) => ({ ...row, top: row.top + 2000 })), + ), + ).toBe(2440); + expect(anchor.getRowId()).toBe("above"); + }); + + it("releases the old reading position for explicit navigation", () => { + const anchor = createReadingAnchor(); + anchor.reconcile(600, rows); + anchor.reset(); + expect(anchor.getRowId()).toBeNull(); + expect( + anchor.reconcile( + 0, + rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })), + ), + ).toBe(0); + }); +}); diff --git a/packages/app/src/agent-stream/reading-anchor.ts b/packages/app/src/agent-stream/reading-anchor.ts new file mode 100644 index 0000000000..4299f1d9c8 --- /dev/null +++ b/packages/app/src/agent-stream/reading-anchor.ts @@ -0,0 +1,45 @@ +interface RowGeometry { + id: string; + top: number; + height: number; +} + +// A row must clear the reading line before the next row takes ownership. +const READING_POSITION_OFFSET_PX = 8; + +// Content coordinates survive user scrolling. Layout commits replace the geometry; +// scroll events reuse it, without another DOM measurement pass. +export function createReadingAnchor() { + let anchor: { id: string; top: number } | null = null; + let geometry: readonly RowGeometry[] = []; + const readingRow = (scrollTop: number) => + geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX); + const project = (scrollTop: number, row: Pick | undefined) => + scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0); + return { + getRowId: () => anchor?.id ?? null, + getReadingRowId: (scrollTop: number) => + readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null, + project, + reset() { + anchor = null; + }, + scroll(scrollTop: number) { + const next = readingRow(scrollTop); + anchor = next ? { id: next.id, top: next.top } : null; + }, + reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number { + geometry = rows; + const previous = anchor && rows.find((row) => row.id === anchor?.id); + // The first virtualized commit can precede its mounted range. Keep the + // pinned reader until it mounts, rather than adopting an unrelated row. + if (anchor && !previous && !userScrolled) return scrollTop; + const correctedTop = project(scrollTop, previous ?? undefined); + // A prepend can expose the bottom of an estimated row above the reader. + // Do not transfer ownership to it until the user moves the reading position. + const next = previous && !userScrolled ? previous : readingRow(correctedTop); + anchor = next ? { id: next.id, top: next.top } : null; + return correctedTop; + }, + }; +} 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 c0fe52e3fc..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"); @@ -491,10 +584,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 }); - Object.defineProperty(scrollContainer, "scrollTop", { - configurable: true, - value: 4959.1708984375, - }); + scrollContainer.scrollTop = 4959.1708984375; scrollTo.mockClear(); act(() => { @@ -504,10 +594,7 @@ describe("createWebStreamStrategy", () => { expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" }); scrollTo.mockClear(); - Object.defineProperty(scrollContainer, "scrollTop", { - configurable: true, - value: 4960.5, - }); + scrollContainer.scrollTop = 4960.5; act(() => { viewportRef.current?.scrollToBottom("message-sent"); @@ -516,10 +603,7 @@ describe("createWebStreamStrategy", () => { expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" }); scrollTo.mockClear(); - Object.defineProperty(scrollContainer, "scrollTop", { - configurable: true, - value: 4967, - }); + scrollContainer.scrollTop = 4967; act(() => { viewportRef.current?.scrollToBottom("message-sent"); @@ -535,7 +619,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); + this.scrollTop = top; }); const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const viewportRef = React.createRef(); @@ -585,7 +669,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; act(() => { root?.render( @@ -621,10 +705,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { - configurable: true, - value: top, - }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; @@ -682,7 +763,7 @@ describe("createWebStreamStrategy", () => { const scrollElement = scrollContainer; Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 }); Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 }); - Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 }); + scrollElement.scrollTop = 0; await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -724,10 +805,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { - configurable: true, - value: top, - }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; @@ -786,7 +864,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1400 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -798,7 +876,7 @@ describe("createWebStreamStrategy", () => { act(() => { scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 })); }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); @@ -831,10 +909,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { - configurable: true, - value: top, - }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; @@ -892,7 +967,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -901,17 +976,17 @@ describe("createWebStreamStrategy", () => { }); scrollTo.mockClear(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 }); + scrollContainer.scrollTop = 991; act(() => { scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 })); }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 }); + scrollContainer.scrollTop = 91; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); expect(scrollTo).not.toHaveBeenCalled(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); @@ -937,10 +1012,7 @@ describe("createWebStreamStrategy", () => { y?: number, ) { const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); - Object.defineProperty(this, "scrollTop", { - configurable: true, - value: top, - }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; @@ -992,7 +1064,7 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 }); + scrollContainer.scrollTop = 50; await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -1004,13 +1076,13 @@ describe("createWebStreamStrategy", () => { act(() => { scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 })); }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 }); + scrollContainer.scrollTop = 20; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); expect(scrollTo).not.toHaveBeenCalled(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 }); + scrollContainer.scrollTop = 50; act(() => { scrollContainer.dispatchEvent(new Event("scroll")); }); @@ -1038,7 +1110,7 @@ describe("createWebStreamStrategy", () => { it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => { const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) { const top = typeof options === "object" ? (options.top ?? 0) : 0; - Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); @@ -1084,7 +1156,7 @@ describe("createWebStreamStrategy", () => { Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; scrollContainer.getBoundingClientRect = () => ({ bottom: 500, @@ -1124,7 +1196,7 @@ describe("createWebStreamStrategy", () => { window.dispatchEvent(jitterPointerMove); }); Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 }); + scrollContainer.scrollTop = 800; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); scrollTo.mockClear(); @@ -1132,11 +1204,11 @@ describe("createWebStreamStrategy", () => { nestedScroller.style.overflowY = "auto"; Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 }); Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 }); - Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 }); + nestedScroller.scrollTop = 100; scrollContainer.append(nestedScroller); act(() => { nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 })); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 }); + scrollContainer.scrollTop = 700; scrollContainer.dispatchEvent(new Event("scroll")); scrollContainer.dispatchEvent( new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }), @@ -1159,7 +1231,7 @@ describe("createWebStreamStrategy", () => { it("stops following output after scrollbar and middle-button upward scrolling", () => { const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) { const top = typeof options === "object" ? (options.top ?? 0) : 0; - Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); @@ -1204,7 +1276,7 @@ describe("createWebStreamStrategy", () => { Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; scrollContainer.getBoundingClientRect = () => ({ bottom: 500, @@ -1232,7 +1304,7 @@ describe("createWebStreamStrategy", () => { pointerType: { value: "mouse" }, }); act(() => scrollContainer.dispatchEvent(scrollbarPointerDown)); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 }); + scrollContainer.scrollTop = 700; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); scrollTo.mockClear(); @@ -1248,7 +1320,7 @@ describe("createWebStreamStrategy", () => { }); expect(scrollTo).not.toHaveBeenCalled(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 }); + scrollContainer.scrollTop = 1300; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 }); scrollContainer.getBoundingClientRect = () => @@ -1276,7 +1348,7 @@ describe("createWebStreamStrategy", () => { pointerType: { value: "mouse" }, }); act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown)); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 }); + scrollContainer.scrollTop = 900; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); scrollTo.mockClear(); @@ -1295,7 +1367,7 @@ describe("createWebStreamStrategy", () => { }); expect(scrollTo).not.toHaveBeenCalled(); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 }); + scrollContainer.scrollTop = 1400; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); const autoscrollPointerDown = new MouseEvent("pointerdown", { bubbles: true, @@ -1329,7 +1401,7 @@ describe("createWebStreamStrategy", () => { window.dispatchEvent(autoscrollPointerUp); window.dispatchEvent(autoscrollPointerMove); }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); scrollTo.mockClear(); @@ -1390,7 +1462,7 @@ describe("createWebStreamStrategy", () => { }; const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) { const top = typeof options === "object" ? (options.top ?? 0) : 0; - Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); + this.scrollTop = top; }); HTMLElement.prototype.scrollTo = scrollTo; const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); @@ -1447,12 +1519,18 @@ describe("createWebStreamStrategy", () => { throw new Error("Expected retained history rows"); } let rowsShifted = false; - scrollContainer.getBoundingClientRect = vi.fn(() => ({ top: 0 }) as DOMRect); - firstRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 4 : 120 }) as DOMRect); - secondRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 120 : 160 }) as DOMRect); + scrollContainer.getBoundingClientRect = () => ({ top: 0, height: 500, bottom: 500 }) as DOMRect; + firstRow.getBoundingClientRect = () => + ({ top: rowsShifted ? -116 : 0, height: 120, bottom: rowsShifted ? 4 : 120 }) as DOMRect; + secondRow.getBoundingClientRect = () => + ({ + top: rowsShifted ? 4 : 120, + height: rowsShifted ? 116 : 40, + bottom: rowsShifted ? 120 : 160, + }) as DOMRect; Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 }); + scrollContainer.scrollTop = 1000; act(() => scrollContainer.dispatchEvent(new Event("scroll"))); const viewportObservation = observed.get(scrollContainer); expect(viewportObservation).toBeDefined(); @@ -1483,7 +1561,7 @@ describe("createWebStreamStrategy", () => { act(() => root?.render(renderWithActivity(false))); scrollTo.mockClear(); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); + scrollContainer.scrollTop = 0; act(() => { scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); scrollContainer.dispatchEvent(new Event("scroll")); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index c798c374d4..226b7c1e2a 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -9,21 +9,23 @@ import React, { } from "react"; import { defaultRangeExtractor, + observeElementOffset, measureElement as measureVirtualElement, useVirtualizer, type Range as VirtualRange, + type Virtualizer, } from "@tanstack/react-virtual"; +import { flushSync } from "react-dom"; 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"; -import { - estimateStreamItemHeight, - shouldAdjustScrollForVirtualRowResize, -} from "./web-virtualization"; +import { estimateStreamItemHeight } from "./web-virtualization"; +import { createReadingAnchor } from "./reading-anchor"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { useRevisedHistoryRows } from "./history-row-revision"; import { createStreamStrategy } from "./strategy"; @@ -49,12 +51,6 @@ interface CreateWebStreamStrategyInput { isMobileBreakpoint: boolean; } -interface HistoryStartPrependAnchor { - progressKey: string; - rowId: string; - viewportOffset: number; -} - type ScrollBehaviorLike = "auto" | "smooth"; const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; @@ -67,24 +63,12 @@ const HISTORY_START_SLOT_HEIGHT_PX = 32; const CONTENT_PADDING_TOP_PX = 16; const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100; const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX; -// A row has to clear this much of the viewport top before the next one takes over as the -// reading position, so a row resting exactly on the edge does not flip back and forth. -const READING_POSITION_OFFSET_PX = 8; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null { - for (const element of contentNode.querySelectorAll("[data-history-row-id]")) { - if (element.dataset.historyRowId === rowId) { - return element; - } - } - return null; -} - const historyStartSlotStyle: CSSProperties = { display: "flex", alignItems: "center", @@ -93,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = { flexShrink: 0, }; +const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" }; + const streamRowStyle: CSSProperties = { display: "flex", flexDirection: "column", @@ -285,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, @@ -304,6 +307,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool olderHistoryProgressKey, scrollEnabled, contentMaxWidth, + imageContext, isMobileBreakpoint, } = props; const historyVirtualized = useRevisedHistoryRows( @@ -348,14 +352,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const lastTouchClientYRef = useRef(null); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); - const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); const [historyStartPaginationState, setHistoryStartPaginationState] = useState( createHistoryStartPaginationState, ); const historyStartPaginationStateRef = useRef(historyStartPaginationState); - const historyStartPrependAnchorRef = useRef(null); - const historyStartPrependAnchorActiveRef = useRef(false); + const readingAnchor = useMemo(createReadingAnchor, []); + const measuredRowHeights = useRef(new Map()); const historyStartSettleSchedulerRef = useRef(null); const lastActiveFollowOutputLayoutRef = useRef(null); const lastObservedViewportGeometryRef = useRef(null); @@ -389,46 +392,80 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); return indexes.length > 0 ? indexes : null; }, [chatFindMessageId, segments.historyVirtualized]); + const rowVirtualizerRef = useRef | null>(null); const rangeExtractor = useCallback( - (range: VirtualRange) => { - const visible = defaultRangeExtractor(range); - if (!chatFindRowIndexes) return visible; - return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right); + (range: VirtualRange): number[] => { + const anchorIndex = segments.historyVirtualized.findIndex( + (row) => row.id === readingAnchor.getRowId(), + ); + const container = scrollContainerRef.current; + // TanStack has computed the new measurements before calling the extractor. + // Select against the pending anchor correction, before mounting any rows at + // the stale pre-prepend offset. The DOM correction still belongs to commit. + const virtualizer = rowVirtualizerRef.current!; + const measurement = virtualizer.measurementsCache[anchorIndex]; + const correctedOffset = + container && measurement + ? readingAnchor.project(container.scrollTop, { + id: String(measurement.key), + top: measurement.start, + }) + : container?.scrollTop; + let visibleRange = range; + if ( + container && + correctedOffset !== undefined && + Math.abs(correctedOffset - container.scrollTop) > 0.5 + ) { + const first = virtualizer.getVirtualItemForOffset(correctedOffset); + const last = virtualizer.getVirtualItemForOffset(correctedOffset + container.clientHeight); + if (first && last) + visibleRange = { ...range, startIndex: first.index, endIndex: last.index }; + } + return [ + ...new Set([ + ...defaultRangeExtractor(visibleRange), + ...(chatFindRowIndexes ?? []), + ...(anchorIndex < 0 ? [] : [anchorIndex]), + ]), + ].sort((left, right) => left - right); }, - [chatFindRowIndexes], + [chatFindRowIndexes, readingAnchor, segments.historyVirtualized], ); + const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null); + const observeVirtualOffset = useCallback((instance, onOffset) => { + virtualOffsetListener.current = onOffset; + const unsubscribe = observeElementOffset(instance, onOffset); + return () => { + virtualOffsetListener.current = null; + unsubscribe?.(); + }; + }, []); + const contentWidth = contentRef.current?.getBoundingClientRect().width; 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) => { const row = segments.historyVirtualized[index]; - return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120; + const measured = row && measuredRowHeights.current.get(row.id); + if (measured && measured.width === contentWidth) return measured.height; + return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 120; }, - measureElement: measureVirtualElement, + observeElementOffset: observeVirtualOffset, rangeExtractor, scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX, - useAnimationFrameWithResizeObserver: true, overscan: 8, }); - useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { - const viewportHeight = instance.scrollRect?.height ?? 0; - const scrollOffset = instance.scrollOffset ?? 0; - const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return shouldAdjustScrollForVirtualRowResize({ - isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current, - rowStart: item.start, - scrollOffset, - remainingDistanceFromBottom: remainingDistance, - bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX, - }); - }; - return () => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; - }; - }, [rowVirtualizer]); + rowVirtualizerRef.current = rowVirtualizer; + // Scroll correction belongs to the viewport's committed layout. The virtualizer + // otherwise writes scrollTop before React applies its new row positions. + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => { @@ -442,7 +479,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool distanceFromHistoryStart: scrollContainer.scrollTop, hasOlderHistory, isLoadingOlderHistory, - isReady: historyStartReadyRef.current && bottomAnchorSettled, + // An explicit jump owns the landing position. Revealing another local + // page here changes the virtual index before the target has mounted. + isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(), progressKey: olderHistoryProgressKey, }; }); @@ -453,29 +492,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool if (transition.state !== previousState) { setHistoryStartPaginationState(transition.state); } - if (!isHistoryStartLoadingOperation(transition.state)) { - historyStartPrependAnchorRef.current = null; - historyStartPrependAnchorActiveRef.current = false; - } if (!transition.shouldLoad || olderHistoryProgressKey === null) { return; } - const scrollContainer = scrollContainerRef.current; - const contentNode = contentRef.current; - const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1); - const anchorElement = - contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null; - if (scrollContainer && anchorRow && anchorElement) { - historyStartPrependAnchorRef.current = { - progressKey: olderHistoryProgressKey, - rowId: anchorRow.id, - viewportOffset: - anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, - }; - } else { - historyStartPrependAnchorRef.current = null; - } - historyStartPrependAnchorActiveRef.current = false; const requestedProgressKey = olderHistoryProgressKey; void (async () => { const started = await onNearHistoryStart(); @@ -512,27 +531,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool setHistoryStartPaginationState(rearmed); evaluateHistoryStart(); }); - const applyHistoryStartPrependAnchor = useStableEvent(() => { - const scrollContainer = scrollContainerRef.current; - const contentNode = contentRef.current; - const anchor = historyStartPrependAnchorRef.current; - if ( - !scrollContainer || - !contentNode || - !anchor || - !historyStartPrependAnchorActiveRef.current - ) { - return; - } - const anchorElement = findHistoryRowElement(contentNode, anchor.rowId); - if (!anchorElement) { - return; - } - const viewportOffset = - anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top; - scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset; - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - }); const scheduleHistoryStartPrependSettle = useStableEvent(() => { let scheduler = historyStartSettleSchedulerRef.current; if (!scheduler) { @@ -543,24 +541,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool isSettling: () => historyStartPaginationStateRef.current.status === "settling", isLoading: () => { const input = getHistoryStartPaginationInput(); - return ( - !input || - input.isLoadingOlderHistory || - pendingVirtualRowMeasureFramesRef.current.size > 0 - ); + return !input || input.isLoadingOlderHistory; }, - onFrame: applyHistoryStartPrependAnchor, onSettle: () => { const input = getHistoryStartPaginationInput(); if (!input) { return; } - historyStartPrependAnchorActiveRef.current = false; const transition = settleHistoryStartPagination( historyStartPaginationStateRef.current, input, ); - historyStartPrependAnchorRef.current = null; applyHistoryStartPaginationTransition(transition); }, }); @@ -569,59 +560,41 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduler.schedule(); }); - useLayoutEffect(() => { - if (!isActiveRef.current) { - return; + 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; } - const anchor = historyStartPrependAnchorRef.current; - if (!anchor || anchor.progressKey === olderHistoryProgressKey) { - return; - } - historyStartPrependAnchorActiveRef.current = true; - evaluateHistoryStart(); - applyHistoryStartPrependAnchor(); - scheduleHistoryStartPrependSettle(); - }, [ - applyHistoryStartPrependAnchor, - evaluateHistoryStart, - olderHistoryProgressKey, - scheduleHistoryStartPrependSettle, - segments.historyMounted, - segments.historyVirtualized, - virtualTotalSize, - ]); + 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(() => { + if (!isActiveRef.current) return; + flushSync(() => { + measureVirtualizedRows(); + }); + }), + [measureVirtualizedRows], + ); const measureVirtualizedRowElement = useCallback( (node: HTMLDivElement | null) => { - if (!node) { - rowVirtualizer.measureElement(null); - return; - } - const pendingFrames = pendingVirtualRowMeasureFramesRef.current; - const existingFrame = pendingFrames.get(node); - if (existingFrame !== undefined) { - window.cancelAnimationFrame(existingFrame); - } - const frame = window.requestAnimationFrame(() => { - pendingFrames.delete(node); - if (isActiveRef.current && node.isConnected) { - rowVirtualizer.measureElement(node); - } - }); - pendingFrames.set(node, frame); + if (!node) return; + virtualRowObserver.observe(node, { box: "border-box" }); + return () => virtualRowObserver.unobserve(node); }, - [rowVirtualizer], + [virtualRowObserver], ); - - useEffect(() => { - const pendingFrames = pendingVirtualRowMeasureFramesRef.current; - return () => { - for (const frame of pendingFrames.values()) { - window.cancelAnimationFrame(frame); - } - pendingFrames.clear(); - }; - }, []); + useEffect(() => () => virtualRowObserver.disconnect(), [virtualRowObserver]); const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; @@ -660,10 +633,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } cancelPendingStickToBottom(); historyStartSettleSchedulerRef.current?.cancel(); - for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) { - window.cancelAnimationFrame(frame); - } - pendingVirtualRowMeasureFramesRef.current.clear(); clearMouseScrollGesture(); clearUpwardInputEvidence(); lastTouchClientYRef.current = null; @@ -731,19 +700,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onReadingPositionChange(null); return; } - const readingLine = scrollContainer.getBoundingClientRect().top + READING_POSITION_OFFSET_PX; - let readingRowId: string | null = null; - for (const element of contentNode.querySelectorAll("[data-history-row-id]")) { - const rowId = element.dataset.historyRowId; - if (!rowId) { - continue; - } - readingRowId = rowId; - if (element.getBoundingClientRect().bottom > readingLine) { - break; - } - } - onReadingPositionChange(readingRowId); + onReadingPositionChange(readingAnchor.getReadingRowId(scrollContainer.scrollTop)); }); const updateScrollMetrics = useCallback(() => { @@ -769,6 +726,54 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onNearBottomChange, }); + const reconcileReadingPosition = useStableEvent(() => { + const container = scrollContainerRef.current; + const content = contentRef.current; + if (!isActiveRef.current || !container || !content) return; + const viewportTop = container.getBoundingClientRect().top; + const scrollTop = container.scrollTop; + const width = content.getBoundingClientRect().width; + const rows = Array.from( + content.querySelectorAll("[data-history-row-id]"), + (element) => { + const bounds = element.getBoundingClientRect(); + const id = element.dataset.historyRowId!; + measuredRowHeights.current.set(id, { width, height: bounds.height }); + return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height }; + }, + ); + const shouldAnchor = !followOutputRef.current && !isJumpSettling(); + if (!shouldAnchor) readingAnchor.reset(); + const correctedTop = readingAnchor.reconcile(container.scrollTop, rows); + if (!shouldAnchor) { + readingAnchor.reset(); + return; + } + if (Math.abs(correctedTop - container.scrollTop) > 0.5) { + container.scrollTop = correctedTop; + lastKnownScrollTopRef.current = container.scrollTop; + // The DOM scroll event arrives later. Fill the corrected virtual range in + // this layout so the reader does not get a frame containing only the pin. + virtualOffsetListener.current?.(container.scrollTop, false); + } + }); + + // 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, + measureVirtualizedRows, + reconcileReadingPosition, + ]); + const stopFollowingOutputFromUserIntent = useStableEvent(() => { cancelPendingStickToBottom(); if (followOutputRef.current) { @@ -801,11 +806,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } lastKnownScrollTopRef.current = currentScrollTop; + if (!followOutputRef.current && !isJumpSettling() && (scrolledUp || scrolledDown)) { + readingAnchor.scroll(currentScrollTop); + } updateScrollMetrics(); evaluateHistoryStart(); }, [ evaluateHistoryStart, isJumpSettling, + readingAnchor, stopFollowingOutputFromUserIntent, updateScrollMetrics, ]); @@ -814,8 +823,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const initialHistoryStartState = createHistoryStartPaginationState(); historyStartPaginationStateRef.current = initialHistoryStartState; setHistoryStartPaginationState(initialHistoryStartState); - historyStartPrependAnchorRef.current = null; - historyStartPrependAnchorActiveRef.current = false; + readingAnchor.reset(); const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -826,7 +834,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool historyStartSettleSchedulerRef.current?.cancel(); historyStartSettleSchedulerRef.current = null; }; - }, [evaluateHistoryStart, props.agentId]); + }, [evaluateHistoryStart, props.agentId, readingAnchor]); useLayoutEffect(() => { if (!isActiveRef.current || !isActivationReady) { @@ -947,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; @@ -959,9 +970,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } else { lastObservedViewportGeometryRef.current = nextGeometry; } - if (historyStartPrependAnchorActiveRef.current) { - applyHistoryStartPrependAnchor(); - } + reconcileReadingPosition(); if (historyStartPaginationStateRef.current.status === "settling") { scheduleHistoryStartPrependSettle(); } @@ -980,13 +989,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool observer.disconnect(); }; }, [ - applyHistoryStartPrependAnchor, + reconcileReadingPosition, evaluateHistoryStart, isActive, reportReadingPosition, scheduleHistoryStartPrependSettle, scheduleStickToBottom, updateScrollMetrics, + measureVirtualizedRows, ]); useLayoutEffect(() => { @@ -1101,7 +1111,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool lastTouchClientYRef.current = null; }; - scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true }); + // Read user movement before the virtualizer's bubbling listener flushes a + // layout correction. Scroll events reuse the last committed row geometry. + scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true, capture: true }); scrollContainer.addEventListener("wheel", handleWheel, { passive: true }); scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true }); scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true }); @@ -1115,7 +1127,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true }); return () => { - scrollContainer.removeEventListener("scroll", handleDomScroll); + scrollContainer.removeEventListener("scroll", handleDomScroll, true); scrollContainer.removeEventListener("wheel", handleWheel); scrollContainer.removeEventListener("keydown", handleKeyDown); scrollContainer.removeEventListener("pointerdown", handlePointerDown); @@ -1188,9 +1200,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool overflowX: "hidden", overflowY: scrollEnabled ? "auto" : "hidden", overscrollBehaviorY: "contain", + // The browser still anchors delayed image growth while following output. + // Detached reading has one owner: reconcileReadingPosition. + overflowAnchor: followOutput ? "auto" : "none", scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined, }; - }, [isMobileBreakpoint, scrollEnabled]); + }, [followOutput, isMobileBreakpoint, scrollEnabled]); const viewportStyle = useMemo( (): CSSProperties => ({ position: "relative", @@ -1200,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", @@ -1227,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]); @@ -1270,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/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 5f43f8f6b1..3b77f6b831 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -1,3 +1,4 @@ +import type { AssistantImageContext } from "@/utils/assistant-image-metadata"; import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; @@ -94,6 +95,7 @@ export interface StreamRenderInput { forwardListContentContainerStyle: StyleProp; // The chat column's max width. The web viewport estimates unmeasured row heights with it. contentMaxWidth: number; + imageContext?: AssistantImageContext; } export interface ResolveStreamRenderStrategyInput { 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 bf13ca6330..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 { describe("estimateStreamItemHeight", () => { it("uses compact estimates for collapsed tool sequence rows", () => { - expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40); - expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40); + expect( + estimateStreamItemHeight({ + item: toolCall("tool", 1), + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), + ).toBe(40); + expect( + estimateStreamItemHeight({ + item: thought("thought", 2), + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), + ).toBe(40); }); it("uses a larger estimate for user messages with image attachments", () => { @@ -147,7 +156,40 @@ describe("estimateStreamItemHeight", () => { ], }; - expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220); + expect(estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH })).toBe( + 220, + ); + }); + + it("uses local image dimensions only from the row's server and workspace", () => { + clearAssistantImageMetadataCache(); + const imageContext = { serverId: "server-one", workspaceRoot: "/workspace/one" }; + setAssistantImageMetadata( + { source: "portrait.png", ...imageContext }, + { width: 800, height: 1600 }, + ); + const item: StreamItem = { + kind: "assistant_message", + id: "local-image", + text: "![Portrait](portrait.png)", + timestamp: createTimestamp(2), + }; + + expect(estimateStreamItemHeight({ item, contentMaxWidth: 608, imageContext })).toBe(1264); + expect( + estimateStreamItemHeight({ + item, + contentMaxWidth: 608, + imageContext: { ...imageContext, workspaceRoot: "/workspace/two" }, + }), + ).toBe(464); + expect( + estimateStreamItemHeight({ + item, + contentMaxWidth: 608, + imageContext: { ...imageContext, serverId: "server-two" }, + }), + ).toBe(464); }); it("uses cached assistant image metadata when available", () => { @@ -166,56 +208,9 @@ describe("estimateStreamItemHeight", () => { timestamp: createTimestamp(2), }; - expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220); - }); -}); - -describe("virtual row resize anchoring", () => { - it("does not move the viewport when a visible row expands while detached", () => { expect( - shouldAdjustScrollForVirtualRowResize({ - isHistoryStartPrependActive: false, - rowStart: 1200, - scrollOffset: 1000, - remainingDistanceFromBottom: 5000, - bottomThreshold: 64, - }), - ).toBe(false); - }); - - it("keeps the reading position when a row above the viewport changes size", () => { - expect( - shouldAdjustScrollForVirtualRowResize({ - isHistoryStartPrependActive: false, - rowStart: 800, - scrollOffset: 1000, - remainingDistanceFromBottom: 5000, - bottomThreshold: 64, - }), - ).toBe(true); - }); - - it("leaves history prepend and bottom following to their existing anchors", () => { - const baseInput = { - rowStart: 800, - scrollOffset: 1000, - remainingDistanceFromBottom: 5000, - bottomThreshold: 64, - }; - - expect( - shouldAdjustScrollForVirtualRowResize({ - ...baseInput, - isHistoryStartPrependActive: true, - }), - ).toBe(false); - expect( - shouldAdjustScrollForVirtualRowResize({ - ...baseInput, - isHistoryStartPrependActive: false, - remainingDistanceFromBottom: 64, - }), - ).toBe(false); + estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH }), + ).toBeGreaterThan(220); }); }); diff --git a/packages/app/src/agent-stream/web-virtualization.ts b/packages/app/src/agent-stream/web-virtualization.ts index 13bc737e5f..0c7f756959 100644 --- a/packages/app/src/agent-stream/web-virtualization.ts +++ b/packages/app/src/agent-stream/web-virtualization.ts @@ -1,3 +1,4 @@ +import type { AssistantImageContext } from "@/utils/assistant-image-metadata"; import type { StreamItem } from "@/types/stream"; import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate"; import { @@ -10,21 +11,6 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100; export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS; const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40; -export function shouldAdjustScrollForVirtualRowResize(input: { - isHistoryStartPrependActive: boolean; - rowStart: number; - scrollOffset: number; - remainingDistanceFromBottom: number; - bottomThreshold: number; -}): boolean { - if (input.isHistoryStartPrependActive) { - return false; - } - return ( - input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset - ); -} - type BottomAnchorE2ETestGlobals = typeof globalThis & { __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown; }; @@ -58,12 +44,28 @@ export interface WebVirtualizedHistoryWindow { mountedEntries: IndexedStreamItem[]; } -export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number { +interface StreamItemHeightEstimateInput { + item: StreamItem; + contentMaxWidth: number; + imageContext?: AssistantImageContext; +} + +export function estimateStreamItemHeight({ + item, + contentMaxWidth, + imageContext, +}: StreamItemHeightEstimateInput): number { switch (item.kind) { case "user_message": return item.images && item.images.length > 0 ? 220 : 96; case "assistant_message": - return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220; + return ( + estimateAssistantMessageHeightFromCache({ + markdown: item.text, + contentMaxWidth, + imageContext, + }) ?? 220 + ); case "tool_call": return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE; case "thought": diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 0c926c559c..2d6525b27f 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,6 +1,8 @@ +import { ComposerEditingSessionsProvider } from "@/composer/editing-session"; import "@/styles/unistyles"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; +import { LucideProvider } from "lucide-react-native"; import * as Linking from "expo-linking"; import * as Notifications from "expo-notifications"; import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router"; @@ -34,7 +36,7 @@ import { ChangelogHost } from "@/changelog"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { LeftSidebar } from "@/components/left-sidebar"; -import { WindowSidebarMenuToggle } from "@/components/headers/menu-header"; +import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle"; import { DesktopWindowControls } from "@/components/desktop/window-controls"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler"; @@ -101,7 +103,6 @@ import { useKeyboardActionDispatcher, } from "@/keyboard/keyboard-action-dispatcher-context"; import { polyfillCrypto } from "@/polyfills/crypto"; -import { polyfillNavigator } from "@/polyfills/navigator"; import { getHostRuntimeStore, hasConfiguredLocalDaemonOverride, @@ -113,8 +114,9 @@ import { import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { usePanelStore } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; -import { getNextThemePreference } from "@/styles/theme"; +import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme"; import { useSessionStore } from "@/stores/session-store"; +import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; import { @@ -137,7 +139,6 @@ import { type WebNotificationClickDetail, } from "@/utils/os-notifications"; -polyfillNavigator(); polyfillCrypto(); export interface HostRuntimeBootstrapState { @@ -549,6 +550,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon } const workspaceChrome = ( + {!isCompactLayout ? ( {themedSidebarChrome} @@ -624,7 +626,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon surface ); - return {content}; + return ( + + {content} + + ); } function SidebarChrome({ @@ -659,7 +665,7 @@ function MobileGestureWrapper({ return ( - + {children} @@ -862,7 +868,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) { pathname === "/new" || pathname === "/sessions" || pathname === "/schedules" || - pathname === "/usage" || routeHasKnownHost); return {children}; @@ -895,7 +900,6 @@ function RootStack() { - @@ -972,11 +976,13 @@ function RootAppTree() { return ( - - - - - + + + + + + + ); diff --git a/packages/app/src/app/new.tsx b/packages/app/src/app/new.tsx index 281ec33555..67db3c2c96 100644 --- a/packages/app/src/app/new.tsx +++ b/packages/app/src/app/new.tsx @@ -1,6 +1,15 @@ import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { + ComposerEditingScope, + WorkspaceComposerEditingSessionsProvider, +} from "@/composer/editing-session"; import { NewWorkspaceScreen } from "@/screens/new-workspace-screen"; +import { buildNewWorkspaceDraftKey } from "@/stores/draft-keys"; + +/** New workspace drafts share one editing-session home, keyed like a tab by draft. */ +const NEW_WORKSPACE_SESSIONS_KEY = "new-workspace"; export default function NewWorkspaceRoute() { const params = useLocalSearchParams<{ @@ -23,16 +32,28 @@ export default function NewWorkspaceRoute() { draftId ?? null, ]); + // The shell remounts this route when the viewport crosses the compact breakpoint; + // the draft's editing session (fullscreen, retained caret) lives above the shell. + const draftKey = buildNewWorkspaceDraftKey(draftId); + const sessionTabIds = useMemo(() => [draftKey], [draftKey]); + return ( - + + + + + ); } diff --git a/packages/app/src/app/usage.tsx b/packages/app/src/app/usage.tsx deleted file mode 100644 index 2928cca3f3..0000000000 --- a/packages/app/src/app/usage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { UsageScreen } from "@/usage"; - -export default function UsageRoute() { - return ( - - - - ); -} diff --git a/packages/app/src/appearance/provider.tsx b/packages/app/src/appearance/provider.tsx index d52864e435..77addad9a3 100644 --- a/packages/app/src/appearance/provider.tsx +++ b/packages/app/src/appearance/provider.tsx @@ -21,6 +21,7 @@ import { } from "@/plugins/themes"; import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme"; import { applyAppearance } from "./apply"; +import { AppearanceSystemBars } from "./system-bars"; interface ContributedThemes { options: PluginThemeOption[]; @@ -107,7 +108,10 @@ export function AppearanceProvider({ children }: { children: ReactNode }) { if (!hasAppliedAppearance) return null; return ( - {children} + + + {children} + ); } diff --git a/packages/app/src/appearance/system-bars.android.tsx b/packages/app/src/appearance/system-bars.android.tsx new file mode 100644 index 0000000000..f36fb60599 --- /dev/null +++ b/packages/app/src/appearance/system-bars.android.tsx @@ -0,0 +1,11 @@ +import { SystemBars } from "react-native-edge-to-edge"; +import { withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; + +function ThemeSystemBars({ colorScheme }: Pick) { + return ; +} + +export const AppearanceSystemBars = withUnistyles(ThemeSystemBars, (theme) => ({ + colorScheme: theme.colorScheme, +})); diff --git a/packages/app/src/appearance/system-bars.tsx b/packages/app/src/appearance/system-bars.tsx new file mode 100644 index 0000000000..930579bc4a --- /dev/null +++ b/packages/app/src/appearance/system-bars.tsx @@ -0,0 +1,3 @@ +export function AppearanceSystemBars() { + return null; +} diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index 65f123cb3d..cebb4ca76d 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -48,6 +48,8 @@ export const ACP_PROVIDER_ICON_SVGS = { goose: '\n \n\n', grok: '\n \n \n\n', + hoplite: + '\n', junie: '\n \n \n \n\n', kilo: '\n \n\n', diff --git a/packages/app/src/assets/acp-provider-icons/hoplite.svg b/packages/app/src/assets/acp-provider-icons/hoplite.svg new file mode 100644 index 0000000000..4d9c443429 --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/hoplite.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/assistant-file-links/parse.test.ts b/packages/app/src/assistant-file-links/parse.test.ts index 12921b855c..3390d0cfa9 100644 --- a/packages/app/src/assistant-file-links/parse.test.ts +++ b/packages/app/src/assistant-file-links/parse.test.ts @@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => { } expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow(); }); + + it("decodes percent-encoded relative markdown hrefs", () => { + // markdown-it percent-encodes non-ASCII characters in hrefs, so a link + // written as [x](docs/reports/开户赠金.md) arrives here percent-encoded. + expect( + parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", + path: "/Users/test/project/docs/reports/开户赠金.md", + lineStart: undefined, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded spaces in relative markdown hrefs", () => { + expect( + parseAssistantFileLink("docs/my%20file.md#L5", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "docs/my%20file.md#L5", + path: "/Users/test/project/docs/my file.md", + lineStart: 5, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded absolute hrefs with line suffixes", () => { + expect( + parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "/tmp/%E5%BC%80%E6%88%B7.md:3", + path: "/tmp/开户.md", + lineStart: 3, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded home-relative hrefs", () => { + expect( + parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", { + workspaceRoot: "/Users/test/project", + decodeHref: true, + }), + ).toEqual({ + raw: "~/notes/%E5%BC%80%E6%88%B7.md", + path: "~/notes/开户.md", + lineStart: undefined, + lineEnd: undefined, + }); + }); + + it("decodes percent-encoded Windows hrefs", () => { + expect( + parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", { + workspaceRoot: "C:/repo", + decodeHref: true, + }), + ).toEqual({ + raw: "C:/repo/my%20dir/app.tsx#L8", + path: "C:/repo/my dir/app.tsx", + lineStart: 8, + lineEnd: undefined, + }); + }); + + it("keeps literal percent signs in raw paths that are not hrefs", () => { + // Inline-code and linkified tokens are raw file paths: a real file named + // "my%20file.md" must not be decoded into "my file.md". + expect( + parseAssistantFileLink("docs/my%20file.md", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "docs/my%20file.md", + path: "/Users/test/project/docs/my%20file.md", + lineStart: undefined, + lineEnd: undefined, + }); + expect( + parseAssistantFileLink("/tmp/my%20file.md:3", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "/tmp/my%20file.md:3", + path: "/tmp/my%20file.md", + lineStart: 3, + lineEnd: undefined, + }); + }); + + it("keeps non-ASCII characters in raw absolute paths", () => { + // `new URL` percent-encodes non-ASCII, so raw paths must not be routed + // through URL parsing — `/tmp/开户.md` must round-trip unchanged. + expect( + parseAssistantFileLink("/tmp/开户.md", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "/tmp/开户.md", + path: "/tmp/开户.md", + lineStart: undefined, + lineEnd: undefined, + }); + expect( + parseAssistantFileLink("/tmp/开户.md#L4", { + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + raw: "/tmp/开户.md#L4", + path: "/tmp/开户.md", + lineStart: 4, + lineEnd: undefined, + }); + }); }); describe("normalizeInlinePathTarget", () => { @@ -469,3 +591,23 @@ describe("normalizeInlinePathTarget", () => { }); }); }); + +describe("encoded filename line markers", () => { + it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])( + "keeps encoded line syntax in the filename %s", + (href) => { + expect( + parseAssistantFileLink(href, { + workspaceRoot: "/workspace", + decodeHref: true, + }), + ).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` }); + expect( + parseAssistantFileLink(href, { + workspaceRoot: "/workspace", + decodeHref: true, + })?.lineStart, + ).toBeUndefined(); + }, + ); +}); diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index 092f3b4dcd..0a8089acca 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([ export interface AssistantHrefParseOptions { workspaceRoot?: string; + /** + * True only when `value` is a markdown href — the renderer percent-encodes + * non-ASCII and space characters, so the path must be decoded. Raw path + * text (inline code, linkified tokens) is not encoded; decoding it would + * send a real file named `my%20file.md` to `my file.md`. + */ + decodeHref?: boolean; } export type AssistantFileLinkClassification = @@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null { }; } -function parseAssistantInlinePathLink(value: string): InlinePathTarget | null { +function parseAssistantInlinePathLink( + value: string, + options: Pick = {}, +): InlinePathTarget | null { const inlinePathTarget = parseInlinePathToken(value); if (!inlinePathTarget) { return null; } - const normalizedPath = normalizePathToken(inlinePathTarget.path); + let pathPart = inlinePathTarget.path; + if (options.decodeHref) { + pathPart = safeDecodeURIComponent(pathPart); + } + const normalizedPath = normalizePathToken(pathPart); if (!normalizedPath || !isAbsolutePath(normalizedPath)) { return null; } @@ -277,7 +291,7 @@ export function classifyAssistantFileLink( return null; } - if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) { + if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) { return { kind: "ambiguousFileCandidate", target, @@ -308,32 +322,19 @@ export function parseAssistantFileLink( return null; } - const inlinePathTarget = parseAssistantInlinePathLink(trimmed); + const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options); if (inlinePathTarget) { return inlinePathTarget; } - const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/); - if (windowsPathMatch) { - const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? ""); - if (!normalizedPath) { - return null; - } - - const lines = parseLineFragment(windowsPathMatch[2] ?? ""); - if (!lines) { - return null; - } - - return { - raw: value, - path: normalizedPath, - ...lines, - }; + const windowsTarget = parseWindowsFileLink(trimmed, value, options); + if (windowsTarget) { + return windowsTarget; } const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, { workspaceRoot: options.workspaceRoot, + decodeHref: options.decodeHref, }); if (relativeTarget) { return relativeTarget; @@ -343,19 +344,33 @@ export function parseAssistantFileLink( return null; } - let parsedUrl: URL; - try { - parsedUrl = new URL(trimmed, "http://paseo.invalid"); - } catch { - return null; + // Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments + // normalize before decode. Raw paths must not: `new URL` percent-encodes + // non-ASCII characters, which would corrupt `/tmp/开户.md` into the + // still-encoded name. + let pathPart: string; + let hashPart: string; + if (options.decodeHref) { + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmed, "http://paseo.invalid"); + } catch { + return null; + } + pathPart = safeDecodeURIComponent(parsedUrl.pathname); + hashPart = parsedUrl.hash; + } else { + const hashIndex = trimmed.indexOf("#"); + pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed; + hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : ""; } - const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname)); + const normalizedPath = normalizePathToken(pathPart); if (!normalizedPath || !isAbsolutePath(normalizedPath)) { return null; } - const lines = parseLineFragment(parsedUrl.hash); + const lines = parseLineFragment(hashPart); if (!lines) { return null; } @@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean { return isPlausibleAssistantLocalPath(path); } +function parseWindowsFileLink( + trimmed: string, + raw: string, + options: Pick = {}, +): InlinePathTarget | null { + const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/); + if (!windowsPathMatch) { + return null; + } + + let pathPart = windowsPathMatch[1] ?? ""; + if (options.decodeHref) { + pathPart = safeDecodeURIComponent(pathPart); + } + const normalizedPath = normalizePathToken(pathPart); + if (!normalizedPath) { + return null; + } + + const lines = parseLineFragment(windowsPathMatch[2] ?? ""); + if (!lines) { + return null; + } + + return { + raw, + path: normalizedPath, + ...lines, + }; +} + function parseWorkspaceRelativeFileLink( value: string, options: AssistantHrefParseOptions, ): InlinePathTarget | null { - const parsed = parseLocalPathParts(value); + const parsed = parseLocalPathParts(value, options); if (!parsed || isAbsolutePath(parsed.path)) { return null; } @@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink( function parseLocalPathParts( value: string, -): { path: string; lines: Pick } | null { + options: Pick = {}, +): { + path: string; + lines: Pick; +} | null { const normalized = normalizePathToken(value); if (!normalized || normalized.includes("?")) { return null; @@ -436,14 +486,19 @@ function parseLocalPathParts( return null; } + // Split literal line markers before decoding; encoded punctuation belongs + // to the filename. Raw inline-code/path text stays untouched. const inlinePathTarget = parseInlinePathToken(beforeHash); if (inlinePathTarget) { - if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) { + let pathPart = inlinePathTarget.path; + if (options.decodeHref) { + pathPart = safeDecodeURIComponent(pathPart); + } + if (!isPlausibleAssistantLocalPath(pathPart)) { return null; } - return { - path: inlinePathTarget.path, + path: pathPart, lines: { lineStart: inlinePathTarget.lineStart, lineEnd: inlinePathTarget.lineEnd, @@ -455,12 +510,17 @@ function parseLocalPathParts( return null; } - if (!isPlausibleAssistantLocalPath(beforeHash)) { + let decodedBeforeHash = beforeHash; + if (options.decodeHref) { + decodedBeforeHash = safeDecodeURIComponent(beforeHash); + } + const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash; + if (!isPlausibleAssistantLocalPath(plausiblePath)) { return null; } return { - path: beforeHash, + path: decodedBeforeHash, lines: fragmentLines, }; } @@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean { function isAmbiguousWorkspaceCandidate( value: string, target: InlinePathTarget, - workspaceRoot?: string, + options: AssistantHrefParseOptions = {}, ): boolean { - const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot); + const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot); if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) { return false; } - const parsed = parseLocalPathParts(value); + const parsed = parseLocalPathParts(value, options); if (!parsed || isAbsolutePath(parsed.path)) { return false; } diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 312bfff1fd..384c3b6857 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import MarkdownIt from "markdown-it"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { classifyForResolution, fetchDaemonResolution, @@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = { workspaceRoot: "/Users/test/project", }; +it("opens the existing non-ASCII file named by a rendered markdown link", async () => { + const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-")); + try { + await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content"); + const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {}); + const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href"); + expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md"); + const resolution = classifyForResolution({ href: href! }, { workspaceRoot }); + expect(resolution.kind).toBe("resolved"); + if (resolution.kind !== "resolved" || resolution.value.kind !== "file") { + throw new Error("Expected a directly resolved file link"); + } + expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content"); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } +}); + function resolvedSuggestions( entries: DirectorySuggestionResult["entries"], ): DirectorySuggestionResult { @@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => { }; describe("classifyForResolution", () => { + it.each([ + ["C:/repo/my%20file.md", undefined], + ["C:\\repo\\my%20file.md", undefined], + ["C:/repo/my%20file.md:12", 12], + ["C:\\repo\\my%20file.md:12", 12], + ])("keeps the literal filename in Windows inline code %s", (href, lineStart) => { + for (const text of [href, undefined]) { + const result = classifyForResolution( + { href, text, sourceType: "inline-code" }, + { workspaceRoot: "C:/repo" }, + ); + expect(result).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } }, + }); + } + expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } }, + }); + }); it("returns the directFile target synchronously", () => { const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT); @@ -165,6 +209,46 @@ describe("classifyForResolution", () => { expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } }); }); + + it("decodes percent-encoded markdown hrefs", () => { + const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT); + + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "docs/my%20file.md", + path: "/Users/test/project/docs/my file.md", + lineStart: undefined, + lineEnd: undefined, + }, + }, + }); + }); + + it("keeps literal percent signs in inline-code paths", () => { + const result = classifyForResolution( + { + href: "docs/my%20file.md", + text: "docs/my%20file.md", + sourceType: "inline-code", + }, + CONTEXT, + ); + + expect(result).toEqual({ + kind: "needsLookup", + ambiguousQuery: "docs/my%20file.md", + token: "docs/my%20file.md", + target: { + raw: "docs/my%20file.md", + path: "/Users/test/project/docs/my%20file.md", + lineStart: undefined, + lineEnd: undefined, + }, + }); + }); }); describe("fetchDaemonResolution", () => { diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index 5fa4849fc8..774d39278b 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -108,13 +108,15 @@ export function classifyForResolution( source: AssistantFileLinkSource, context: AssistantFileLinkContext, ): AssistantFileLinkResolution { - const token = getAssistantFileLinkToken(source).trim(); + const tokenInfo = getAssistantFileLinkTokenInfo(source); + const token = tokenInfo.token.trim(); if (!token) { return { kind: "resolved", value: { kind: "ignored" } }; } const classification = classifyAssistantFileLink(token, { workspaceRoot: context.workspaceRoot, + decodeHref: tokenInfo.fromHref, }); if (!classification) { return { kind: "resolved", value: { kind: "ignored" } }; @@ -148,14 +150,26 @@ export function classifyForResolution( } export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string { - if (isLinkifiedSource(source) || source.sourceType === "inline-code") { + return getAssistantFileLinkTokenInfo(source).token; +} + +// Markdown link hrefs are percent-encoded by the renderer; inline-code and +// linkified text are raw file paths that must not be decoded. +function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): { + token: string; + fromHref: boolean; +} { + if (source.sourceType === "inline-code") { + return { token: source.text?.trim() || source.href, fromHref: false }; + } + if (isLinkifiedSource(source)) { const text = source.text?.trim(); if (text && isFileLookingAssistantToken(text)) { - return text; + return { token: text, fromHref: false }; } } - return source.href; + return { token: source.href, fromHref: true }; } export function getAmbiguousSuggestionQuery( diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts index 2a08594aa5..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, - ambiguousQuery: resolution.ambiguousQuery, + resolution, }) : DISABLED_QUERY_KEY, [resolution, serverId, workspaceRoot], @@ -179,7 +186,7 @@ function openAssistantFileLink(input: { const capturedQueryKey = assistantFileLinkQueryKey({ serverId: capturedConfig.serverId, workspaceRoot: capturedConfig.workspaceRoot, - ambiguousQuery: capturedResolution.ambiguousQuery, + resolution: capturedResolution, }); const run = async () => { @@ -245,13 +252,18 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour function assistantFileLinkQueryKey(input: { serverId?: string; workspaceRoot?: string; - ambiguousQuery: string; + resolution: Extract; }): AssistantFileLinkQueryKey { return [ "assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, - input.ambiguousQuery, + { + query: input.resolution.ambiguousQuery, + path: input.resolution.target.path, + lineStart: input.resolution.target.lineStart ?? null, + lineEnd: input.resolution.target.lineEnd ?? null, + }, ]; } diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts index 0c0818e8f1..3314b7a398 100644 --- a/packages/app/src/assistant-image/use-assistant-image.ts +++ b/packages/app/src/assistant-image/use-assistant-image.ts @@ -415,8 +415,8 @@ export function useAssistantImage({ const preview = dataImage ? dataImagePreview : filePreview; const previewUri = preview.status === "loaded" ? preview.uri : null; const uri = directUri ?? previewUri; - const cachedMetadata = useMemo( - () => getAssistantImageMetadata({ source, workspaceRoot, serverId }), + const cachedAspectRatio = useMemo( + () => getAssistantImageMetadata({ source, workspaceRoot, serverId })?.aspectRatio ?? null, [serverId, source, workspaceRoot], ); const [lifecycle, dispatchLifecycle] = useReducer( @@ -454,9 +454,9 @@ export function useAssistantImage({ dispatch({ type: "preview_created", uri, - aspectRatio: cachedMetadata?.aspectRatio ?? null, + aspectRatio: cachedAspectRatio, }); - }, [cachedMetadata, dispatch, uri]); + }, [cachedAspectRatio, dispatch, uri]); const handleImageError = useCallback(() => { if (uri) { @@ -484,7 +484,7 @@ export function useAssistantImage({ const metadata = dimensions ? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions) : null; - const aspectRatio = metadata?.aspectRatio ?? cachedMetadata?.aspectRatio ?? null; + const aspectRatio = metadata?.aspectRatio ?? cachedAspectRatio; if (!aspectRatio) { dispatch({ type: "failed", @@ -495,7 +495,7 @@ export function useAssistantImage({ } dispatch({ type: "image_loaded", uri, aspectRatio }); }, - [cachedMetadata, dispatch, serverId, source, t, uri, workspaceRoot], + [cachedAspectRatio, dispatch, serverId, source, t, uri, workspaceRoot], ); const acquisitionFailure = getAcquisitionFailure({ @@ -539,6 +539,6 @@ export function useAssistantImage({ return { status: "loading", binding, - aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null, + aspectRatio: (hasCurrentLifecycleUri ? lifecycle.aspectRatio : null) ?? cachedAspectRatio, }; } 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/voice/audio-engine-types.ts b/packages/app/src/audio/audio-engine-types.ts similarity index 88% rename from packages/app/src/voice/audio-engine-types.ts rename to packages/app/src/audio/audio-engine-types.ts index 03108b5cf6..90bec732fb 100644 --- a/packages/app/src/voice/audio-engine-types.ts +++ b/packages/app/src/audio/audio-engine-types.ts @@ -20,7 +20,7 @@ export interface AudioEngine { toggleMute(): boolean; isMuted(): boolean; - play(audio: AudioPlaybackSource): Promise; + play(audio: AudioPlaybackSource, signal?: AbortSignal): Promise; stop(): void; clearQueue(): void; isPlaying(): boolean; diff --git a/packages/app/src/voice/audio-engine.d.ts b/packages/app/src/audio/audio-engine.d.ts similarity index 100% rename from packages/app/src/voice/audio-engine.d.ts rename to packages/app/src/audio/audio-engine.d.ts diff --git a/packages/app/src/audio/audio-engine.native.ts b/packages/app/src/audio/audio-engine.native.ts new file mode 100644 index 0000000000..86a6fc3d26 --- /dev/null +++ b/packages/app/src/audio/audio-engine.native.ts @@ -0,0 +1,224 @@ +import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types"; + +import { createAudioPlayer, setAudioModeAsync } from "expo-audio"; +import { File, Paths } from "expo-file-system"; +import { createPlaybackQueue } from "./playback"; +import { playFile } from "./file-playback"; +import { playPcm16 } from "./pcm"; + +export function createAudioEngine( + callbacks: AudioEngineCallbacks, + _options?: { traceLabel?: string }, +): AudioEngine { + const native = require("@getpaseo/expo-two-way-audio"); + + const refs: { + initialized: boolean; + captureActive: boolean; + muted: boolean; + destroyed: boolean; + } = { + initialized: false, + captureActive: false, + muted: false, + destroyed: false, + }; + + const microphoneSubscription = native.addExpoTwoWayAudioEventListener( + "onMicrophoneData", + (event: { data: Uint8Array }) => { + if (!refs.captureActive || refs.muted) { + return; + } + const pcm = event.data; + callbacks.onCaptureData(pcm); + }, + ); + const volumeSubscription = native.addExpoTwoWayAudioEventListener( + "onInputVolumeLevelData", + (event: { data: number }) => { + if (!refs.captureActive) { + return; + } + const level = refs.muted ? 0 : event.data; + callbacks.onVolumeLevel(level); + }, + ); + const interruptionSubscription = native.addExpoTwoWayAudioEventListener( + "onAudioInterruption", + (event: { data: string }) => { + if (event.data !== "blocked") { + return; + } + const wasCaptureActive = refs.captureActive; + refs.captureActive = false; + refs.muted = false; + callbacks.onVolumeLevel(0); + if (wasCaptureActive) { + callbacks.onInterruption?.(); + } + }, + ); + + async function ensureInitialized(): Promise { + if (refs.initialized) { + return; + } + const success = await native.initialize(); + if (!success) { + throw new Error("expo-two-way-audio: native initialize() returned false"); + } + refs.initialized = true; + } + + /** + * Release the OS audio session as soon as we are neither capturing nor playing. + * Holding it keeps the user's background music paused — on iOS the non-mixing + * `.playAndRecord` category survives backgrounding and is re-asserted on every + * foreground, so an unreleased session means their music never comes back. + */ + function releaseSessionIfIdle(): void { + if (!refs.initialized || refs.destroyed) { + return; + } + if (refs.captureActive || playback.isPlaying()) { + return; + } + // The wrapper no-ops on binaries whose native module predates this function. + native.releaseAudioSession(); + } + + async function ensureMicrophonePermission(): Promise { + let permission = await native.getMicrophonePermissionsAsync().catch(() => null); + if (!permission?.granted) { + permission = await native.requestMicrophonePermissionsAsync().catch(() => null); + } + if (!permission?.granted) { + throw new Error( + "Microphone permission is required to capture audio. Please enable microphone access in system settings.", + ); + } + } + + let nextFileId = 0; + async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise { + const bytes = new Uint8Array(await audio.arrayBuffer()); + if (signal.aborted) throw new Error("Playback stopped"); + if (audio.type.startsWith("audio/pcm")) { + await ensureInitialized(); + return playPcm16(bytes, audio.type, signal, native); + } + // Capture owns its audio session while active. File playback alone must not + // initialize the microphone or the native two-way engine. + if (!refs.captureActive) { + await setAudioModeAsync({ + playsInSilentMode: true, + allowsRecording: false, + interruptionMode: "duckOthers", + interruptionModeAndroid: "duckOthers", + }); + } + if (signal.aborted) throw new Error("Playback stopped"); + // AVPlayer needs a file extension to recognize local encoded audio on iOS. + const extension = + { + "audio/wav": "wav", + "audio/x-wav": "wav", + "audio/wave": "wav", + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/mp4": "m4a", + "audio/aac": "aac", + "audio/ogg": "ogg", + "audio/flac": "flac", + }[audio.type.split(";")[0].trim()] ?? "audio"; + const file = new File(Paths.cache, `paseo-audio-${Date.now()}-${nextFileId++}.${extension}`); + try { + file.write(bytes); + const player = createAudioPlayer(file.uri, { + updateInterval: 100, + keepAudioSessionActive: refs.captureActive, + }); + return await playFile(player, signal); + } finally { + if (file.exists) file.delete(); + } + } + const playback = createPlaybackQueue(playAudio, releaseSessionIfIdle); + + return { + async initialize() { + await ensureInitialized(); + }, + + async destroy() { + if (refs.destroyed) { + return; + } + refs.destroyed = true; + playback.destroy(); + if (refs.captureActive) { + native.toggleRecording(false); + refs.captureActive = false; + } + refs.muted = false; + callbacks.onVolumeLevel(0); + if (refs.initialized) { + native.tearDown(); + refs.initialized = false; + } + microphoneSubscription.remove(); + volumeSubscription.remove(); + interruptionSubscription.remove(); + }, + + async startCapture() { + if (refs.captureActive) { + return; + } + + try { + await ensureMicrophonePermission(); + await ensureInitialized(); + const isRecording = native.toggleRecording(true); + if (!isRecording) { + throw new Error( + "Microphone capture could not start because Android audio focus is unavailable.", + ); + } + refs.captureActive = true; + } catch (error) { + const wrapped = error instanceof Error ? error : new Error(String(error)); + callbacks.onError?.(wrapped); + throw wrapped; + } + }, + + async stopCapture() { + if (refs.captureActive) { + native.toggleRecording(false); + } + refs.captureActive = false; + refs.muted = false; + callbacks.onVolumeLevel(0); + releaseSessionIfIdle(); + }, + + toggleMute() { + refs.muted = !refs.muted; + if (refs.muted) { + callbacks.onVolumeLevel(0); + } + return refs.muted; + }, + + isMuted() { + return refs.muted; + }, + + play: playback.play, + stop: playback.stop, + clearQueue: playback.clearQueue, + isPlaying: playback.isPlaying, + }; +} diff --git a/packages/app/src/voice/audio-engine.web.ts b/packages/app/src/audio/audio-engine.web.ts similarity index 63% rename from packages/app/src/voice/audio-engine.web.ts rename to packages/app/src/audio/audio-engine.web.ts index 0453c0ae33..dc0f049ca7 100644 --- a/packages/app/src/voice/audio-engine.web.ts +++ b/packages/app/src/audio/audio-engine.web.ts @@ -1,15 +1,8 @@ +import { parsePcmSampleRate, resampleToPcm16 } from "./pcm"; import { isElectronRuntime } from "@/desktop/host"; -import type { - AudioEngine, - AudioEngineCallbacks, - AudioPlaybackSource, -} from "@/voice/audio-engine-types"; +import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types"; -interface QueuedAudio { - audio: AudioPlaybackSource; - resolve: (duration: number) => void; - reject: (error: Error) => void; -} +import { createPlaybackQueue } from "./playback"; function getAudioContextCtor(): typeof AudioContext | null { if (typeof window === "undefined") { @@ -21,40 +14,6 @@ function getAudioContextCtor(): typeof AudioContext | null { return browserWindow.AudioContext ?? browserWindow.webkitAudioContext ?? null; } -function floatToInt16(sample: number): number { - const clamped = Math.max(-1, Math.min(1, sample)); - return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); -} - -function resampleToPcm16(input: Float32Array, inputRate: number, outputRate: number): Uint8Array { - if (input.length === 0) { - return new Uint8Array(0); - } - - const ratio = inputRate / outputRate; - const outputLength = Math.max(1, Math.round(input.length / ratio)); - const output = new Int16Array(outputLength); - for (let i = 0; i < outputLength; i += 1) { - const sourceIndex = i * ratio; - const i0 = Math.floor(sourceIndex); - const i1 = Math.min(input.length - 1, i0 + 1); - const frac = sourceIndex - i0; - const sample = input[i0] * (1 - frac) + input[i1] * frac; - output[i] = floatToInt16(sample); - } - - return new Uint8Array(output.buffer, output.byteOffset, output.byteLength); -} - -function parsePcmSampleRate(mimeType: string): number | null { - const match = /rate=(\d+)/i.exec(mimeType); - if (!match) { - return null; - } - const rate = Number(match[1]); - return Number.isFinite(rate) && rate > 0 ? rate : null; -} - function pcm16LeToAudioBuffer( context: AudioContext, bytes: Uint8Array, @@ -98,14 +57,6 @@ export function createAudioEngine( gain: GainNode | null; started: boolean; muted: boolean; - queue: QueuedAudio[]; - processingQueue: boolean; - activePlayback: { - source: AudioBufferSourceNode; - resolve: (duration: number) => void; - reject: (error: Error) => void; - settled: boolean; - } | null; } = { playbackContext: null, captureContext: null, @@ -115,29 +66,31 @@ export function createAudioEngine( gain: null, started: false, muted: false, - queue: [], - processingQueue: false, - activePlayback: null, }; async function ensurePlaybackContext(): Promise { - if (refs.playbackContext) { - if (refs.playbackContext.state === "suspended") { - await refs.playbackContext.resume().catch(() => undefined); - } - return refs.playbackContext; + if (!refs.playbackContext) { + const AudioContextCtor = getAudioContextCtor(); + if (!AudioContextCtor) throw new Error("AudioContext unavailable"); + refs.playbackContext = new AudioContextCtor(); } - - const AudioContextCtor = getAudioContextCtor(); - if (!AudioContextCtor) { - throw new Error("AudioContext unavailable"); - } - - const context = new AudioContextCtor(); + const context = refs.playbackContext; if (context.state === "suspended") { - await context.resume().catch(() => undefined); + let timeout: ReturnType | undefined; + try { + await Promise.race([ + context.resume(), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Audio playback requires a user interaction")), + 2000, + ); + }), + ]); + } finally { + clearTimeout(timeout); + } } - refs.playbackContext = context; return context; } @@ -162,7 +115,7 @@ export function createAudioEngine( return context; } - async function playAudio(audio: AudioPlaybackSource): Promise { + async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise { const context = await ensurePlaybackContext(); const arrayBuffer = await audio.arrayBuffer(); const type = (audio.type || "").toLowerCase(); @@ -174,53 +127,39 @@ export function createAudioEngine( ) : await decodeAudioData(context, arrayBuffer); + if (signal.aborted) throw new Error("Playback stopped"); const durationSec = audioBuffer.duration; const source = context.createBufferSource(); source.buffer = audioBuffer; source.connect(context.destination); return await new Promise((resolve, reject) => { - refs.activePlayback = { source, resolve, reject, settled: false }; - - const settle = (fn: () => void) => { - const active = refs.activePlayback; - if (!active || active.source !== source || active.settled) { - return; - } - active.settled = true; - refs.activePlayback = null; - fn(); + const abort = () => { + source.stop(); + source.disconnect(); + reject(new Error("Playback stopped")); }; - - source.addEventListener("ended", () => { - settle(() => resolve(durationSec)); - }); - + signal.addEventListener("abort", abort, { once: true }); + source.addEventListener( + "ended", + () => { + signal.removeEventListener("abort", abort); + source.disconnect(); + resolve(durationSec); + }, + { once: true }, + ); try { source.start(); } catch (error) { - settle(() => reject(error instanceof Error ? error : new Error(String(error)))); + signal.removeEventListener("abort", abort); + source.disconnect(); + reject(error); } }); } - async function processQueue(): Promise { - if (refs.processingQueue || refs.queue.length === 0) { - return; - } - - refs.processingQueue = true; - while (refs.queue.length > 0) { - const item = refs.queue.shift()!; - try { - const duration = await playAudio(item.audio); - item.resolve(duration); - } catch (error) { - item.reject(error instanceof Error ? error : new Error(String(error))); - } - } - refs.processingQueue = false; - } + const playback = createPlaybackQueue(playAudio); async function stopCapture(): Promise { refs.started = false; @@ -263,8 +202,7 @@ export function createAudioEngine( }, async destroy() { - this.stop(); - this.clearQueue(); + playback.destroy(); await stopCapture(); const playbackContext = refs.playbackContext; @@ -334,7 +272,8 @@ export function createAudioEngine( return; } - callbacks.onCaptureData(resampleToPcm16(input, context.sampleRate, 16000)); + const pcm = resampleToPcm16(input, context.sampleRate, 16000); + callbacks.onCaptureData(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength)); }; source.connect(processor); @@ -370,40 +309,9 @@ export function createAudioEngine( return refs.muted; }, - async play(audio: AudioPlaybackSource) { - return await new Promise((resolve, reject) => { - refs.queue.push({ audio, resolve, reject }); - if (!refs.processingQueue) { - void processQueue(); - } - }); - }, - - stop() { - if (refs.activePlayback) { - const active = refs.activePlayback; - refs.activePlayback = null; - try { - active.source.stop(); - } catch { - // Ignore best-effort stop errors. - } - if (!active.settled) { - active.settled = true; - active.reject(new Error("Playback stopped")); - } - } - }, - - clearQueue() { - while (refs.queue.length > 0) { - refs.queue.shift()!.reject(new Error("Playback stopped")); - } - refs.processingQueue = false; - }, - - isPlaying() { - return refs.activePlayback !== null; - }, + play: playback.play, + stop: playback.stop, + clearQueue: playback.clearQueue, + isPlaying: playback.isPlaying, }; } diff --git a/packages/app/src/audio/file-playback.ts b/packages/app/src/audio/file-playback.ts new file mode 100644 index 0000000000..6d4370bd6f --- /dev/null +++ b/packages/app/src/audio/file-playback.ts @@ -0,0 +1,61 @@ +export interface FilePlaybackStatus { + playbackState: string; + isLoaded: boolean; + didJustFinish: boolean; + duration: number; +} + +export interface FilePlayer { + play(): void; + remove(): void; + addListener( + event: "playbackStatusUpdate", + listener: (status: FilePlaybackStatus) => void, + ): { remove(): void }; +} + +/** Own completion, load failure and resource release for the native file backend. */ +export function playFile(player: FilePlayer, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let settled = false; + // expo-audio 1.x does not report Android decoder errors. A local file that + // never becomes loaded must still release its queue slot and temporary file. + let timeout: ReturnType | undefined; + const finish = (error?: unknown, duration = 0) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + signal.removeEventListener("abort", abort); + subscription.remove(); + player.remove(); + if (error) reject(error); + else resolve(duration); + }; + const abort = () => finish(new Error("Playback stopped")); + let loaded = false; + const subscription = player.addListener("playbackStatusUpdate", (status) => { + if (status.playbackState === "error" || status.playbackState === "failed") { + finish(new Error("Audio file could not be decoded")); + } else if (status.didJustFinish) { + finish(undefined, status.duration); + } else if (status.isLoaded && status.duration > 0 && !loaded) { + loaded = true; + clearTimeout(timeout); + timeout = setTimeout( + () => finish(new Error("Audio playback timed out")), + (status.duration + 30) * 1000, + ); + } + }); + timeout = setTimeout(() => finish(new Error("Audio file could not be loaded")), 15000); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + else { + try { + player.play(); + } catch (error) { + finish(error); + } + } + }); +} diff --git a/packages/app/src/audio/index.ts b/packages/app/src/audio/index.ts new file mode 100644 index 0000000000..fd5bbfd221 --- /dev/null +++ b/packages/app/src/audio/index.ts @@ -0,0 +1,3 @@ +export { createAudioEngine } from "./audio-engine"; +export type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types"; +export { createPlayAudio } from "./play-audio"; diff --git a/packages/app/src/audio/pcm/index.ts b/packages/app/src/audio/pcm/index.ts new file mode 100644 index 0000000000..fe1164b8ce --- /dev/null +++ b/packages/app/src/audio/pcm/index.ts @@ -0,0 +1,124 @@ +const floatToInt16 = (sample: number): number => { + const clamped = Math.max(-1, Math.min(1, sample)); + return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); +}; + +export const resampleToPcm16 = ( + input: Float32Array, + inputRate: number, + outputRate: number, +): Int16Array => { + if (input.length === 0) { + return new Int16Array(0); + } + if (inputRate === outputRate) { + const out = new Int16Array(input.length); + for (let i = 0; i < input.length; i++) { + out[i] = floatToInt16(input[i]); + } + return out; + } + + const ratio = inputRate / outputRate; + const outputLength = Math.max(1, Math.round(input.length / ratio)); + const out = new Int16Array(outputLength); + for (let i = 0; i < outputLength; i++) { + const sourceIndex = i * ratio; + const i0 = Math.floor(sourceIndex); + const i1 = Math.min(input.length - 1, i0 + 1); + const frac = sourceIndex - i0; + const sample = input[i0] * (1 - frac) + input[i1] * frac; + out[i] = floatToInt16(sample); + } + return out; +}; + +export function parsePcmSampleRate(mimeType: string): number | null { + const match = /rate=(\d+)/i.exec(mimeType); + if (!match) { + return null; + } + const rate = Number(match[1]); + return Number.isFinite(rate) && rate > 0 ? rate : null; +} + +function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array { + if (fromRate === toRate) { + return pcm; + } + + const inputSamples = Math.floor(pcm.length / 2); + const outputSamples = Math.floor((inputSamples * toRate) / fromRate); + const out = new Uint8Array(outputSamples * 2); + const ratio = fromRate / toRate; + + const readInt16 = (sampleIndex: number): number => { + const i = sampleIndex * 2; + if (i + 1 >= pcm.length) { + return 0; + } + const lo = pcm[i]; + const hi = pcm[i + 1]; + let value = (hi << 8) | lo; + if (value & 0x8000) { + value = value - 0x10000; + } + return value; + }; + + const writeInt16 = (sampleIndex: number, value: number): void => { + const clamped = Math.max(-32768, Math.min(32767, Math.round(value))); + const i = sampleIndex * 2; + out[i] = clamped & 0xff; + out[i + 1] = (clamped >> 8) & 0xff; + }; + + for (let i = 0; i < outputSamples; i++) { + const srcPos = i * ratio; + const i0 = Math.floor(srcPos); + const frac = srcPos - i0; + const s0 = readInt16(i0); + const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1)); + writeInt16(i, s0 + (s1 - s0) * frac); + } + + return out; +} + +interface PcmOutput { + resumePlayback(): void; + playPCMData(bytes: Uint8Array): void; + stopPlayback(): void; +} + +/** Keep voice output on the native communication engine and its echo reference. */ +export function playPcm16( + bytes: Uint8Array, + mimeType: string, + signal: AbortSignal, + output: PcmOutput, +): Promise { + if (signal.aborted) return Promise.reject(new Error("Playback stopped")); + const pcm = resamplePcm16(bytes, parsePcmSampleRate(mimeType) ?? 24000, 16000); + const duration = pcm.length / 2 / 16000; + return new Promise((resolve, reject) => { + let timeout: ReturnType | undefined; + const abort = () => { + clearTimeout(timeout); + output.stopPlayback(); + reject(new Error("Playback stopped")); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + output.resumePlayback(); + output.playPCMData(pcm); + timeout = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(duration); + }, duration * 1000); + } catch (error) { + signal.removeEventListener("abort", abort); + reject(error); + } + }); +} diff --git a/packages/app/src/audio/play-audio.test.ts b/packages/app/src/audio/play-audio.test.ts new file mode 100644 index 0000000000..a47b5c0ecc --- /dev/null +++ b/packages/app/src/audio/play-audio.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from "vitest"; +import { createPlayAudio } from "./play-audio"; +import { playPcm16, parsePcmSampleRate, resampleToPcm16 } from "./pcm"; +import { playFile, type FilePlaybackStatus, type FilePlayer } from "./file-playback"; + +const source = { base64: "UklGRg==", mimeType: "audio/wav" }; +test("passes RPC file bytes to the shared engine and awaits completion", async () => { + const owner = new AbortController(); + let finish!: () => void; + const played = new Promise((resolve) => { + finish = resolve; + }); + let completed = false; + const play = createPlayAudio( + { + async play(audio, signal) { + expect(signal).toBe(owner.signal); + expect(audio.type).toBe("audio/wav"); + expect(audio.size).toBe(4); + expect(new Uint8Array(await audio.arrayBuffer())).toEqual(new Uint8Array([82, 73, 70, 70])); + await played; + return 1; + }, + }, + owner.signal, + ); + const result = play(source).then(() => { + completed = true; + return undefined; + }); + await Promise.resolve(); + expect(completed).toBe(false); + finish(); + await result; + expect(completed).toBe(true); +}); + +test.each([ + { ...source, base64: "garbage!" }, + { ...source, base64: "" }, + { ...source, mimeType: "text/plain" }, +])("rejects invalid input before reaching a decoder: %o", async (input) => { + const play = createPlayAudio( + { + play() { + throw new Error("Reached decoder"); + }, + }, + new AbortController().signal, + ); + await expect(play(input)).rejects.toThrow(/^Audio must/); +}); + +test("rejects calls from an unloaded plugin", async () => { + const owner = new AbortController(); + owner.abort(new Error("Plugin unloaded")); + const play = createPlayAudio( + { + play() { + throw new Error("Reached decoder"); + }, + }, + owner.signal, + ); + await expect(play(source)).rejects.toThrow("Playback stopped"); +}); + +test("voice PCM retains its native output and 16 kHz samples", async () => { + const pcm = new Uint8Array([0, 0, 255, 127, 0, 128]); + let written: Uint8Array | undefined; + const duration = await playPcm16( + pcm, + "audio/pcm;rate=16000;bits=16", + new AbortController().signal, + { + resumePlayback() {}, + playPCMData(bytes) { + written = bytes; + }, + stopPlayback() {}, + }, + ); + expect(written).toEqual(pcm); + expect(duration).toBe(3 / 16000); +}); + +test("voice PCM cancellation stops native output and rejects", async () => { + let stopped = false; + const owner = new AbortController(); + const result = playPcm16(new Uint8Array(32000), "audio/pcm;rate=16000;bits=16", owner.signal, { + resumePlayback() {}, + playPCMData() {}, + stopPlayback() { + stopped = true; + }, + }); + owner.abort(); + await expect(result).rejects.toThrow("Playback stopped"); + expect(stopped).toBe(true); +}); + +function filePlayer() { + let listener: (status: FilePlaybackStatus) => void = () => {}; + let removed = false; + let started = false; + const player: FilePlayer = { + play() { + started = true; + }, + remove() { + removed = true; + }, + addListener(_event, callback) { + listener = callback; + return { + remove() { + listener = () => {}; + }, + }; + }, + }; + return { + player, + status: (status: Partial) => + listener({ + playbackState: "ready", + duration: 1, + didJustFinish: false, + isLoaded: true, + ...status, + }), + removed: () => removed, + started: () => started, + }; +} + +test("native file playback waits for completion and releases its player", async () => { + const h = filePlayer(); + const promise = playFile(h.player, new AbortController().signal); + expect(h.started()).toBe(true); + h.status({ didJustFinish: true }); + await expect(promise).resolves.toBe(1); + expect(h.removed()).toBe(true); +}); + +test("native decoder errors reject and release the player", async () => { + const h = filePlayer(); + const promise = playFile(h.player, new AbortController().signal); + h.status({ playbackState: "failed" }); + await expect(promise).rejects.toThrow("could not be decoded"); + expect(h.removed()).toBe(true); +}); + +test("native cancellation releases playback and settles the caller", async () => { + const h = filePlayer(); + const owner = new AbortController(); + const promise = playFile(h.player, owner.signal); + owner.abort(new Error("Plugin unloaded")); + await expect(promise).rejects.toThrow("Playback stopped"); + expect(h.removed()).toBe(true); +}); + +test.each(["audio/pcm;rate=16000;bits=16", "AUDIO/PCM; rate=16000"])( + "rejects raw PCM instead of initializing voice capture: %s", + async (mimeType) => { + const play = createPlayAudio( + { + play: async () => { + throw new Error("Reached engine"); + }, + }, + new AbortController().signal, + ); + await expect(play({ base64: "AAAAAA==", mimeType })).rejects.toThrow( + "Pass an audio file, such as WAV or MP3", + ); + }, +); + +// Voice capture and browser dictation use this same PCM boundary. +test("capture PCM preserves clipping, signed samples, and empty input", () => { + expect(resampleToPcm16(new Float32Array([-2, -1, -0.5, 0, 0.5, 1, 2]), 16000, 16000)).toEqual( + new Int16Array([-32768, -32768, -16384, 0, 16384, 32767, 32767]), + ); + expect(resampleToPcm16(new Float32Array(), 48000, 16000)).toEqual(new Int16Array()); +}); + +test("capture PCM resamples with interpolation and preserves the last sample", () => { + expect(resampleToPcm16(new Float32Array([-1, 0, 1]), 24000, 16000)).toEqual( + new Int16Array([-32768, 16384]), + ); + expect(resampleToPcm16(new Float32Array([0, 1]), 8000, 16000)).toEqual( + new Int16Array([0, 16384, 32767, 32767]), + ); +}); + +test("voice playback shares PCM sample-rate parsing", () => { + expect(parsePcmSampleRate("audio/pcm;rate=16000;bits=16")).toBe(16000); + expect(parsePcmSampleRate("audio/PCM;RATE=48000")).toBe(48000); + expect(parsePcmSampleRate("audio/pcm")).toBeNull(); + expect(parsePcmSampleRate("audio/pcm;rate=0")).toBeNull(); +}); diff --git a/packages/app/src/audio/play-audio.ts b/packages/app/src/audio/play-audio.ts new file mode 100644 index 0000000000..e5d831de17 --- /dev/null +++ b/packages/app/src/audio/play-audio.ts @@ -0,0 +1,38 @@ +import { Buffer } from "buffer"; +import type { AudioEngine } from "./audio-engine-types"; + +/** RPC-friendly file input. No browser Blob, URL, or platform knowledge is needed. */ +export function createPlayAudio(engine: Pick, signal: AbortSignal) { + return async (source: { base64: string; mimeType: string }): Promise => { + if (signal.aborted) throw new Error("Playback stopped"); + if ( + !source || + typeof source.base64 !== "string" || + !/^[A-Za-z0-9+/]+={0,2}$/.test(source.base64) || + source.base64.length % 4 !== 0 || + !source.base64.length + ) { + throw new Error("Audio must contain a non-empty base64-encoded file"); + } + if ( + typeof source.mimeType !== "string" || + !/^audio\/[\w.+-]+(?:[ \t]*;[^\r\n]+)?$/i.test(source.mimeType) + ) { + throw new Error("Audio must have an audio MIME type, such as audio/wav"); + } + const bytes = Uint8Array.from(Buffer.from(source.base64, "base64")); + const [mediaType, ...parameters] = source.mimeType.split(";"); + const type = [mediaType.trim().toLowerCase(), ...parameters].join(";"); + if (mediaType.trim().toLowerCase() === "audio/pcm") { + throw new Error("Pass an audio file, such as WAV or MP3; raw PCM is not supported"); + } + await engine.play( + { + type, + size: bytes.byteLength, + arrayBuffer: async () => bytes.buffer, + }, + signal, + ); + }; +} diff --git a/packages/app/src/audio/playback.test.ts b/packages/app/src/audio/playback.test.ts new file mode 100644 index 0000000000..2627d899cf --- /dev/null +++ b/packages/app/src/audio/playback.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from "vitest"; +import { createPlaybackQueue } from "./playback"; + +function harness() { + const started: string[] = []; + const finishes: (() => void)[] = []; + const queue = createPlaybackQueue((source, signal) => { + started.push(source); + return new Promise((resolve, reject) => { + finishes.push(() => resolve(1)); + signal.addEventListener("abort", () => reject(new Error("Playback stopped")), { once: true }); + }); + }); + return { queue, started, finishes }; +} + +test("plays in order and resolves only after playback completes", async () => { + const h = harness(); + const first = h.queue.play("first"); + const second = h.queue.play("second"); + expect(h.started).toEqual(["first"]); + h.finishes[0](); + await expect(first).resolves.toBe(1); + expect(h.started).toEqual(["first", "second"]); + h.finishes[1](); + await expect(second).resolves.toBe(1); +}); + +test("cancels one owner's queued and active work without stopping another owner", async () => { + const h = harness(); + const owner = new AbortController(); + const first = h.queue.play("first", owner.signal); + const queued = h.queue.play("cancelled", owner.signal); + const other = h.queue.play("other"); + const rejected = Promise.all([ + expect(first).rejects.toThrow("Playback stopped"), + expect(queued).rejects.toThrow("Playback stopped"), + ]); + owner.abort(new Error("unloaded")); + await rejected; + expect(h.started).toEqual(["first", "other"]); + h.finishes[1](); + await expect(other).resolves.toBe(1); +}); + +test("stop during loading cannot start cancelled audio or strand the next call", async () => { + const h = harness(); + const first = h.queue.play("loading"); + const rejected = expect(first).rejects.toThrow("Playback stopped"); + h.queue.stop(); + await rejected; + const second = h.queue.play("next"); + h.finishes[1](); + await expect(second).resolves.toBe(1); +}); + +test("a failed file does not block the queue", async () => { + const queue = createPlaybackQueue(async (source) => { + if (source === "invalid") throw new Error("Invalid audio"); + return 2; + }); + const failed = queue.play("invalid"); + const next = queue.play("valid"); + await expect(failed).rejects.toThrow("Invalid audio"); + await expect(next).resolves.toBe(2); +}); + +test("ending the voice waiting cue leaves a plugin's active audio playing", async () => { + const { createVoiceRuntime } = await import("../voice/voice-runtime"); + let finishPlugin!: () => void; + let cueQueued!: () => void; + const queued = new Promise((resolve) => { + cueQueued = resolve; + }); + const output = createPlaybackQueue<{ type: string }>( + (_source, signal) => + new Promise((resolve, reject) => { + finishPlugin = () => resolve(1); + signal.addEventListener("abort", () => reject(new Error("Playback stopped")), { + once: true, + }); + }), + ); + const engine = { + initialize: async () => {}, + destroy: async () => { + output.destroy(); + }, + startCapture: async () => {}, + stopCapture: async () => {}, + toggleMute: () => false, + isMuted: () => false, + play(source: { type: string }, signal?: AbortSignal) { + if (source.type.startsWith("audio/pcm")) cueQueued(); + return output.play(source, signal); + }, + stop: output.stop, + clearQueue: output.clearQueue, + isPlaying: output.isPlaying, + }; + const runtime = createVoiceRuntime({ + engine, + getServerInfo: () => ({ + serverId: "host", + hostname: "host", + version: "1.0.0", + capabilities: { + voice: { dictation: { enabled: true, reason: "" }, voice: { enabled: true, reason: "" } }, + }, + }), + activateKeepAwake: async () => {}, + deactivateKeepAwake: async () => {}, + }); + runtime.registerSession({ + serverId: "host", + setVoiceMode: async () => {}, + sendVoiceAudioChunk: async () => {}, + audioPlayed: async () => {}, + abortRequest: async () => {}, + setAssistantAudioPlaying: () => {}, + }); + try { + await runtime.startVoice("host", "agent"); + const result = engine.play({ type: "audio/wav" }).then( + () => "finished", + () => "stopped", + ); + runtime.onTurnEvent("host", "agent", "turn_started"); + await queued; + runtime.onAssistantAudioStarted("host"); + finishPlugin(); + expect(await result).toBe("finished"); + } finally { + await runtime.destroy(); + } +}); diff --git a/packages/app/src/audio/playback.ts b/packages/app/src/audio/playback.ts new file mode 100644 index 0000000000..8d0744b79c --- /dev/null +++ b/packages/app/src/audio/playback.ts @@ -0,0 +1,81 @@ +/** One queue for voice and plugin audio, including cancellation while loading. */ +export function createPlaybackQueue( + start: (source: Source, signal: AbortSignal) => Promise, + onIdle: () => void = () => {}, +) { + interface Item { + source: Source; + controller: AbortController; + resolve: (duration: number) => void; + reject: (error: unknown) => void; + release: () => void; + } + const pending: Item[] = []; + let active: Item | null = null; + let destroyed = false; + + async function drain() { + if (active) return; + const item = pending.shift(); + if (!item) { + onIdle(); + return; + } + active = item; + try { + if (item.controller.signal.aborted) throw new Error("Playback stopped"); + item.resolve(await start(item.source, item.controller.signal)); + } catch (error) { + item.reject(error); + } finally { + item.release(); + active = null; + void drain(); + } + } + + const stopped = () => new Error("Playback stopped"); + return { + play(source: Source, signal?: AbortSignal): Promise { + if (destroyed) return Promise.reject(new Error("Audio engine destroyed")); + if (signal?.aborted) return Promise.reject(new Error("Playback stopped")); + return new Promise((resolve, reject) => { + const controller = new AbortController(); + const cancel = () => { + controller.abort(); + const index = pending.indexOf(item); + if (index !== -1) { + pending.splice(index, 1); + item.release(); + reject(new Error("Playback stopped")); + } + }; + const item: Item = { + source, + controller, + resolve, + reject, + release: () => signal?.removeEventListener("abort", cancel), + }; + signal?.addEventListener("abort", cancel, { once: true }); + pending.push(item); + void drain(); + }); + }, + stop() { + active?.controller.abort(); + }, + clearQueue() { + for (const item of pending.splice(0)) { + item.release(); + item.reject(stopped()); + } + }, + isPlaying: () => active !== null, + destroy() { + destroyed = true; + this.clearQueue(); + this.stop(); + }, + }; +} diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts index fbd37c15e7..cc8ad8fc11 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - getBottomSheetVisibleContentHeight, - getCompactSheetSafeAreaPadding, -} from "@/components/adaptive-modal-sheet-layout"; +import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; describe("getCompactSheetSafeAreaPadding", () => { it("assigns safe clearance to the footer independently of decorative padding", () => { @@ -49,17 +46,3 @@ describe("getCompactSheetSafeAreaPadding", () => { ).toEqual({}); }); }); - -describe("getBottomSheetVisibleContentHeight", () => { - it("stops subtracting the retained keyboard height after the keyboard hides", () => { - const layout = { - containerHeight: 874, - contentPosition: 88, - handleHeight: 24, - keyboardHeight: 344, - }; - - expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418); - expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762); - }); -}); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.ts b/packages/app/src/components/adaptive-modal-sheet-layout.ts index 91fc62274e..b38db5750c 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.ts @@ -10,28 +10,6 @@ export interface CompactSheetSafeAreaPadding { footerPaddingBottom?: number; } -interface BottomSheetVisibleContentHeightInput { - containerHeight: number; - contentPosition: number; - handleHeight: number; - keyboardHeight: number; - isKeyboardVisible: boolean; -} - -export function getBottomSheetVisibleContentHeight({ - containerHeight, - contentPosition, - handleHeight, - keyboardHeight, - isKeyboardVisible, -}: BottomSheetVisibleContentHeightInput): number { - "worklet"; - return Math.max( - 0, - containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0), - ); -} - export function getCompactSheetSafeAreaPadding({ isCompact, isKeyboardVisible, diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 4ceb43d917..d4d833e3f1 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Keyboard, Pressable, Text, View } from "react-native"; import type { DimensionValue, StyleProp, ViewStyle } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, @@ -12,22 +12,16 @@ import { useGlobalWebOverlayLayer, useWebOverlayRegistration, } from "../lib/overlay-root"; -import { - KEYBOARD_STATUS, - useBottomSheetInternal, - type BottomSheetBackgroundProps, -} from "@gorhom/bottom-sheet"; -import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { ArrowLeft, Search, X } from "lucide-react-native"; +import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; +import Animated from "react-native-reanimated"; +import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native"; import { IsolatedBottomSheetModal, + SheetVisibleFrame, type ContextBridge, useIsolatedBottomSheetVisibility, } from "@/components/ui/isolated-bottom-sheet-modal"; -import { - getBottomSheetVisibleContentHeight, - getCompactSheetSafeAreaPadding, -} from "@/components/adaptive-modal-sheet-layout"; +import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; import { ScrollView } from "@/components/ui/scroll-view"; import { isWeb } from "@/constants/platform"; import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility"; @@ -207,10 +201,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - bottomSheetVisibleContent: { - minHeight: 0, - overflow: "hidden", - }, bottomSheetVisibleScroll: { flex: 1, minHeight: 0, @@ -233,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({ const WEB_EXIT_DURATION_MS = 160; -function SheetBackground({ style }: BottomSheetBackgroundProps) { +/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */ +export function SheetBackground({ style }: BottomSheetBackgroundProps) { const { theme } = useUnistyles(); const combinedStyle = useMemo( () => [ @@ -249,6 +240,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) { return ; } +const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({ + indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] }, +})); + +const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 }; + +/** + * The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children + * join the handle, so a header passed here drags the sheet like the indicator does. That is how a + * sheet whose content scrolls on its own keeps its header draggable without content panning. + */ +export function SheetHandle({ + children, + // Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator. + indicatorStyle: _indicatorStyle, + ...props +}: ComponentProps) { + if (!children) return ; + // The children carry their own semantics; the handle's slider role would swallow them on web. + return ( + + {children} + + ); +} + /** * The sheet body, indented to the sheet's content inset. * @@ -263,37 +287,34 @@ function SheetContent({ style, children }: { style: StyleProp; childr return {children}; } -function BottomSheetVisibleContent({ children }: { children: ReactNode }) { - const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } = - useBottomSheetInternal(); - const visibleContentStyle = useAnimatedStyle(() => { - const { containerHeight, handleHeight } = animatedLayoutState.get(); - if (containerHeight < 0 || handleHeight < 0) { - return { height: 0 }; - } - - const initialDetentPosition = animatedDetentsState.get().detents?.[0]; - const contentPosition = - initialDetentPosition == null - ? animatedPosition.get() - : Math.min(animatedPosition.get(), initialDetentPosition); - - const keyboardState = animatedKeyboardState.get(); - return { - height: getBottomSheetVisibleContentHeight({ - containerHeight, - contentPosition, - handleHeight, - keyboardHeight: keyboardState.heightWithinContainer, - isKeyboardVisible: keyboardState.status === KEYBOARD_STATUS.SHOWN, - }), - }; - }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); - +/** + * An icon action in a sheet header's trailing rail, drawn like the Close button so a header's + * actions line up with it at one size. Pass it through `SheetHeader.actions`. + */ +export function SheetHeaderIconButton({ + icon: Icon, + accessibilityLabel, + onPress, + testID, +}: { + icon: LucideIcon; + accessibilityLabel: string; + onPress: () => void; + testID?: string; +}) { + const { theme } = useUnistyles(); return ( - - {children} - + + {({ pressed }) => ( + + )} + ); } @@ -353,19 +374,11 @@ export function SheetHeaderView({ {header.actions ? {header.actions} : null} {showCloseButton ? ( - - {({ pressed }) => ( - - )} - + /> ) : null} {search ? ( @@ -471,8 +484,6 @@ export interface AdaptiveModalSheetProps { bodyStyle?: StyleProp; /** Layout intent for the sheet body, composed over the sheet's own content inset. */ contentStyle?: StyleProp; - /** Size compact sheet content to the live snap height instead of its largest snap point. */ - sizeContentToCurrentSnapPoint?: boolean; /** Re-establishes caller-owned contexts inside the native or compact sheet portal. */ contextBridge?: ContextBridge | null; } @@ -493,10 +504,8 @@ export function AdaptiveModalSheet({ presentation, contentStyle, bodyStyle, - sizeContentToCurrentSnapPoint = true, contextBridge = null, }: AdaptiveModalSheetProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); @@ -519,14 +528,14 @@ export function AdaptiveModalSheet({ () => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }), [compactSafeAreaPadding.footerPaddingBottom], ); - const footerView = footer ? ( - - {footer} - - ) : null; - const handleIndicatorStyle = useMemo( - () => ({ backgroundColor: theme.colors.palette.zinc[600] }), - [theme.colors.palette.zinc], + const footerView = useMemo( + () => + footer ? ( + + {footer} + + ) : null, + [footer, footerClearanceStyle, footerContainerStyle], ); useEffect(() => { if (!isWeb && visible) { @@ -628,7 +637,6 @@ export function AdaptiveModalSheet({ )} - {footerView} ); @@ -644,17 +652,13 @@ export function AdaptiveModalSheet({ backdropOpacity={0.45} enablePanDownToClose backgroundComponent={SheetBackground} - handleIndicatorStyle={handleIndicatorStyle} + handleComponent={SheetHandle} keyboardBehavior="extend" keyboardBlurBehavior="restore" accessible={false} presentation={presentation} > - {sizeContentToCurrentSnapPoint ? ( - {sheetContent} - ) : ( - sheetContent - )} + {sheetContent} ); } diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 4dcd50b019..d874a73618 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -6,7 +6,6 @@ import { Folder, FolderOpen, FolderPlus, - Github, HardDrive, Plus, Search, @@ -34,6 +33,7 @@ import { type EditingTextInputHandle, } from "@/components/ui/text-input"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { GitHubIcon } from "@/components/icons/github-icon"; import { applyAvailableAddProjectHosts, backAddProjectPage, @@ -162,7 +162,7 @@ function FlowBackButton({ onPress }: { onPress: () => void }) { } function methodIcon(method: AddProjectMethodId): FlowRowOption["icon"] { - if (method === "github") return Github; + if (method === "github") return GitHubIcon; if (method === "browse") return FolderOpen; if (method === "new-directory") return FolderPlus; return Search; @@ -656,7 +656,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { ? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}` : repository.nameWithOwner, subtitle: repository.description, - icon: Github, + icon: GitHubIcon, testID: `add-project-flow-repository-${repository.id}`, select: () => setState((current) => openGithubLocationPage(current, page.hostId, repository)), 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 ? : } + + + ); @@ -129,6 +113,7 @@ export function ProviderCatalogList({ const { entries: catalogEntries } = useAcpProviderCatalog(); const { entries: providerEntries } = useProvidersSnapshot(serverId); const [search, setSearch] = useState(""); + const [gridWidth, setGridWidth] = useState(null); const installedIds = useMemo( () => new Set(providerEntries?.map((entry) => entry.provider) ?? []), @@ -137,41 +122,48 @@ export function ProviderCatalogList({ const availableEntries = useMemo( () => - catalogEntries - .filter((entry) => !installedIds.has(entry.id)) - .filter((entry) => matchesSearch(entry, search)), + rankProviderCatalog( + catalogEntries.filter((entry) => !installedIds.has(entry.id)), + search, + ), [catalogEntries, installedIds, search], ); + const handleGridLayout = useCallback((event: LayoutChangeEvent) => { + setGridWidth(event.nativeEvent.layout.width); + }, []); + + const tileWidth = useMemo(() => { + if (gridWidth === null) return undefined; + const columns = columnCountForWidth(gridWidth); + if (columns === 1) return undefined; + return Math.floor((gridWidth - TILE_GAP * (columns - 1)) / columns); + }, [gridWidth]); + return ( - - - - - + {availableEntries.length === 0 ? ( - + {t("providerCatalog.noProviders")} ) : ( - + {availableEntries.map((entry) => ( - @@ -183,101 +175,56 @@ export function ProviderCatalogList({ } const styles = StyleSheet.create((theme) => ({ - searchField: { + searchRail: { flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - paddingHorizontal: theme.spacing[3], marginBottom: theme.spacing[3], }, - searchIcon: { - width: 18, - alignItems: "center", + grid: { + flexDirection: "row", + flexWrap: "wrap", + gap: TILE_GAP, }, - searchInput: { - flex: 1, - minWidth: 0, - paddingVertical: theme.spacing[3], - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - list: { - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - overflow: "hidden", - }, - row: { + tile: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], padding: theme.spacing[3], - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, + }, + tileFull: { + width: "100%", + }, + details: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], }, iconFrame: { + flexShrink: 0, width: 36, height: 36, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface2, alignItems: "center", justifyContent: "center", + }, + addButton: { flexShrink: 0, }, - textColumn: { - flex: 1, - minWidth: 0, - gap: theme.spacing[1], - }, - titleRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minWidth: 0, - }, name: { color: theme.colors.foreground, fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - flexShrink: 1, + fontWeight: theme.fontWeight.normal, }, - version: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - flexShrink: 0, - }, - description: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - installLink: { - alignSelf: "flex-start", + metaRow: { flexDirection: "row", + flexWrap: "wrap", alignItems: "center", - gap: theme.spacing[1], - maxWidth: "100%", - }, - installLinkText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - actionButton: { - width: 92, - flexShrink: 0, + gap: theme.spacing[2], }, stateBox: { minHeight: 96, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface1, alignItems: "center", justifyContent: "center", - gap: theme.spacing[3], padding: theme.spacing[4], }, stateText: { diff --git a/packages/app/src/components/provider-catalog-search.test.ts b/packages/app/src/components/provider-catalog-search.test.ts new file mode 100644 index 0000000000..4079d79602 --- /dev/null +++ b/packages/app/src/components/provider-catalog-search.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { rankProviderCatalog } from "./provider-catalog-search"; + +const entries = [ + { id: "kiro-cli", title: "Kiro CLI" }, + { id: "goose", title: "goose" }, + { id: "kimi", title: "Kimi Code CLI" }, + { id: "gemini", title: "Gemini CLI" }, + { id: "qwen-code", title: "Qwen Code" }, + { id: "codex-acp", title: "Codex" }, +]; + +function titles(list: { title: string }[]): string[] { + return list.map((entry) => entry.title); +} + +describe("rankProviderCatalog", () => { + it("lists every provider alphabetically by name without a query", () => { + expect(titles(rankProviderCatalog(entries, " "))).toEqual([ + "Codex", + "Gemini CLI", + "goose", + "Kimi Code CLI", + "Kiro CLI", + "Qwen Code", + ]); + }); + + it("ranks the strongest match first and drops providers that do not match", () => { + expect(titles(rankProviderCatalog(entries, "cod"))).toEqual([ + "Codex", + "Kimi Code CLI", + "Qwen Code", + ]); + }); + + it("matches in-word subsequences and multiple tokens like the other pickers", () => { + expect(titles(rankProviderCatalog(entries, "gmni"))).toEqual(["Gemini CLI"]); + expect(titles(rankProviderCatalog(entries, "qw cod"))).toEqual(["Qwen Code"]); + }); + + it("matches the provider id", () => { + expect(titles(rankProviderCatalog(entries, "kiro-cli"))).toEqual(["Kiro CLI"]); + }); +}); diff --git a/packages/app/src/components/provider-catalog-search.ts b/packages/app/src/components/provider-catalog-search.ts new file mode 100644 index 0000000000..23303c868f --- /dev/null +++ b/packages/app/src/components/provider-catalog-search.ts @@ -0,0 +1,34 @@ +import { + compareMatchScores, + scoreTextFields, + type MatchScore, +} from "@getpaseo/protocol/search/text-match"; + +export interface SearchableCatalogEntry { + id: string; + title: string; +} + +function compareTitles(a: SearchableCatalogEntry, b: SearchableCatalogEntry): number { + return a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); +} + +/** + * Orders the Add provider catalog. Without a query the list is alphabetical by + * name. With a query it uses the pickers' ranked matcher over the fields the + * tile shows (name and id), best match first, ties alphabetical. + */ +export function rankProviderCatalog( + entries: readonly T[], + query: string, +): T[] { + if (!query.trim()) return [...entries].sort(compareTitles); + + const scored: { entry: T; score: MatchScore }[] = []; + for (const entry of entries) { + const score = scoreTextFields(query, [entry.title, entry.id]); + if (score) scored.push({ entry, score }); + } + scored.sort((a, b) => compareMatchScores(a.score, b.score) || compareTitles(a.entry, b.entry)); + return scored.map(({ entry }) => entry); +} diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index bf2d1c1b95..61d42882e8 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -95,11 +95,11 @@ function CustomModelRow({ return ( - + {model.label} ({ iconButton: { width: 28, height: 28, + flexShrink: 0, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", @@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, flexShrink: 0, }, + customModelText: { + flexShrink: 1, + minWidth: 0, + }, modelRowFiller: { flex: 1, }, diff --git a/packages/app/src/components/question-form-card-core.test.ts b/packages/app/src/components/question-form-card-core.test.ts index 40692d4acf..293bf24ba8 100644 --- a/packages/app/src/components/question-form-card-core.test.ts +++ b/packages/app/src/components/question-form-card-core.test.ts @@ -147,4 +147,29 @@ describe("question form card core", () => { Response: "custom", }); }); + + test("keeps option previews so options can be compared", () => { + const questions = parseQuestionFormQuestions({ + questions: [ + { + question: "Which layout?", + header: "Layout", + options: [ + { + label: "Tabs", + description: "One tab per section", + preview: "\n \n", + }, + { label: "Single page" }, + ], + multiSelect: false, + }, + ], + }); + + expect(questions?.[0]?.options).toEqual([ + { label: "Tabs", description: "One tab per section", preview: "\n \n" }, + { label: "Single page", description: undefined, preview: undefined }, + ]); + }); }); diff --git a/packages/app/src/components/question-form-card-core.ts b/packages/app/src/components/question-form-card-core.ts index 0dea6c706e..78007dba2e 100644 --- a/packages/app/src/components/question-form-card-core.ts +++ b/packages/app/src/components/question-form-card-core.ts @@ -1,6 +1,7 @@ export interface QuestionOption { label: string; description?: string; + preview?: string; } export interface QuestionFormQuestion { @@ -46,6 +47,7 @@ export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion options.push({ label: o.label, description: typeof o.description === "string" ? o.description : undefined, + preview: readOptionalString(o, "preview"), }); } questions.push({ diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 5b3b146f4e..655e01d210 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -10,6 +10,7 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { isWeb } from "@/constants/platform"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input/types"; +import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface"; import { areQuestionsAnswered, buildQuestionFormAnswers, @@ -29,6 +30,9 @@ interface QuestionFormCardProps { } const IS_WEB = isWeb; +// Native previews are uncapped: inside the option's Pressable, Android never hands a +// drag to the preview's nested scroll, so lines past a cap would be unreachable. +const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined; function getQuestionInputPlaceholder({ question, @@ -133,6 +137,20 @@ function QuestionOptionRow({ {option.description ? ( {option.description} ) : null} + {option.preview ? ( + // Shown for every option at once so they can be compared before picking. + // Long lines wrap, as in Claude Code, since most previews are prose. + + {option.preview} + + ) : null} @@ -704,6 +722,10 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, lineHeight: 20, }, + optionPreviewText: { + lineHeight: 20, + ...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null), + }, selectionControl: { width: 18, height: 18, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 4275099cfd..c750121368 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -30,6 +30,7 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { density, densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; @@ -63,11 +64,7 @@ import { type SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; -import { - hasActiveSidebarLabelFilter, - useSidebarViewStore, - type SidebarGroupMode, -} from "@/stores/sidebar-view-store"; +import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; import { ContextMenu, @@ -96,7 +93,7 @@ import { SidebarWorkspaceMenu, } from "@/components/sidebar/sidebar-workspace-menu"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; -import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; +import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { @@ -118,6 +115,7 @@ import { SidebarWorkspaceTrailingContent, useSidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; +import { DensityIcon } from "@/components/ui/density-icon"; import { PressHighlight } from "@/components/ui/press-highlight"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; @@ -826,9 +824,10 @@ function NewWorkspaceGhostRow({ {({ hovered, pressed }) => ( <> - state.getPinnedWorkspaceOrder); const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder); - const hasActiveLabelFilter = useSidebarViewStore((state) => - hasActiveSidebarLabelFilter(state.labelFilter), - ); const handlePinnedWorkspaceReorder = useCallback( (reorderedWorkspaces: SidebarWorkspacePlacement[]) => { const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); @@ -1953,11 +1949,10 @@ export function SidebarWorkspaceList({ // this whole subtree, which unmounted the header — and the header is where the display menu's // trigger lives, so filtering the last row away closed the menu you were filtering from. // - // Only the label filter can get here. The project filter resolves against the projects it can - // see and falls back to "all projects" when nothing matches, so it either keeps at least one - // project or is not applied at all — it can narrow this list but never empty it. - const sidebarFilterEmpty = - hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0; + // Background visibility can also hide the last project. Keep the header mounted + // whenever a local filter empties an otherwise populated directory, so users can + // reach the display menu to reveal their work again. + const sidebarFilterEmpty = hasProjectsBeforeFilter && projects.length === 0; // Project mode is the one that keeps its project headers; every other grouping mode is a flat // list of grouped rows, so a new mode lands in the grouped branch rather than silently in this @@ -2119,6 +2114,7 @@ function ProjectModeList({ onToggleWorkspacePin: ToggleSidebarWorkspacePin; onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; }) { + const { t } = useTranslation(); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( @@ -2418,7 +2414,12 @@ function ProjectModeList({ <> {pinnedChats.length > 0 ? ( - + {pinnedCollapsed ? null : ( <> ({ // the step in reads as belonging to that project. Padding rather than margin, so the hover and // pressed fills stay the same box as every other row in the sidebar. newWorkspaceGhostRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[4], @@ -2547,8 +2548,8 @@ const styles = StyleSheet.create((theme) => ({ // The width of a workspace row's status slot, so the label lands on the same rail as the // titles above it. newWorkspaceGhostIconSlot: { - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), alignItems: "center", justifyContent: "center", flexShrink: 0, @@ -2567,7 +2568,7 @@ const styles = StyleSheet.create((theme) => ({ }, projectRow: { position: "relative", - minHeight: 36, + minHeight: density.rowHeight, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -2689,7 +2690,7 @@ const styles = StyleSheet.create((theme) => ({ right: theme.spacing[2], }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index 018c8cc37e..d62b3f9ff1 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -301,6 +301,13 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { sheetTitle={t("sidebar.display.heading")} testID="sidebar-display-preferences-content" > + + {t("sidebar.display.showBackground")} + ; export interface SidebarDisplayPreferences { + showBackground: boolean; + toggleBackground: () => void; grouping: SidebarGroupMode; setGrouping: (mode: SidebarGroupMode) => void; titleSource: WorkspaceTitleSource; @@ -48,6 +50,8 @@ export interface SidebarDisplayPreferences { * this for a value and set it; where it lands is this module's problem. */ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { + const showBackground = useSidebarViewStore((state) => state.showBackground); + const toggleBackground = useSidebarViewStore((state) => state.toggleBackground); const grouping = useSidebarViewStore((state) => state.groupMode); const setGrouping = useSidebarViewStore((state) => state.setGroupMode); const hostFilters = useSidebarViewStore((state) => state.hostFilters); @@ -104,6 +108,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { return useMemo( () => ({ + showBackground, + toggleBackground, grouping, setGrouping, titleSource: workspaceTitleSource, @@ -125,6 +131,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { clearLabelFilter, }), [ + showBackground, + toggleBackground, grouping, setGrouping, workspaceTitleSource, diff --git a/packages/app/src/components/sidebar/pinned-section-header.tsx b/packages/app/src/components/sidebar/pinned-section-header.tsx deleted file mode 100644 index 9dc2c9fdba..0000000000 --- a/packages/app/src/components/sidebar/pinned-section-header.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { ChevronDown, ChevronRight } from "lucide-react-native"; -import { Pressable, Text } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { isNative } from "@/constants/platform"; -import type { Theme } from "@/styles/theme"; - -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronRight = withUnistyles(ChevronRight); -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); - -export function PinnedSectionHeader({ - collapsed, - onToggle, -}: { - collapsed: boolean; - onToggle: () => void; -}) { - const { t } = useTranslation(); - const isCompact = useIsCompactFormFactor(); - const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); - const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown; - - return ( - - {({ hovered }) => ( - <> - {t("sidebar.pinned.title")} - {hovered || isNative || isCompact ? ( - - ) : null} - - )} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - header: { - minHeight: 36, - flexDirection: "row", - alignItems: "center", - alignSelf: "flex-start", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - userSelect: "none", - }, - title: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, -})); diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx index c3950efe2d..1830e3fa10 100644 --- a/packages/app/src/components/sidebar/project-leading-visual.tsx +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -1,9 +1,11 @@ import { ActivityIndicator, View, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; -import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; +import { densityIconBox, densityIconProps } from "@/styles/density"; +import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getProjectStatusBadgeContent, @@ -22,6 +24,9 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; // Every surfaced status shares one badge shell, so the badge never changes size or position // between states. Only the thing inside it changes. +// Matches the workspace title's lineHeight (sidebar-workspace-row-content's +// workspaceBranchText) so the icon centers on the title rather than floating above it. +const LEADING_SLOT_HEIGHT = 20; const STATUS_BADGE_SIZE = 12; // How far the badge overhangs the icon's bottom-right corner. Well short of half the badge: // centered on the corner it reads as hanging off the icon rather than sitting on it, and the @@ -34,9 +39,8 @@ const STATUS_BADGE_OFFSET = -4; // // The filled alert occupies the full badge shell so needs-input remains more prominent than // the passive status dots. -// Matches the workspace title's lineHeight (sidebar-workspace-row-content's -// workspaceBranchText) so the icon centers on the title rather than floating above it. -const LEADING_SLOT_HEIGHT = 20; +const DensityProjectIconView = withUnistyles(ProjectIconView); +const projectIconProps = densityIconProps("md", () => ({})); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); @@ -170,6 +174,7 @@ function ProjectStatusBadge({ statusBucket: SidebarStateBucket; backdrop: SidebarSurfaceBackdrop; }) { + const { t } = useTranslation(); // Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout, // so nesting it inside would clip it against the very thing that was meant to separate it from // the icon. It anchors to the same corner instead, growing around the centre the dot had. @@ -177,7 +182,7 @@ function ProjectStatusBadge({ return ( @@ -188,7 +193,7 @@ function ProjectStatusBadge({ return ( @@ -228,12 +233,12 @@ function ProjectIcon({ projectViewKey: string; }) { return ( - ); } @@ -269,11 +274,11 @@ const styles = StyleSheet.create((theme) => { return { // The slot is as tall as the title's line box, not as tall as the icon, and centers the - // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a - // 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab — - // which is why the workspace status indicator is also 20 tall. Keep the two in step. + // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot + // only as tall as the icon puts it above the title and the kebab — which is why the + // workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`. projectLeadingVisualSlot: { - width: theme.iconSize.md, + width: densityIconBox("md"), height: LEADING_SLOT_HEIGHT, flexShrink: 0, alignItems: "center", @@ -282,8 +287,8 @@ const styles = StyleSheet.create((theme) => { // Anchors the corner badge to the icon rather than to the taller slot. projectIconBox: { position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), }, projectIconFallbackText: { fontSize: 9, diff --git a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx index f67bd738ba..01e07eedd4 100644 --- a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx @@ -2,19 +2,11 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { ChevronDown, ChevronUp } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content"; -import type { Theme } from "@/styles/theme"; - -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); -const foregroundColorMapping = (theme: Theme) => ({ - color: theme.colors.foreground, -}); -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronUp = withUnistyles(ChevronUp); +import { DensityIcon } from "@/components/ui/density-icon"; +import { density, densityIconBox } from "@/styles/density"; /** * The row that ends a truncated group. It is a workspace row that happens to say "Show more", so @@ -62,17 +54,11 @@ export function SidebarGroupToggleRow({ {({ hovered, pressed }) => ( <> - {expanded ? ( - - ) : ( - - )} + {label} @@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({ const styles = StyleSheet.create((theme) => ({ // Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx. row: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], @@ -106,8 +92,8 @@ const styles = StyleSheet.create((theme) => ({ // The width of a workspace row's status slot, so the label lands on the same rail as the // titles above it rather than two points to their left. iconSlot: { - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), alignItems: "center", justifyContent: "center", flexShrink: 0, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 4519f6d4ff..2eeded947d 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -1,22 +1,12 @@ -import { - useCallback, - useMemo, - useState, - type ComponentType, - type ReactNode, - type Ref, -} from "react"; +import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react"; import { Pressable, Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; -import { ICON_SIZE } from "@/styles/theme"; -import type { Theme } from "@/styles/theme"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Shortcut } from "@/components/ui/shortcut"; +import { density, densityIconBox } from "@/styles/density"; import type { ShortcutKey } from "@/utils/format-shortcut"; -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - type SidebarHeaderRowVariant = "header" | "compact" | "inline"; export type SidebarRowIcon = ComponentType<{ size: number; color: string }>; @@ -49,7 +39,7 @@ interface SidebarHeaderRowProps { } export function SidebarHeaderRow({ - icon: Icon, + icon, label, onPress, isActive = false, @@ -64,9 +54,8 @@ export function SidebarHeaderRow({ const [isHovered, setIsHovered] = useState(false); const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); - const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]); const isHighlighted = isHovered || isActive; - const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm; + const iconSize = variant === "header" ? "md" : "sm"; let right = trailing ?? null; if (right === null && shortcutKeys && isHovered) { @@ -91,10 +80,11 @@ export function SidebarHeaderRow({ aria-selected={isActive} style={styles.button} > - {ThemedIcon ? ( - ) : ( @@ -140,7 +130,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", // Same row geometry as the settings sidebar items. Shorter than the header // strip so the hover highlight clears the strip's bottom separator. - minHeight: 28, + minHeight: density.tightRowHeight, borderRadius: theme.borderRadius.lg, }, rowHighlighted: { @@ -152,14 +142,14 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - minHeight: 28, + minHeight: density.tightRowHeight, paddingVertical: theme.spacing[1], // Match the project rows' inner padding so the icons align on one vertical // edge with the list below. paddingHorizontal: theme.spacing[2], }, - iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md }, - iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm }, + iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") }, + iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") }, label: { flexShrink: 1, fontSize: theme.fontSize.base, diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index d709b3d9ea..97ff3d2772 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -14,12 +14,14 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAppDiagnosticStore } from "@/diagnostics/store"; import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore } from "@/stores/session-store"; +import { density } from "@/styles/density"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -30,12 +32,10 @@ import { openExternalUrl } from "@/utils/open-external-url"; const DISCORD_URL = "https://discord.gg/jz8T2uahpH"; const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new"; const ThemedActivity = withUnistyles(Activity); -const ThemedCircleHelp = withUnistyles(CircleHelp); const ThemedGift = withUnistyles(Gift); const ThemedKeyboard = withUnistyles(Keyboard); const ThemedDiscordIcon = withUnistyles(DiscordIcon); const ThemedGitHubIcon = withUnistyles(GitHubIcon); -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); @@ -109,9 +109,10 @@ export function SidebarHelpMenu() { accessibilityLabel={t("sidebar.help.trigger")} > {({ hovered }) => ( - )} @@ -182,8 +183,8 @@ export function SidebarHelpMenu() { const styles = StyleSheet.create((theme) => ({ trigger: { - width: 28, - height: 28, + width: density.iconButton, + height: density.iconButton, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[1], diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index 9399a3bd13..ec834aee19 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"; import { useSidebarWorkspacesList, @@ -54,7 +55,9 @@ export function SidebarModelProvider({ active?: boolean; children: ReactNode; }) { + const { t } = useTranslation(); const list = useSidebarWorkspacesList({ enabled: active }); + const showBackground = useSidebarViewStore((state) => state.showBackground); const groupMode = useSidebarViewStore((state) => state.groupMode); const labelFilter = useSidebarViewStore((state) => state.labelFilter); const projectFilters = useSidebarViewStore((state) => state.projectFilters); @@ -105,9 +108,12 @@ export function SidebarModelProvider({ workspaces: [...workspaceEntriesByKey.values()], projectFilters: resolvedProjectFilters, }); - const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter }); + const filtered = filterWorkspacesByLabels({ + workspaces: byProject.filter((workspace) => showBackground || !workspace.background), + ...labelFilter, + }); return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace])); - }, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]); + }, [showBackground, labelFilter, resolvedProjectFilters, workspaceEntriesByKey]); const visibleWorkspaceKeys = useMemo( () => new Set(filteredWorkspaceEntriesByKey.keys()), [filteredWorkspaceEntriesByKey], @@ -122,16 +128,19 @@ export function SidebarModelProvider({ const included = new Set(resolvedProjectFilters); projects = projects.filter((project) => included.has(project.viewKey)); } - if (hasActiveLabelFilter) { + if (hasActiveLabelFilter || !showBackground) { projects = projects.flatMap((project) => { const workspaces = project.workspaces.filter((workspace) => visibleWorkspaceKeys.has(workspace.workspaceKey), ); - return workspaces.length > 0 ? [{ ...project, workspaces }] : []; + return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0) + ? [{ ...project, workspaces }] + : []; }); } return projects; }, [ + showBackground, hasActiveLabelFilter, hasActiveProjectFilter, resolvedProjectFilters, @@ -150,6 +159,7 @@ export function SidebarModelProvider({ pinnedCollapsed, collapsedProjectKeys, collapsedWorkspaceGroupKeys, + t, }), [ collapsedProjectKeys, @@ -161,6 +171,7 @@ export function SidebarModelProvider({ pinnedKeys, pinnedWorkspaceOrder, filteredWorkspaceEntriesByKey, + t, ], ); const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]); diff --git a/packages/app/src/components/sidebar/sidebar-popover.tsx b/packages/app/src/components/sidebar/sidebar-popover.tsx index ec13605d08..e57f3cf6ac 100644 --- a/packages/app/src/components/sidebar/sidebar-popover.tsx +++ b/packages/app/src/components/sidebar/sidebar-popover.tsx @@ -6,7 +6,7 @@ import type { SidebarSection } from "@/sidebar-nav/model"; /** * The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on - * compact ones. Plugin items and the built-in Usage item open it the same way: + * compact ones. Plugin items open it this way: * `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to, * and `SidebarPopoverSurface` for the body. */ diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index 55882a0c49..c2dc31f2ee 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import type { SidebarProjectEntry, @@ -85,6 +86,7 @@ function projectionInput(options?: { pinnedCollapsed: options?.pinnedCollapsed ?? false, collapsedProjectKeys: new Set(), collapsedWorkspaceGroupKeys: new Set(), + t: i18n.t, }; } diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index c67042b45e..2e84f02aae 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; import { splitPinnedSidebarGroups, @@ -45,6 +46,7 @@ export interface SidebarProjectionInput { pinnedCollapsed: boolean; collapsedProjectKeys: ReadonlySet; collapsedWorkspaceGroupKeys: ReadonlySet; + t: TFunction; } export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection { @@ -100,7 +102,7 @@ function buildWorkspaceGroups( return []; case "status": return statusWorkspaceGroups( - buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey), + buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey, input.t), ); } } diff --git a/packages/app/src/components/sidebar/sidebar-section-header.tsx b/packages/app/src/components/sidebar/sidebar-section-header.tsx new file mode 100644 index 0000000000..7a959435bc --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-section-header.tsx @@ -0,0 +1,115 @@ +import { useMemo, type ReactNode } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react-native"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { DensityIcon } from "@/components/ui/density-icon"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { density } from "@/styles/density"; + +/** A sidebar section label such as "Pinned" or "Workspaces". A section with `onToggle` + * collapses from its title; children sit at the trailing edge as its actions. */ +export function SidebarSectionHeader({ + title, + collapsed, + onToggle, + testID, + children, +}: { + title: string; + collapsed?: boolean; + onToggle?: () => void; + testID?: string; + children?: ReactNode; +}) { + return ( + + {onToggle ? ( + + ) : ( + + {title} + + )} + {children ? {children} : null} + + ); +} + +function CollapsibleTitle({ + title, + collapsed, + onToggle, + testID, +}: { + title: string; + collapsed: boolean; + onToggle: () => void; + testID?: string; +}) { + const isCompact = useIsCompactFormFactor(); + const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); + + return ( + + {({ hovered }) => ( + <> + {title} + {hovered || isNative || isCompact ? ( + + ) : null} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + minHeight: density.rowHeight, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + // The title lands at spacing[2] to align with the rows' leading icons. Trailing actions + // are icon buttons whose painted glyph stops inside the SVG box, so 4px aligns the ink + // rather than the box to the row rail. + paddingLeft: theme.spacing[2], + paddingRight: 4, + userSelect: "none", + }, + // The toggle reaches back over the header's leading padding so the whole label edge is pressable. + toggle: { + alignSelf: "stretch", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + marginLeft: -theme.spacing[2], + paddingHorizontal: theme.spacing[2], + }, + title: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + actions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 051121fd3f..baffc3a010 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { StyleSheet } from "react-native-unistyles"; -import type { Theme } from "@/styles/theme"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { withUnistyles } from "react-native-unistyles"; import { @@ -73,7 +73,7 @@ import { SidebarWorkspaceContextMenu, SidebarWorkspaceMenu, } from "@/components/sidebar/sidebar-workspace-menu"; -import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; +import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin"; @@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; // Themed icon wrappers -const foregroundMutedColorMapping = (theme: Theme) => ({ +const mutedGroupIconProps = densityIconProps("sm", (theme) => ({ color: theme.colors.foregroundMuted, -}); +})); // One mapping per bucket, resolved through the status-dot producer so a group header and // the rows under it cannot disagree about what "failed" looks like. -const needsInputColorMapping = (theme: Theme) => ({ +const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, -}); -const failedColorMapping = (theme: Theme) => ({ +})); +const failedGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, -}); -const attentionColorMapping = (theme: Theme) => ({ +})); +const attentionGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, -}); -const runningColorMapping = (theme: Theme) => ({ +})); +const runningGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, -}); +})); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); @@ -146,6 +146,7 @@ export function SidebarStatusWorkspaceList({ parentGestureRef, dragGestureHostActive, }: StatusWorkspaceListProps) { + const { t } = useTranslation(); const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedWorkspaceGroupKeys, ); @@ -202,7 +203,12 @@ export function SidebarStatusWorkspaceList({ <> {pinnedWorkspaces.length > 0 ? ( - + {pinnedCollapsed ? null : ( <> state.toggleWorkspaceGroupCollapsed, @@ -431,7 +438,7 @@ function StatusGroupHeader({ ; } if (collapsed) { - return ; + return ; } - return ; + return ; } function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) { switch (bucket) { case "needs_input": - return ; + return ; case "failed": - return ; + return ; case "attention": - return ; + return ; case "running": - return ; + return ; case "done": - return ; + return ; } } @@ -1057,7 +1064,7 @@ const styles = StyleSheet.create((theme) => ({ }, statusWorkspaceListContainer: {}, statusGroupRow: { - minHeight: 36, + minHeight: density.rowHeight, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -1083,8 +1090,8 @@ const styles = StyleSheet.create((theme) => ({ }, statusGroupLeadingVisualSlot: { position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), flexShrink: 0, alignItems: "center", justifyContent: "center", @@ -1107,7 +1114,7 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index 115003a3f2..e1543fac41 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -380,6 +380,7 @@ export function SidebarWorkspaceContextMenu({ serviceLabel: serviceSummary ? t(workspaceServiceLabelKey(serviceSummary), { name: serviceSummary.name }) : null, + t, }); const workspaceTarget = useMemo( () => ({ diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 7b48f0dd6c..7fc904f3b3 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -16,6 +16,7 @@ import { type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; import { useAppSettings } from "@/hooks/use-settings"; +import { densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getStatusDotColor } from "@/utils/status-dot-color"; @@ -27,19 +28,16 @@ import { import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { StatusRing } from "@/components/status-ring"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; +import { DensityIcon } from "@/components/ui/density-icon"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const needsInputColorMapping = (theme: Theme) => ({ color: theme.colors.surface0, fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, }); const ThemedCircleAlert = withUnistyles(CircleAlert); -const ThemedMonitor = withUnistyles(Monitor); -const ThemedFolder = withUnistyles(Folder); -const ThemedFolderGit2 = withUnistyles(FolderGit2); export function SidebarWorkspaceRowFrame({ workspace, @@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({ ) : null; } - let KindIcon: typeof ThemedMonitor; - if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; - else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; - else KindIcon = ThemedFolder; + let KindIcon: typeof Monitor; + if (workspaceKind === "local_checkout") KindIcon = Monitor; + else if (workspaceKind === "worktree") KindIcon = FolderGit2; + else KindIcon = Folder; const dotColorStyle = getStatusDotColorStyle(bucket); return ( - + {dotColorStyle ? : null} ); @@ -455,6 +453,9 @@ export function SidebarWorkspaceTrailingActionOverlay({ ); } +// The status dot centres on the title's first line, so both share its line height. +const TITLE_LINE_HEIGHT = 20; + const styles = StyleSheet.create((theme) => ({ workspaceRowContent: { position: "relative", @@ -482,8 +483,8 @@ const styles = StyleSheet.create((theme) => ({ }, workspaceStatusDot: { position: "relative", - width: theme.iconSize.md, - height: 20, + width: densityIconBox("md"), + height: TITLE_LINE_HEIGHT, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", @@ -517,7 +518,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: "400", - lineHeight: 20, + lineHeight: TITLE_LINE_HEIGHT, opacity: 0.76, flex: 1, minWidth: 0, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index f8dcdebaf5..c5f8a278bd 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -4,6 +4,7 @@ import { View, Text, type GestureResponderEvent } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import type { HostBadgeModel } from "@/hosts/appearance"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { density } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -485,7 +486,7 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[1], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts index 2068dbe271..465caf1344 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import { resolveSidebarWorkspaceAccessibilityLabel, @@ -39,6 +40,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" }, workspaceTitleSource: "title", hostBadgeLabel: "Build host", + t: i18n.t, }); expect(label).toBe("Investigate search, Build host"); @@ -56,6 +58,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { hostBadgeLabel: "Build host", pullRequestLabel: "Pull request 42", serviceLabel: "Service web running", + t: i18n.t, }); expect(label).toBe( @@ -69,6 +72,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { workspaceTitleSource: "title", leadingProjectName: "Search project", hostBadgeLabel: "Build host", + t: i18n.t, }); expect(label).toBe("Search project, Investigate search, Build host"); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.ts index 955c9d2636..da3782f082 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.ts @@ -1,6 +1,7 @@ +import type { TFunction } from "i18next"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { WorkspaceTitleSource } from "@/hooks/use-settings"; -import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model"; +import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; export function resolveSidebarWorkspacePrimaryLabel(input: { workspace: Pick; @@ -19,6 +20,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: { hostBadgeLabel?: string | null; pullRequestLabel?: string | null; serviceLabel?: string | null; + t: TFunction; }): string { return [ input.leadingProjectName, @@ -28,7 +30,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: { input.serviceLabel, input.workspace.statusBucket === "done" ? null - : STATUS_BUCKET_LABELS[input.workspace.statusBucket], + : getStatusBucketLabel(input.workspace.statusBucket, input.t), ] .filter((label): label is string => Boolean(label)) .join(", "); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 5f467785d7..9705f3be15 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -397,18 +397,6 @@ export function SplitContainer({ }, [workspaceKey], ); - const handleCreateExplorerTab = useCallback( - () => onCreateNewTab({ paneId: explorerSidebarPaneId ?? undefined }), - [explorerSidebarPaneId, onCreateNewTab], - ); - const handleMoveExplorerTabToMain = useCallback( - (tabId: string) => { - if (layout.focusedPaneId) { - onMoveTabToPane(tabId, layout.focusedPaneId); - } - }, - [layout.focusedPaneId, onMoveTabToPane], - ); const splitRoot = useMemo( () => resolveSplitContainerRoot({ @@ -725,8 +713,19 @@ export function SplitContainer({ closingTabIds={closingTabIds} onSelectTab={onSelectTabInPane} onCloseTab={onCloseTab} - onCreateNewTab={handleCreateExplorerTab} - onMoveTabToMain={handleMoveExplorerTabToMain} + onCreateNewTab={onCreateNewTab} + hoveredCloseTabKey={hoveredCloseTabKey} + setHoveredCloseTabKey={setHoveredCloseTabKey} + onCopyResumeCommand={onCopyResumeCommand} + onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} + onCopyFilePath={onCopyFilePath} + onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} + onCloseTabsToLeft={onCloseTabsToLeft} + onCloseTabsToRight={onCloseTabsToRight} + onCloseOtherTabs={onCloseOtherTabs} + onExitFocusMode={onExitFocusMode} buildPaneContentModel={buildPaneContentModel} onReorderTabsInPane={onReorderTabsInPane} activeDragTabId={activeDragTabId} diff --git a/packages/app/src/components/ui/menu/menu-anchor.test.ts b/packages/app/src/components/ui/anchor.test.ts similarity index 88% rename from packages/app/src/components/ui/menu/menu-anchor.test.ts rename to packages/app/src/components/ui/anchor.test.ts index add33fff3a..cb18a86271 100644 --- a/packages/app/src/components/ui/menu/menu-anchor.test.ts +++ b/packages/app/src/components/ui/anchor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computePosition, getTransformOrigin, type Rect } from "./menu-anchor"; +import { computePosition, getTransformOrigin, type Rect } from "./anchor"; const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 }; const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 }; @@ -55,6 +55,18 @@ describe("computePosition", () => { expect(right).toEqual({ x: 144, y: 100, actualPlacement: "right" }); }); + it("lets hover cards flip horizontally when the opposite side has room", () => { + const result = position({ + triggerRect: { ...TRIGGER, x: 850 }, + placement: "right", + flipHorizontal: true, + }); + expect(result).toEqual({ x: 646, y: 100, actualPlacement: "left" }); + expect( + position({ triggerRect: { ...TRIGGER, x: 850 }, placement: "right" }).actualPlacement, + ).toBe("right"); + }); + it("keeps the surface inside a display area that does not start at the origin", () => { // Regression: the horizontal clamp used to ignore displayArea.x while the vertical one // honoured displayArea.y, so an offset display area pushed the surface off its left edge. diff --git a/packages/app/src/components/ui/menu/menu-anchor.ts b/packages/app/src/components/ui/anchor.ts similarity index 82% rename from packages/app/src/components/ui/menu/menu-anchor.ts rename to packages/app/src/components/ui/anchor.ts index efdfbc8ab2..51454a9ee5 100644 --- a/packages/app/src/components/ui/menu/menu-anchor.ts +++ b/packages/app/src/components/ui/anchor.ts @@ -1,10 +1,8 @@ import type { View } from "react-native"; /** - * Where a floating menu surface goes relative to the thing that opened it. - * - * This was two byte-identical copies, one in `dropdown-menu.tsx` and one in `context-menu.tsx`. - * Submenus need a third caller, so it lives here now and the menus import it. + * Where a floating surface goes relative to the thing that opened it. Menus and hover cards + * place themselves with it. */ export type Placement = "top" | "bottom" | "left" | "right"; @@ -38,14 +36,15 @@ export function measureElement(element: View): Promise { * there is more room above. Both conditions matter: flipping into a side that is equally * cramped just moves the clipping, so a surface taller than the whole viewport stays put. * - * Only the vertical placements flip. A left/right submenu that doesn't fit is handled by the - * caller choosing its side up front, because flipping it mid-open would move the surface out - * from under the pointer that is travelling toward it. + * Horizontal flipping is opt-in for hover cards. Submenus choose their direction up front so + * they do not move out from under the pointer travelling toward them. */ function flipPlacement(input: { placement: Placement; triggerRect: Rect; contentHeight: number; + contentWidth: number; + flipHorizontal: boolean; displayArea: Rect; }): Placement { const { placement, triggerRect, contentHeight, displayArea } = input; @@ -58,6 +57,14 @@ function flipPlacement(input: { if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) { return "bottom"; } + if (input.flipHorizontal) { + const spaceLeft = triggerRect.x - displayArea.x; + const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width); + if (placement === "right" && spaceRight < input.contentWidth && spaceLeft > spaceRight) + return "left"; + if (placement === "left" && spaceLeft < input.contentWidth && spaceRight > spaceLeft) + return "right"; + } return placement; } @@ -120,6 +127,7 @@ export function computePosition({ placement, alignment, offset, + flipHorizontal = false, }: { triggerRect: Rect; contentSize: Size; @@ -127,11 +135,15 @@ export function computePosition({ placement: Placement; alignment: Alignment; offset: number; + /** Hover cards can choose either side; submenu direction is owned by the menu. */ + flipHorizontal?: boolean; }): { x: number; y: number; actualPlacement: Placement } { const actualPlacement = flipPlacement({ placement, triggerRect, contentHeight: contentSize.height, + contentWidth: contentSize.width, + flipHorizontal, displayArea, }); const anchored = anchorToPlacement({ diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index e160970223..707ce60648 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -174,6 +174,7 @@ export function Button({ ...props }: PropsWithChildren< Omit & { + ref?: React.Ref; variant?: ButtonVariant; size?: ButtonSize; leftIcon?: LeftIcon; diff --git a/packages/app/src/components/ui/combobox-options.test.ts b/packages/app/src/components/ui/combobox-options.test.ts index 6129af9463..e3e98633b3 100644 --- a/packages/app/src/components/ui/combobox-options.test.ts +++ b/packages/app/src/components/ui/combobox-options.test.ts @@ -160,3 +160,16 @@ describe("combobox above-search ordering", () => { expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0); }); }); + +describe("search-only option text", () => { + it("finds hidden search text while keeping title matches first", () => { + const bodyMatch = { id: "pr:7", label: "Fix parsing", searchText: "Release notes for 2024" }; + const titleMatch = { id: "pr:8", label: "2024 release" }; + expect(filterAndRankComboboxOptions([bodyMatch, titleMatch], "2024")).toEqual([ + titleMatch, + bodyMatch, + ]); + expect(filterAndRankComboboxOptions([bodyMatch], "unrelated")).toEqual([]); + expect(filterAndRankComboboxOptions([bodyMatch], "parsing 2024")).toEqual([bodyMatch]); + }); +}); diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts index e23f2315ef..006eb1a21e 100644 --- a/packages/app/src/components/ui/combobox-options.ts +++ b/packages/app/src/components/ui/combobox-options.ts @@ -10,18 +10,22 @@ export interface ComboboxOptionModel { id: string; label: string; description?: string; + /** Additional text to match without displaying it in the option row. */ + searchText?: string; kind?: ComboboxOptionKind; } -const DESCRIPTION_FALLBACK_TIER = 99; +const SECONDARY_TEXT_FALLBACK_TIER = 99; function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null { const best = scoreTextFields(search, [opt.label, opt.id]); if (best) return best; - if (!opt.description) return null; - const descriptionScore = scoreTextFields(search, [opt.description]); - if (!descriptionScore) return null; - return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER }; + const secondaryFields = opt.searchText + ? [opt.label, opt.id, opt.description ?? "", opt.searchText] + : [opt.description ?? ""]; + const secondaryScore = scoreTextFields(search, secondaryFields); + if (!secondaryScore) return null; + return { ...secondaryScore, tier: secondaryScore.tier + SECONDARY_TEXT_FALLBACK_TIER }; } export interface BuildVisibleComboboxOptionsInput { diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 736ca3bfa5..caf73d1bbc 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { Check, File, Folder, Search } from "lucide-react-native"; @@ -50,6 +50,7 @@ import type { ComboboxOptionModel } from "./combobox-options"; import { isWeb } from "@/constants/platform"; import { IsolatedBottomSheetModal, + SheetVisibleFrame, useIsolatedBottomSheetVisibility, } from "./isolated-bottom-sheet-modal"; import { @@ -948,9 +949,19 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], ); + const { footer, safeAreaBottom } = props; const frameStyle = useMemo( - () => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }], - [props.safeAreaBottom], + () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }], + [footer, safeAreaBottom], + ); + const footerStyle = useMemo( + () => [styles.footer, { paddingBottom: safeAreaBottom }], + [safeAreaBottom], + ); + + const footerView = useMemo( + () => (footer ? {footer} : undefined), + [footer, footerStyle], ); const body = props.hasChildren ? ( @@ -983,46 +994,47 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { keyboardBlurBehavior="none" presentation={props.presentation} > - - {props.header ? ( - - ) : ( - <> - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - )} - {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( - body - ) : ( - - {body} - - )} - {props.footer ? {props.footer} : null} - + + + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} + + ); } diff --git a/packages/app/src/components/ui/density-icon.tsx b/packages/app/src/components/ui/density-icon.tsx new file mode 100644 index 0000000000..ee24ee3588 --- /dev/null +++ b/packages/app/src/components/ui/density-icon.tsx @@ -0,0 +1,51 @@ +import { useMemo, type ComponentType } from "react"; +import { withUnistyles } from "react-native-unistyles"; +import { densityIconSize, type DensityIconSize } from "@/styles/density"; +import type { Theme } from "@/styles/theme"; + +type IconComponent = ComponentType<{ size: number; color: string; strokeWidth?: number }>; + +type IconColor = { + [K in keyof Theme["colors"]]: Theme["colors"][K] extends string ? K : never; +}[keyof Theme["colors"]]; + +function IconGlyph({ + icon: Icon, + size, + color, + strokeWidth, +}: { + icon: IconComponent; + size: number; + color: string; + strokeWidth?: number; +}) { + return ; +} + +const ThemedIconGlyph = withUnistyles(IconGlyph); + +/** An icon sized by density: larger on compact layouts. See `@/styles/density`. */ +export function DensityIcon({ + icon, + size, + sizeAdjustment = 0, + color, + strokeWidth, +}: { + icon: IconComponent; + size: DensityIconSize; + /** Only for a glyph that reads larger than others at the same size. */ + sizeAdjustment?: number; + color: IconColor; + strokeWidth?: number; +}) { + const uniProps = useMemo( + () => (theme: Theme, rt: { breakpoint?: string }) => ({ + size: densityIconSize(rt, size) + sizeAdjustment, + color: theme.colors[color], + }), + [size, sizeAdjustment, color], + ); + return ; +} diff --git a/packages/app/src/components/ui/floating-action-button.tsx b/packages/app/src/components/ui/floating-action-button.tsx index 33d72985fe..2b06e097f2 100644 --- a/packages/app/src/components/ui/floating-action-button.tsx +++ b/packages/app/src/components/ui/floating-action-button.tsx @@ -1,47 +1,67 @@ +import { forwardRef, useMemo } from "react"; +import { Text, type View } from "react-native"; import type { LucideIcon } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; import { PressHighlight } from "@/components/ui/press-highlight"; -import { ICON_SIZE, SPACING } from "@/styles/theme"; +import { ICON_SIZE } from "@/styles/theme"; -const FLOATING_ACTION_BUTTON_SIZE = 56; -const FLOATING_ACTION_BUTTON_INSET = SPACING[4]; - -/** Space a scrolling surface keeps below its content so the button traps nothing. */ -export const FLOATING_ACTION_BUTTON_CLEARANCE = - FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET; +import { + FLOATING_ACTION_BUTTON_SIZE, + FLOATING_ACTION_BUTTON_INSET, +} from "./floating-action-layout"; +export { + FLOATING_ACTION_BUTTON_CLEARANCE, + floatingActionsClearance, +} from "./floating-action-layout"; export interface FloatingActionButtonProps { icon: LucideIcon; - /** Names the action; the button carries no label. */ + /** Names the action for assistive technology. */ accessibilityLabel: string; onPress: () => void; + label?: string; + disabled?: boolean; + bottomInset?: number; testID?: string; } /** - * The one floating action a scrolling surface can offer. It pins itself to the - * bottom-right of its nearest positioned ancestor, so the ancestor decides what - * the button may cover. + * Pins an action to the bottom-right of its nearest positioned ancestor. + * The surface reserves scroll clearance and uses bottomInset to separate stacked actions. */ -export function FloatingActionButton({ - icon: Icon, - accessibilityLabel, - onPress, - testID, -}: FloatingActionButtonProps) { - return ( - - - - ); -} +export const FloatingActionButton = forwardRef( + function FloatingActionButton( + { icon: Icon, accessibilityLabel, onPress, label, disabled = false, bottomInset = 0, testID }, + ref, + ) { + const accessibilityState = useMemo(() => ({ disabled }), [disabled]); + const buttonStyle = useMemo( + () => [ + styles.button, + label && styles.labeled, + disabled && styles.disabled, + { bottom: FLOATING_ACTION_BUTTON_INSET + bottomInset }, + ], + [bottomInset, disabled, label], + ); + return ( + + + {label ? {label} : null} + + ); + }, +); const styles = StyleSheet.create((theme) => ({ button: { @@ -56,6 +76,15 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface3, ...theme.shadow.md, }, + labeled: { + width: "auto", + minWidth: FLOATING_ACTION_BUTTON_SIZE, + paddingHorizontal: theme.spacing[4], + flexDirection: "row", + gap: theme.spacing[2], + }, + label: { color: theme.colors.foreground, fontSize: theme.fontSize.base }, + disabled: { opacity: theme.opacity[50] }, pressed: { backgroundColor: theme.colors.interactionHighlight, }, diff --git a/packages/app/src/components/ui/floating-action-layout.test.ts b/packages/app/src/components/ui/floating-action-layout.test.ts new file mode 100644 index 0000000000..2256f4ef9b --- /dev/null +++ b/packages/app/src/components/ui/floating-action-layout.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { + floatingActionsClearance, + FLOATING_ACTION_BUTTON_CLEARANCE, +} from "./floating-action-layout"; + +describe("floating action scroll clearance", () => { + it("keeps the last diff line above Jump to file before and after feedback is added", () => { + const jump = FLOATING_ACTION_BUTTON_CLEARANCE; + const feedback = FLOATING_ACTION_BUTTON_CLEARANCE + 44; + expect(floatingActionsClearance([jump, 0], 34)).toBe(106); + expect(floatingActionsClearance([jump, feedback], 34)).toBe(222); + expect(floatingActionsClearance([0, feedback], 34)).toBe(150); + expect(floatingActionsClearance([jump, feedback], 0)).toBe(188); + expect(floatingActionsClearance([0, 0], 34)).toBe(0); + }); +}); diff --git a/packages/app/src/components/ui/floating-action-layout.ts b/packages/app/src/components/ui/floating-action-layout.ts new file mode 100644 index 0000000000..c54fd62dbe --- /dev/null +++ b/packages/app/src/components/ui/floating-action-layout.ts @@ -0,0 +1,15 @@ +import { SPACING } from "@/styles/theme"; + +export const FLOATING_ACTION_BUTTON_SIZE = 56; +export const FLOATING_ACTION_BUTTON_INSET = SPACING[4]; +export const FLOATING_ACTION_BUTTON_CLEARANCE = + FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET; + +/** Reserve the stacked actions and the safe-area inset once for their scrolling surface. */ +export function floatingActionsClearance( + actionClearances: readonly number[], + bottomInset: number, +): number { + const clearance = actionClearances.reduce((total, action) => total + action, 0); + return clearance > 0 ? clearance + bottomInset : 0; +} diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx new file mode 100644 index 0000000000..3d438505d3 --- /dev/null +++ b/packages/app/src/components/ui/hover-card.tsx @@ -0,0 +1,418 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, + type RefObject, +} from "react"; +import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native"; +import { FadeIn, FadeOut } from "react-native-reanimated"; +import { StyleSheet } from "react-native-unistyles"; +import { Portal } from "@gorhom/portal"; +import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; +import { + OverlayLayerProvider, + useOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; +import { FloatingSurface } from "@/components/ui/floating"; +import { isWeb } from "@/constants/platform"; +import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; +import { + computePosition, + measureElement, + type Alignment, + type Placement, + type Rect, + type Size, +} from "./anchor"; + +const CLOSE_GRACE_MS = 100; + +interface HoverCardContextValue { + open: boolean; + layer: number; + setTriggerRef: (node: View | null) => void; + triggerRef: RefObject; + contentRef: RefObject; + openNow: () => void; + scheduleClose: () => void; +} + +const HoverCardContext = createContext(null); + +/** + * A card that opens while the pointer is on its trigger and stays open while the pointer crosses + * onto it, so its content can be pressed. Hover only exists on web; elsewhere the trigger renders + * alone and the content never mounts. See docs/hover.md. + * + * ```tsx + * + * {trigger} + * {details} + * + * ``` + */ +export function HoverCard({ + disabled = false, + children, +}: { + /** Closes the card and keeps it closed, e.g. while its trigger is dragged. */ + disabled?: boolean; + children: ReactNode; +}): ReactNode { + if (!isWeb) return children; + return {children}; +} + +function WebHoverCard({ + disabled, + children, +}: { + disabled: boolean; + children: ReactNode; +}): ReactElement { + const layer = useOverlayLayer("floating"); + const triggerRef = useRef(null); + const contentRef = useRef(null); + const [open, setOpen] = useState(false); + const graceTimerRef = useRef | null>(null); + // Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible. + const keyboardFocusedRef = useRef(false); + + const clearGraceTimer = useCallback(() => { + if (graceTimerRef.current) { + clearTimeout(graceTimerRef.current); + graceTimerRef.current = null; + } + }, []); + + const focusInside = useCallback(() => { + if (!isWeb) return false; + const active = document.activeElement; + return [triggerRef.current, contentRef.current].some((view) => + (view as unknown as HTMLElement | null)?.contains(active), + ); + }, []); + + // Keyboard focus keeps the card open for keyboard users; the focus a pointer click leaves + // behind on the trigger or a button in the card does not outlast the pointer leaving. + const keyboardFocusInside = useCallback(() => { + return focusInside() && document.activeElement?.matches(":focus-visible") === true; + }, [focusInside]); + + const scheduleClose = useCallback(() => { + if (keyboardFocusInside()) return; + if (graceTimerRef.current) return; + graceTimerRef.current = setTimeout(() => { + graceTimerRef.current = null; + if (keyboardFocusInside()) return; + setOpen(false); + }, CLOSE_GRACE_MS); + }, [keyboardFocusInside]); + + const openNow = useCallback(() => { + clearGraceTimer(); + if (!disabled) { + setOpen(true); + } + }, [clearGraceTimer, disabled]); + + const keyPressed = useCallback( + (event: KeyboardEvent): boolean => { + if (!isWeb) return false; + const trigger = triggerRef.current as unknown as HTMLElement | null; + if (!trigger) return false; + if (event.key === "Escape" && open) { + event.preventDefault(); + if (focusInside()) trigger.focus(); + clearGraceTimer(); + setOpen(false); + return true; + } else if ( + focusInside() && + event.target === trigger && + ["ArrowDown", "Enter", " "].includes(event.key) + ) { + event.preventDefault(); + openNow(); + requestAnimationFrame(() => { + (contentRef.current as unknown as HTMLElement | null) + ?.querySelector( + 'button, [role="button"][tabindex="0"], a[href], input, [tabindex="0"]', + ) + ?.focus(); + }); + return true; + } + return false; + }, + [clearGraceTimer, focusInside, open, openNow], + ); + + const setOverlayScope = useWebOverlayRegistration({ + active: !disabled && open, + layer, + onKeyDown: keyPressed, + manageFocus: false, + }); + const setTriggerRef = useCallback( + (node: View | null) => { + triggerRef.current = node; + setOverlayScope(node); + }, + [setOverlayScope], + ); + + useEffect(() => { + if (!isWeb) return; + const trigger = triggerRef.current as unknown as HTMLElement | null; + if (!trigger) return; + const close = () => { + clearGraceTimer(); + setOpen(false); + }; + const focusEntered = (event: FocusEvent) => { + const target = event.target as Element; + keyboardFocusedRef.current = target.matches(":focus-visible"); + if (trigger.contains(target)) { + openNow(); + } else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) { + clearGraceTimer(); + } + }; + // Only keyboard focus keeps the card open, so only losing keyboard focus closes it. A button + // that disables itself on press drops pointer focus while the pointer is still on the card. + const focusLeft = (event: FocusEvent) => { + const next = event.relatedTarget as Node | null; + if ( + keyboardFocusedRef.current && + !trigger.contains(next) && + !(contentRef.current as unknown as HTMLElement | null)?.contains(next) + ) { + scheduleClose(); + } + }; + const activateClosedTrigger = (event: KeyboardEvent) => { + if ( + !open && + !event.defaultPrevented && + !event.isComposing && + ["ArrowDown", "Enter", " "].includes(event.key) + ) + keyPressed(event); + }; + const scrolled = (event: Event) => { + if (!(contentRef.current as unknown as HTMLElement | null)?.contains(event.target as Node)) + close(); + }; + document.addEventListener("focusin", focusEntered); + document.addEventListener("focusout", focusLeft); + trigger.addEventListener("keydown", activateClosedTrigger); + document.addEventListener("scroll", scrolled, true); + window.addEventListener("resize", close); + return () => { + document.removeEventListener("focusin", focusEntered); + document.removeEventListener("focusout", focusLeft); + trigger.removeEventListener("keydown", activateClosedTrigger); + document.removeEventListener("scroll", scrolled, true); + window.removeEventListener("resize", close); + }; + }, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]); + + // While open, the safe zone covers trigger + content + the bridge between + // them. Close only fires when the pointer leaves the safe zone; re-entering + // it (including the bridge) cancels the pending close. + useHoverSafeZone({ + enabled: open, + triggerRef, + contentRef, + onEnterSafeZone: clearGraceTimer, + onLeaveSafeZone: scheduleClose, + }); + + useEffect(() => { + if (disabled) { + clearGraceTimer(); + setOpen(false); + } + }, [clearGraceTimer, disabled]); + + useEffect(() => clearGraceTimer, [clearGraceTimer]); + + const value = useMemo( + () => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }), + [open, layer, setTriggerRef, openNow, scheduleClose], + ); + + return ( + + {children} + + ); +} + +export function HoverCardTrigger({ + children, + focusable = false, + accessibilityLabel, +}: { + children: ReactNode; + focusable?: boolean; + accessibilityLabel?: string; +}): ReactNode { + const ctx = useContext(HoverCardContext); + if (!ctx) return children; + return ( + + {children} + + ); +} + +interface HoverCardContentProps { + placement: Placement; + alignment?: Alignment; + offset?: number; + /** Layered over the default popover surface. */ + style?: StyleProp; + role?: Role; + accessibilityLabel?: string; + testID?: string; + /** Mounted only while the card is open, so whatever it fetches runs only then. */ + children: ReactNode; +} + +export function HoverCardContent(props: HoverCardContentProps): ReactElement | null { + const ctx = useContext(HoverCardContext); + if (!ctx?.open) return null; + return ( + + ); +} + +function HoverCardSurface({ + layer, + triggerRef, + contentRef, + placement, + alignment = "center", + offset = 4, + style, + role, + accessibilityLabel, + testID, + children, +}: HoverCardContentProps & { + layer: number; + triggerRef: RefObject; + contentRef: RefObject; +}): ReactElement { + const bottomSheetInternal = useBottomSheetModalInternal(true); + const [triggerRect, setTriggerRect] = useState(null); + const [contentSize, setContentSize] = useState(null); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + + useEffect(() => { + if (!triggerRef.current) return; + let cancelled = false; + void measureElement(triggerRef.current).then((rect) => { + if (!cancelled) setTriggerRect(rect); + return undefined; + }); + return () => { + cancelled = true; + }; + }, [triggerRef]); + + useEffect(() => { + if (!triggerRect || !contentSize) return; + const { width, height } = Dimensions.get("window"); + const { x, y } = computePosition({ + triggerRect, + contentSize, + displayArea: { x: 0, y: 0, width, height }, + placement, + flipHorizontal: true, + alignment, + offset, + }); + setPosition({ x, y }); + }, [alignment, contentSize, offset, placement, triggerRect]); + + const handleLayout = useCallback( + (event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width, height } = event.nativeEvent.layout; + setContentSize({ width, height }); + }, + [], + ); + + const frameStyle = useMemo( + () => ({ + position: "absolute" as const, + top: position?.y ?? -9999, + left: position?.x ?? -9999, + }), + [position?.x, position?.y], + ); + const surfaceStyle = useMemo(() => [styles.surface, style], [style]); + + return ( + + + + {children} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + portalOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 1000, + }, + surface: { + backgroundColor: theme.colors.popover, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.xl, + ...theme.shadow.md, + zIndex: 1000, + }, +})); diff --git a/packages/app/src/components/ui/icon-button-chrome.ts b/packages/app/src/components/ui/icon-button-chrome.ts index 394a744e64..75735ac986 100644 --- a/packages/app/src/components/ui/icon-button-chrome.ts +++ b/packages/app/src/components/ui/icon-button-chrome.ts @@ -1,3 +1,4 @@ +import { createContext, useCallback, useContext } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; @@ -8,6 +9,8 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ export type IconButtonChromeSize = "large" | "small"; const SMALL_ICON_BUTTON_SIZE = 20; +/** Width and height of a large icon button on compact layouts. */ +export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32; const COMPACT_SMALL_ICON_BUTTON_SIZE = 32; export interface IconButtonChromeState { @@ -27,20 +30,53 @@ interface IconButtonChromeOptions { state?: IconButtonChromeState; compact?: boolean; disabled?: boolean; + /** A circular hitbox and highlight, for buttons inside a pill-shaped container. */ + round?: boolean; style?: StyleProp; } +const IconButtonChromeContext = createContext<"default" | "header-island">("default"); + +/** A container owns the chrome of its icon actions, including custom menu and badge triggers. */ +export const IconButtonChromeProvider = IconButtonChromeContext.Provider; + +/** The same state callback works with Pressable, TooltipTrigger, and DropdownMenuTrigger. */ +export function useIconButtonChromeStyle({ + size, + compact, + disabled, + round, + style, + active, +}: Omit & { active?: boolean }) { + const scope = useContext(IconButtonChromeContext); + return useCallback( + (state: IconButtonChromeState) => + iconButtonChromeStyle({ + size: scope === "header-island" ? "large" : size, + compact, + disabled, + round: scope === "header-island" || round, + style, + state: { ...state, active: active || state.active }, + }), + [scope, size, compact, disabled, round, style, active], + ); +} + /** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */ export function iconButtonChromeStyle({ size, state, compact = false, disabled = false, + round = false, style, }: IconButtonChromeOptions): StyleProp { const highlighted = state?.active || state?.hovered || state?.pressed || state?.open; return [ resolveIconButtonFrame(size, compact), + round ? styles.round : null, style, highlighted ? styles.highlighted : null, disabled ? styles.disabled : null, @@ -67,11 +103,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number { const styles = StyleSheet.create((theme) => ({ large: { width: { - xs: 32, + xs: COMPACT_LARGE_ICON_BUTTON_SIZE, md: HEADER_CONTROL_HEIGHT, }, height: { - xs: 32, + xs: COMPACT_LARGE_ICON_BUTTON_SIZE, md: HEADER_CONTROL_HEIGHT, }, padding: 0, @@ -104,6 +140,9 @@ const styles = StyleSheet.create((theme) => ({ outlineWidth: 0, outlineColor: "transparent", }, + round: { + borderRadius: theme.borderRadius.full, + }, highlighted: { backgroundColor: theme.colors.interactionHighlight, }, diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx new file mode 100644 index 0000000000..d107547dcf --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx @@ -0,0 +1,91 @@ +import { + BottomSheetModalInternalProvider, + INITIAL_CONTAINER_LAYOUT, + useBottomSheetModalInternal, +} from "@gorhom/bottom-sheet"; +import { PortalHost } from "@gorhom/portal"; +import { useCallback, useId, useMemo, useRef, type ReactNode } from "react"; +import { + Dimensions, + StatusBar, + StyleSheet, + View, + type LayoutChangeEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { useSharedValue } from "react-native-reanimated"; + +/** + * Hosts the sheets presented from inside it. + * + * A sheet's content normally lands at the app root, on the far side of the portal, where every + * context between the root and the call site is gone (floating-panels.md Gotcha 7). Inside a + * scope, a sheet renders into the scope's own host instead, so its content reads the scope's + * contexts — the navigator, the workspace providers — without bridging each one by hand. The + * scope only moves where the sheet draws: it still joins the app-wide sheet stack, so stacking, + * Back and the backdrop treat it like every other sheet. + * + * The host fills the scope, and the sheets presented in it size and dock to the scope, not the + * window. Sheets presented outside the scope keep drawing at the root, above it. + */ +export function SheetHostScope({ + children, + style, +}: { + children: ReactNode; + style?: StyleProp; +}) { + const parent = useBottomSheetModalInternal(); + const hostName = `sheet-host-${useId()}`; + const containerLayoutState = useSharedValue({ ...INITIAL_CONTAINER_LAYOUT }); + const hostRef = useRef(null); + + // Mirrors Gorhom's own hosting container measurement, so a sheet in the scope lays out against + // the scope the way a root sheet lays out against the window. + const handleLayout = useCallback( + ({ nativeEvent }: LayoutChangeEvent) => { + const { height } = nativeEvent.layout; + hostRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => { + const windowHeight = Dimensions.get("window").height; + const top = pageY ?? 0; + containerLayoutState.set({ + height, + offset: { + top, + bottom: Math.max(0, windowHeight - (top + height + (StatusBar.currentHeight ?? 0))), + left: 0, + right: 0, + }, + }); + }); + }, + [containerLayoutState], + ); + + const value = useMemo( + () => ({ ...parent, hostName, containerLayoutState }), + [containerLayoutState, hostName, parent], + ); + + return ( + + + {children} + + + + + + ); +} + +const styles = StyleSheet.create({ + scope: { + flex: 1, + minHeight: 0, + }, + host: { + ...StyleSheet.absoluteFillObject, + }, +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 3f9c9113d0..457181c53f 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -40,6 +40,13 @@ type IsolatedBottomSheetModalProps = Omit< BottomSheetModalProps, "enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent" > & { + /** + * Whether closing (drag, backdrop, Back) also dismisses, which unmounts the content. Default + * true: the owner hears `onDismiss` and drops its state. Pass `false` for a sheet whose close the + * owner may veto: it parks at index -1 with its content mounted, the owner hears `onChange(-1)`, + * and either snaps it back or unmounts it, which dismisses it from the stack. + */ + enableDismissOnClose?: boolean; /** * Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge * would have to reach around it. @@ -84,19 +91,24 @@ export const IsolatedBottomSheetModal = forwardRef< presentation = "push", contextBridge, backdropOpacity, + enableDismissOnClose = true, ...bottomSheetProps } = props; const renderBackdrop = useCallback( (backdropProps: BottomSheetBackdropProps) => ( - + ), - [backdropOpacity], + [backdropOpacity, enableDismissOnClose], ); const modal = ( @@ -152,3 +164,7 @@ export function useIsolatedBottomSheetVisibility({ handleSheetDismiss, }; } + +export { SheetVisibleFrame } from "./visible-frame"; +export { systemBackPress } from "./back-press"; +export { SheetHostScope } from "./host-scope"; diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx index 1232566462..8c85ef77ca 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx @@ -1,5 +1,15 @@ import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; -export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { +/** + * What a backdrop tap does. `dismiss` takes the top modal off the stack and unmounts it; `close` + * only slides this sheet down, so a sheet that keeps its content through a close can veto it. + */ +export type SheetBackdropPress = "dismiss" | "close"; + +export function SheetBackdrop({ + pressBehavior: _pressBehavior, + ...props +}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) { + // Gorhom's native backdrop closes its own sheet, which here is always the top one. return ; } diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx index ba1e397a9e..749d89359c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx @@ -1,16 +1,30 @@ import { BottomSheetBackdrop, + useBottomSheet, useBottomSheetModal, type BottomSheetBackdropProps, } from "@gorhom/bottom-sheet"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, StyleSheet, View } from "react-native"; +import type { SheetBackdropPress } from "./sheet-backdrop"; -export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { +export type { SheetBackdropPress }; + +export function SheetBackdrop({ + pressBehavior, + ...props +}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) { const { dismiss } = useBottomSheetModal(); + const { close } = useBottomSheet(); const { t } = useTranslation(); - const dismissTopSheet = useCallback(() => dismiss(), [dismiss]); + const handlePress = useCallback(() => { + // A sheet that may veto its close slides itself down and lets its owner decide. The other + // sheets ask the provider for the top modal, so a lower backdrop caught mid-transition still + // dismisses the sheet the user sees. + if (pressBehavior === "close") close(); + else dismiss(); + }, [close, dismiss, pressBehavior]); return ( @@ -28,7 +42,7 @@ export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: numb accessibilityLabel={t("common.bottomSheetBackdrop")} // The provider owns presentation order, including when a lower backdrop remounts. // Keep this target until the portal unmounts: animation must not expose the sheet below. - onPress={dismissTopSheet} + onPress={handlePress} style={StyleSheet.absoluteFill} /> diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts index 89ecce2b1e..60b386bd45 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts @@ -245,3 +245,12 @@ describe("bottom sheet visibility tracker", () => { expect(context.backPress.press()).toBe(false); }); }); + +it("does not cancel the owner's editor when the compact sheet leaves the tree on resize", () => { + const context = setup(); + openSheet(context); + context.tracker.attachController(null); + context.tracker.handleSheetDismiss(); + expect(context.closeCount()).toBe(0); + expect(context.backPress.press()).toBe(false); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts index 54ee6f58cd..dec2dd535c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts @@ -121,6 +121,9 @@ export function createBottomSheetVisibilityTracker(opts: { } }, handleSheetDismiss() { + // Portal cleanup reports dismissal after the sheet leaves the tree. It is + // presentation teardown, so the owner keeps its desired state. + if (!controller) return; if (visible) { setPhase("dismissing"); notifyClose(); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts new file mode 100644 index 0000000000..9e98bd359f --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout"; + +describe("getBottomSheetVisibleContentHeight", () => { + it("stops subtracting the retained keyboard height after the keyboard hides", () => { + const layout = { + containerHeight: 874, + contentPosition: 88, + handleHeight: 24, + keyboardHeight: 344, + }; + + expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418); + expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762); + }); +}); + +// Below the lowest snap the frame must travel with the sheet, not shrink to the screen edge. +describe("sheet footer motion", () => { + it("keeps the frame height fixed while opening and panning below the lowest snap", () => { + const layout = { + containerHeight: 1000, + handleHeight: 24, + keyboardHeight: 0, + isKeyboardVisible: false, + lowestDetentPosition: 400, + }; + const heights = [1000, 800, 600, 400].map((contentPosition) => + getBottomSheetVisibleContentHeight({ ...layout, contentPosition }), + ); + expect(heights).toEqual([576, 576, 576, 576]); + }); +}); + +it("resizes above the lowest snap and never returns negative or unmeasured heights", () => { + const layout = { + containerHeight: 1000, + handleHeight: 24, + keyboardHeight: 0, + isKeyboardVisible: false, + lowestDetentPosition: 400, + }; + expect(getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100 })).toBe(876); + expect( + getBottomSheetVisibleContentHeight({ + ...layout, + contentPosition: 100, + keyboardHeight: 950, + isKeyboardVisible: true, + }), + ).toBe(0); + expect( + getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, handleHeight: -999 }), + ).toBe(0); + expect( + getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, containerHeight: -999 }), + ).toBe(0); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts new file mode 100644 index 0000000000..316f56048a --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts @@ -0,0 +1,25 @@ +interface BottomSheetVisibleContentHeightInput { + containerHeight: number; + contentPosition: number; + lowestDetentPosition?: number; + handleHeight: number; + keyboardHeight: number; + isKeyboardVisible: boolean; +} + +export function getBottomSheetVisibleContentHeight({ + containerHeight, + contentPosition, + lowestDetentPosition, + handleHeight, + keyboardHeight, + isKeyboardVisible, +}: BottomSheetVisibleContentHeightInput): number { + "worklet"; + if (containerHeight < 0 || handleHeight < 0) return 0; + const position = Math.min(contentPosition, lowestDetentPosition ?? contentPosition); + return Math.max( + 0, + containerHeight - position - handleHeight - (isKeyboardVisible ? keyboardHeight : 0), + ); +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx new file mode 100644 index 0000000000..15ead99edf --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { View, StyleSheet } from "react-native"; +import { KEYBOARD_STATUS, useBottomSheetInternal } from "@gorhom/bottom-sheet"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout"; + +/** Fixed-detent sheet content. The footer shares the sheet's translation below its lowest snap. */ +export function SheetVisibleFrame({ + children, + footer, +}: { + children: ReactNode; + footer?: ReactNode; +}) { + const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } = + useBottomSheetInternal(); + const visibleStyle = useAnimatedStyle(() => { + const { containerHeight, handleHeight } = animatedLayoutState.get(); + const keyboard = animatedKeyboardState.get(); + return { + height: getBottomSheetVisibleContentHeight({ + containerHeight, + handleHeight, + contentPosition: animatedPosition.get(), + lowestDetentPosition: animatedDetentsState.get().detents?.[0], + keyboardHeight: keyboard.heightWithinContainer, + isKeyboardVisible: keyboard.status === KEYBOARD_STATUS.SHOWN, + }), + }; + }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); + return ( + + {children} + {footer} + + ); +} + +const styles = StyleSheet.create({ + frame: { minHeight: 0, overflow: "hidden" }, + body: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/components/ui/menu/index.ts b/packages/app/src/components/ui/menu/index.ts index c2114d1593..5668556899 100644 --- a/packages/app/src/components/ui/menu/index.ts +++ b/packages/app/src/components/ui/menu/index.ts @@ -35,4 +35,4 @@ export { type MenuPresentation, } from "./menu-context"; export { currentPageId, isSubPageOpen, MENU_ROOT_PATH, type MenuPath } from "./menu-navigation"; -export type { Alignment, Placement, Rect, Size } from "./menu-anchor"; +export type { Alignment, Placement, Rect, Size } from "../anchor"; diff --git a/packages/app/src/components/ui/menu/menu-context.ts b/packages/app/src/components/ui/menu/menu-context.ts index 8dceb70ec0..9635f09c9f 100644 --- a/packages/app/src/components/ui/menu/menu-context.ts +++ b/packages/app/src/components/ui/menu/menu-context.ts @@ -9,7 +9,7 @@ import { openSubPage, type MenuPath, } from "./menu-navigation"; -import type { Rect } from "./menu-anchor"; +import type { Rect } from "../anchor"; /** * How a menu draws itself once it is open. diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index b001c056fc..52afc71a77 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -38,7 +38,7 @@ import { type Placement, type Rect, type Size, -} from "./menu-anchor"; +} from "../anchor"; const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const; const CONTENT_ENTERING_DURATION_MS = 150; diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 0b5a6a77ac..2f73149244 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -26,7 +26,7 @@ import { MenuPage } from "./menu-item"; import { currentPageId, isSubPageOpen } from "./menu-navigation"; import { AnchoredSurface, MenuOverlay } from "./menu-overlay"; import { getMenuSheetBottomPadding } from "./menu-sheet-layout"; -import type { Alignment, Placement } from "./menu-anchor"; +import type { Alignment, Placement } from "../anchor"; import type { KeyboardFocusScope } from "@/keyboard/actions"; const ThemedChevronLeft = withUnistyles(ChevronLeft); @@ -126,8 +126,14 @@ export interface MenuSurfaceProps { * popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time. */ export function MenuSurface(props: MenuSurfaceProps): ReactElement | null { - const { presentation } = useMenuContext("MenuSurface"); + const { presentation, open, setOpen } = useMenuContext("MenuSurface"); const active = useRetainedPanelActive(); + // A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes + // for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet + // and promoted it to a tab, say, would otherwise bring the list back on the next tab switch. + useEffect(() => { + if (!active && open) setOpen(false); + }, [active, open, setOpen]); // Portals escape the hidden panel. Remove the surface in its inactive commit, // before freezing can suspend an async menu action's closing update. if (!active) return null; diff --git a/packages/app/src/components/ui/pane-content-toolbar.tsx b/packages/app/src/components/ui/pane-content-toolbar.tsx index 03634670d1..4ff947afc9 100644 --- a/packages/app/src/components/ui/pane-content-toolbar.tsx +++ b/packages/app/src/components/ui/pane-content-toolbar.tsx @@ -16,6 +16,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; import { iconButtonChromeGlyphSize, iconButtonChromeStyle, + useIconButtonChromeStyle, smallIconButtonChromeFrameSize, } from "@/components/ui/icon-button-chrome"; import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives"; @@ -60,6 +61,8 @@ interface ToolbarButtonCommonProps extends Omit< tooltip?: ReactNode; selected?: boolean; compact?: boolean; + /** A circular highlight, for buttons inside a pill-shaped container. */ + round?: boolean; shortcut?: ShortcutKey[][] | null; tooltipSide?: "left" | "right" | "top" | "bottom"; style?: StyleProp; @@ -81,6 +84,7 @@ export function ToolbarButton({ tooltip: tooltipBody, selected = false, compact = false, + round = false, shortcut, tooltipSide = "bottom", style, @@ -90,17 +94,14 @@ export function ToolbarButton({ testID, ...props }: ToolbarButtonProps) { - const buttonStyle = useMemo( - () => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) => - iconButtonChromeStyle({ - size: "small", - compact, - state: { ...state, active: selected }, - disabled: Boolean(disabled), - style, - }), - [compact, disabled, selected, style], - ); + const buttonStyle = useIconButtonChromeStyle({ + size: "small", + compact, + active: selected, + disabled: Boolean(disabled), + round, + style, + }); const accessibilityState = useMemo( () => ({ disabled: Boolean(disabled), selected }), [disabled, selected], diff --git a/packages/app/src/components/ui/text-input/text-input.native.test.tsx b/packages/app/src/components/ui/text-input/text-input.native.test.tsx index 4fa604d657..b52d7b9923 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.test.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.test.tsx @@ -56,6 +56,16 @@ afterEach(() => { function noop() {} +function createHandleRecorder() { + const handleRef = createRef(); + const received: (EditingTextInputHandle | null)[] = []; + function assignHandle(handle: EditingTextInputHandle | null) { + received.push(handle); + handleRef.current = handle; + } + return { handleRef, received, assignHandle }; +} + describe("EditingTextInputNative", () => { it("uses the bottom-sheet input only inside a bottom sheet", () => { act(() => { @@ -76,6 +86,32 @@ describe("EditingTextInputNative", () => { expect(bottomSheetTextInputRender.mock.calls[0]?.[0]).toMatchObject({ testID: "inside" }); }); + it("keeps its editing handle and selection when native editing rerenders the leaf", () => { + const { handleRef, received, assignHandle } = createHandleRecorder(); + act(() => + root?.render(), + ); + const input = container?.querySelector("textarea"); + if (!input) throw new Error("Expected native editing surface"); + const handle = handleRef.current; + act(() => { + handle?.focus(); + const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + if (!setValue) throw new Error("Expected input value setter"); + setValue.call(input, "native edit"); + input.setSelectionRange(2, 5); + input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "native edit" })); + }); + expect(handleRef.current).toBe(handle); + expect(received).toEqual([handle]); + expect(handle?.getText()).toBe("native edit"); + expect([input.selectionStart, input.selectionEnd]).toEqual([2, 5]); + expect(document.activeElement).toBe(input); + act(() => root?.unmount()); + root = null; + expect(received).toEqual([handle, null]); + }); + it("clears text via clear() when replaceText receives an empty string", () => { const handleRef = createRef(); @@ -105,12 +141,14 @@ describe("EditingTextInputNative", () => { ); }); const grownInput = container?.querySelector("input"); + const handle = handleRef.current; act(() => { handleRef.current?.reset(); }); expect(container?.querySelector("input")).not.toBe(grownInput); + expect(handleRef.current).toBe(handle); }); it("restores focus after replacing a reset native input", () => { diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index 72a15a9b8e..4ff773cefa 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -69,52 +69,63 @@ export const EditingTextInput = forwardRef ({ ...current, autoFocus })); }, []); - useImperativeHandle(ref, () => ({ - focus: () => { - if (isAwaitingReplacementRef.current) { - setReplacementFocus(true); - return; - } - inputRef.current?.focus(); - }, - blur: () => { - if (isAwaitingReplacementRef.current) { - setReplacementFocus(false); - return; - } - inputRef.current?.blur(); - }, - isFocused: () => inputRef.current?.isFocused?.() ?? false, - getText: () => textRef.current, - replaceText: (nextText, selection) => { - textRef.current = nextText; - if (inputRef.current?.replaceText) { - inputRef.current.replaceText(nextText, selection); - return; - } - if (nextText === "") { - inputRef.current?.clear?.(); - return; - } - inputRef.current?.setNativeProps?.({ - text: nextText, - ...(selection ? { selection } : {}), - }); - if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); - }, - reset: () => { - textRef.current = ""; - const autoFocus = inputRef.current?.isFocused?.() ?? false; - if (inputRef.current?.replaceText) { - inputRef.current.replaceText(""); - } else { - inputRef.current?.clear?.(); - } - isAwaitingReplacementRef.current = true; - setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); - }, - getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, - })); + useImperativeHandle( + ref, + () => ({ + focus: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(true); + return; + } + inputRef.current?.focus(); + }, + blur: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(false); + return; + } + inputRef.current?.blur(); + }, + isFocused: () => inputRef.current?.isFocused?.() ?? false, + getText: () => textRef.current, + replaceText: (nextText, selection) => { + textRef.current = nextText; + if (inputRef.current?.replaceText) { + inputRef.current.replaceText(nextText, selection); + return; + } + if (nextText === "") { + inputRef.current?.clear?.(); + return; + } + inputRef.current?.setNativeProps?.({ + text: nextText, + ...(selection ? { selection } : {}), + }); + if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); + }, + setSelection: (selection) => { + if (inputRef.current?.setSelection) { + inputRef.current.setSelection(selection.start, selection.end); + return; + } + inputRef.current?.setNativeProps?.({ selection }); + }, + reset: () => { + textRef.current = ""; + const autoFocus = inputRef.current?.isFocused?.() ?? false; + if (inputRef.current?.replaceText) { + inputRef.current.replaceText(""); + } else { + inputRef.current?.clear?.(); + } + isAwaitingReplacementRef.current = true; + setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); + }, + getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, + }), + [setReplacementFocus], + ); const handleChangeText = useCallback( (nextText: string) => { diff --git a/packages/app/src/components/ui/text-input/text-input.web.tsx b/packages/app/src/components/ui/text-input/text-input.web.tsx index 100ae185df..021e3b8e0c 100644 --- a/packages/app/src/components/ui/text-input/text-input.web.tsx +++ b/packages/app/src/components/ui/text-input/text-input.web.tsx @@ -57,31 +57,39 @@ export const EditingTextInput = forwardRef ({ - focus: () => inputRef.current?.focus(), - blur: () => inputRef.current?.blur(), - isFocused: () => document.activeElement === inputRef.current, - getText: () => { - const input = inputRef.current as WebTextInputElement | null; - const nextText = input?.value ?? textRef.current; - textRef.current = nextText; - return nextText; - }, - replaceText: (nextText, selection) => { - textRef.current = nextText; - const input = inputRef.current as WebTextInputElement | null; - if (input && "value" in input) input.value = nextText; - if (selection && typeof input?.setSelectionRange === "function") { - input.setSelectionRange(selection.start, selection.end); - } - }, - reset: () => { - textRef.current = ""; - const input = inputRef.current as WebTextInputElement | null; - if (input && "value" in input) input.value = ""; - }, - getNativeRef: () => inputRef.current, - })); + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + isFocused: () => document.activeElement === inputRef.current, + getText: () => { + const input = inputRef.current as WebTextInputElement | null; + const nextText = input?.value ?? textRef.current; + textRef.current = nextText; + return nextText; + }, + replaceText: (nextText, selection) => { + textRef.current = nextText; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = nextText; + if (selection && typeof input?.setSelectionRange === "function") { + input.setSelectionRange(selection.start, selection.end); + } + }, + setSelection: (selection) => { + const input = inputRef.current as WebTextInputElement | null; + input?.setSelectionRange?.(selection.start, selection.end); + }, + reset: () => { + textRef.current = ""; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = ""; + }, + getNativeRef: () => inputRef.current, + }), + [], + ); return ( { - return new Promise((resolve) => { - element.measureInWindow((x, y, width, height) => { - resolve({ x, y, width, height }); - }); - }); -} - -function computeHoverCardPosition({ - triggerRect, - contentSize, - displayArea, - offset, -}: { - triggerRect: Rect; - contentSize: { width: number; height: number }; - displayArea: Rect; - offset: number; -}): { x: number; y: number } { - let x = triggerRect.x + triggerRect.width + offset; - let y = triggerRect.y; - - // If it overflows right, try left - if (x + contentSize.width > displayArea.width - 8) { - x = triggerRect.x - contentSize.width - offset; - } - - // Constrain to screen - const padding = 8; - x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x)); - y = Math.max( - displayArea.y + padding, - Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y), - ); - - return { x, y }; -} - -const HOVER_GRACE_MS = 100; const HOVER_CARD_WIDTH = 260; interface WorkspaceHoverCardProps { @@ -110,237 +56,80 @@ export function WorkspaceHoverCard({ disabled = false, children, }: PropsWithChildren): ReactNode { + const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); - if (!isWeb || isCompact) { + if (isCompact) { return children; } return ( - - {children} - - ); -} - -function WorkspaceHoverCardDesktop({ - workspace, - prHint, - isDragging, - disabled = false, - children, -}: PropsWithChildren): ReactElement { - const triggerRef = useRef(null); - const contentRef = useRef(null); - const [open, setOpen] = useState(false); - const graceTimerRef = useRef | null>(null); - const triggerHoveredRef = useRef(false); - - const clearGraceTimer = useCallback(() => { - if (graceTimerRef.current) { - clearTimeout(graceTimerRef.current); - graceTimerRef.current = null; - } - }, []); - - const scheduleClose = useCallback(() => { - if (graceTimerRef.current) return; - graceTimerRef.current = setTimeout(() => { - graceTimerRef.current = null; - setOpen(false); - }, HOVER_GRACE_MS); - }, []); - - const handleTriggerEnter = useCallback(() => { - triggerHoveredRef.current = true; - clearGraceTimer(); - if (!isDragging && !disabled) { - setOpen(true); - } - }, [clearGraceTimer, disabled, isDragging]); - - const handleTriggerLeave = useCallback(() => { - triggerHoveredRef.current = false; - scheduleClose(); - }, [scheduleClose]); - - // While open, the safe zone covers trigger + content + the bridge between - // them. Close only fires when the pointer leaves the safe zone; re-entering - // it (including the bridge) cancels the pending close. - useHoverSafeZone({ - enabled: open, - triggerRef, - contentRef, - onEnterSafeZone: clearGraceTimer, - onLeaveSafeZone: scheduleClose, - }); - - // Close while another row interaction owns attention. - useEffect(() => { - if (isDragging || disabled) { - clearGraceTimer(); - setOpen(false); - } - }, [clearGraceTimer, disabled, isDragging]); - - // Cleanup on unmount - useEffect(() => { - return () => { - clearGraceTimer(); - }; - }, [clearGraceTimer]); - - return ( - - {children} - {open ? ( - - ) : null} - + + {children} + + + + ); } function WorkspaceHoverCardContent({ workspace, prHint, - triggerRef, - contentRef, }: { workspace: SidebarWorkspaceEntry; prHint: PrHint | null; - triggerRef: React.RefObject; - contentRef: React.RefObject; -}): ReactElement | null { +}): ReactElement { const { t } = useTranslation(); - const bottomSheetInternal = useBottomSheetModalInternal(true); - const [triggerRect, setTriggerRect] = useState(null); - const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); - const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - - // Measure trigger — same pattern as tooltip.tsx - useEffect(() => { - if (!triggerRef.current) return; - - let cancelled = false; - measureElement(triggerRef.current).then((rect) => { - if (cancelled) return; - setTriggerRect(rect); - return; - }); - - return () => { - cancelled = true; - }; - }, [triggerRef]); - - // Compute position when both measurements are available - useEffect(() => { - if (!triggerRect || !contentSize) return; - const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); - const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight }; - const result = computeHoverCardPosition({ - triggerRect, - contentSize, - displayArea, - offset: 4, - }); - setPosition(result); - }, [triggerRect, contentSize]); - - const handleLayout = useCallback( - (event: { nativeEvent: { layout: { width: number; height: number } } }) => { - const { width, height } = event.nativeEvent.layout; - setContentSize({ width, height }); - }, - [], - ); - - const frameStyle = useMemo( - () => ({ - position: "absolute" as const, - top: position?.y ?? -9999, - left: position?.x ?? -9999, - }), - [position?.x, position?.y], - ); - return ( - - - - - - {workspace.name} - - - {prHint ? : null} - {workspace.diffStat ? ( - - - - - ) : null} - - {workspace.currentBranch ? ( - - ) : null} - {workspace.workspaceDirectoryLabel ? ( - - ) : null} - {prHint?.checks && prHint.checks.length > 0 ? ( - <> - - - - ) : null} - + <> + + + {workspace.name} + - + {prHint ? : null} + {workspace.diffStat ? ( + + + + + ) : null} + + {workspace.currentBranch ? ( + + ) : null} + {workspace.workspaceDirectoryLabel ? ( + + ) : null} + {prHint?.checks && prHint.checks.length > 0 ? ( + <> + + + + ) : null} + ); } @@ -561,14 +350,6 @@ function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; ho } const styles = StyleSheet.create((theme) => ({ - portalOverlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - zIndex: 1000, - }, card: { backgroundColor: theme.colors.surface1, borderWidth: 1, @@ -581,7 +362,6 @@ const styles = StyleSheet.create((theme) => ({ shadowOpacity: 0.2, shadowRadius: 8, elevation: 8, - zIndex: 1000, }, cardHeader: { flexDirection: "row", diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 1954394176..3a326aed3d 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -125,8 +125,7 @@ function buildCreateAgentOptions({ provider, }: { composerState: { - modeOptions: { id: string }[]; - selectedMode: string; + effectiveModeId: string; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; }; @@ -137,20 +136,11 @@ function buildCreateAgentOptions({ workspaceId: string; provider: CreateAgentRequestOptions["provider"]; }): CreateAgentRequestOptions { - // Reconcile the selected mode against the discovered modes. The mode picker - // shows modeOptions[0] when the stored mode isn't in the list (e.g. a stale - // globally-remembered mode this workspace's provider config no longer - // defines), so the submitted mode must match that display rather than send a - // stale mode the provider would reject. - const modeOptionIds = composerState.modeOptions.map((mode) => mode.id); - const reconciledMode = modeOptionIds.includes(composerState.selectedMode) - ? composerState.selectedMode - : (modeOptionIds[0] ?? ""); return { provider, cwd: workspaceDirectory, workspaceId, - ...(reconciledMode !== "" ? { modeId: reconciledMode } : {}), + ...(composerState.effectiveModeId ? { modeId: composerState.effectiveModeId } : {}), ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), ...(composerState.effectiveThinkingOptionId ? { thinkingOptionId: composerState.effectiveThinkingOptionId } diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index d5f09dc77f..72795289cf 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -55,6 +55,7 @@ import type { import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getFeatureHighlightColor, + isFeatureActive, getFeatureTooltip, getAgentControlHintKey, resolveAgentModelSelection, @@ -211,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) { } function getFeatureIconColor( - featureId: string, - enabled: boolean, + feature: AgentFeature, palette: { blue: { 400: string }; green: { 400: string }; @@ -220,11 +220,11 @@ function getFeatureIconColor( }, foregroundMuted: string, ): string { - if (!enabled) { + if (!isFeatureActive(feature)) { return foregroundMuted; } - switch (getFeatureHighlightColor(featureId)) { + switch (getFeatureHighlightColor(feature.id)) { case "blue": return palette.blue[400]; case "green": @@ -1335,8 +1335,7 @@ function DesktopFeatureItem({ @@ -346,7 +345,6 @@ export function CompactModelSheet({ onClose={closeModelBrowser} snapPoints={SNAP_POINTS} scrollable={false} - sizeContentToCurrentSnapPoint presentation="push" contentStyle={styles.sheetBody} testID="agent-controls-model-browser-sheet" diff --git a/packages/app/src/composer/agent-controls/utils.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts index 1c91d8ce79..dfacec0ec7 100644 --- a/packages/app/src/composer/agent-controls/utils.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -3,6 +3,7 @@ import { getFeatureHighlightColor, getFeatureTooltip, getAgentControlHintKey, + isFeatureActive, normalizeModelId, resolveAgentModelSelection, } from "./utils"; @@ -35,9 +36,32 @@ describe("feature metadata helpers", () => { it("maps feature highlight colors by feature id", () => { expect(getFeatureHighlightColor("fast_mode")).toBe("yellow"); + expect(getFeatureHighlightColor("service_tier")).toBe("yellow"); expect(getFeatureHighlightColor("plan_mode")).toBe("blue"); expect(getFeatureHighlightColor("other")).toBe("default"); }); + + it("treats a select feature as active when a non-default option is selected", () => { + const speed = { + type: "select" as const, + id: "service_tier", + label: "Speed", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "fast", label: "Fast" }, + ], + }; + expect(isFeatureActive({ ...speed, value: "default" })).toBe(false); + expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true); + expect( + isFeatureActive({ + type: "toggle", + id: "fast_mode", + label: "Fast", + value: true, + }), + ).toBe(true); + }); }); describe("normalizeModelId", () => { diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index 631b55753e..c0b1da7c47 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -1,7 +1,11 @@ import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; import { i18n } from "@/i18n/i18next"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; -import { FAST_MODE_FEATURE_ID, PLAN_MODE_FEATURE_ID } from "@/agent-controls/policy"; +import { + FAST_MODE_FEATURE_ID, + PLAN_MODE_FEATURE_ID, + SPEED_FEATURE_ID, +} from "@/agent-controls/policy"; export type ExplainedAgentControl = "mode" | "model" | "thinking"; export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow"; @@ -35,9 +39,16 @@ export function getFeatureTooltip(feature: Pick option.id === feature.value); + return selectedOption !== undefined && !selectedOption.isDefault; +} + export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor { switch (featureId) { case FAST_MODE_FEATURE_ID: + case SPEED_FEATURE_ID: return "yellow"; case "auto_accept": return "green"; diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index 91bb98e7a8..4b6407c872 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -1,23 +1,30 @@ import { ComposerDockBackground } from "./internal/background"; export { ComposerDockBackground } from "./internal/background"; +export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen"; import { ScrollView } from "@/components/ui/scroll-view"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings"; import { KeyboardTranslateView } from "@/keyboard/shift"; -import { createContext, useCallback, useContext, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native"; +import { StyleSheet as ThemedStyleSheet } from "react-native-unistyles"; import Animated, { useAnimatedStyle, useSharedValue, - useDerivedValue, useAnimatedReaction, type SharedValue, } from "react-native-reanimated"; import { useKeyboardShift } from "@/keyboard/shift"; -import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity"; +import { + resolveCenteredClearance, + resolveComposerSlotCapacity, + updateComposerCapacity, + type ComposerCapacity, +} from "./internal/capacity"; -const ViewportCapacity = createContext | null>(null); +const ViewportSizing = createContext | null>(null); interface ComposerViewportProps extends ViewProps { bottomInset?: number; @@ -34,11 +41,12 @@ function ComposerViewport({ }: ComposerViewportProps) { const measuredHeight = useSharedValue(0); const sizing = useSharedValue(undefined); - const { layoutShift } = useKeyboardShift(); + const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift(); useAnimatedReaction( () => ({ height: measuredHeight.value, bottomInset, + safeAreaBottom: safeAreaBottom.value, keyboardShift: layoutShift.value, centered, }), @@ -48,7 +56,6 @@ function ComposerViewport({ }, [], ); - const capacity = useDerivedValue(() => sizing.value?.capacity); const measureViewport = useCallback( (event: LayoutChangeEvent) => { measuredHeight.value = event.nativeEvent.layout.height; @@ -59,19 +66,24 @@ function ComposerViewport({ return ( - {children} + {children} ); } /** Bound the complete composer (including its controls), not just the text input. */ -function ComposerViewportContent({ style, ...props }: ViewProps) { - const capacity = useContext(ViewportCapacity); - if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport"); +function ComposerViewportContent({ + style, + fullscreen = false, + ...props +}: ViewProps & { fullscreen?: boolean }) { + const compact = useIsCompactFormFactor(); + const sizing = useContext(ViewportSizing); + if (!sizing) throw new Error("ComposerViewportContent requires ComposerViewport"); const constraint = useAnimatedStyle(() => ({ // Only viewport changes and keyboard start/end events trigger layout. Motion // stays in KeyboardTranslateView and never changes this constraint per frame. - maxHeight: capacity.value, + maxHeight: resolveComposerSlotCapacity(sizing.value, { compact, fullscreen }), })); return ( @@ -79,34 +91,92 @@ function ComposerViewportContent({ style, ...props }: ViewProps) { ); } +/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */ +function useCenteredClearance(safeAreaBottom: number) { + const [viewportHeight, setViewportHeight] = useState(0); + const [formBottom, setFormBottom] = useState(0); + const measureViewport = useCallback((event: LayoutChangeEvent) => { + setViewportHeight(event.nativeEvent.layout.height); + }, []); + const measureForm = useCallback((event: LayoutChangeEvent) => { + const { y, height } = event.nativeEvent.layout; + setFormBottom(y + height); + }, []); + return { + value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }), + measureViewport, + measureForm, + }; +} + interface ComposerDockProps { children: [ReactNode, ReactNode, ReactNode?]; centered?: boolean; + /** + * Fullscreen is a layout mode of the same mounted composer: the slot fills the + * stationary viewport over the content and lays out the keyboard itself, so the + * surface stops translating. The screen that owns the chrome hides it. + */ + fullscreen?: boolean; } /** The stationary viewport, translated surface, and bounded composer are one owner. */ export function ComposerDock({ children: [content, composer, overlay], centered = false, + fullscreen = false, }: ComposerDockProps) { const insets = useSafeAreaInsets(); const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings); + const centeredClearance = useCenteredClearance(insets.bottom); // Preserve the existing centered form's visual balance on tablets. - const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0; + const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0; + const fullscreenSurface = [ + themedStyles.fullscreenComposer, + { + paddingTop: insets.top, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + paddingRight: insets.right, + }, + ]; if (centered) { + // Same tree in both modes. Fullscreen stops centering and translating, hides the + // setup fields, and lets the composer slot take the whole stationary viewport. return ( - - - + + + {content} - - {composer} + + {composer} {overlay} @@ -116,7 +186,7 @@ export function ComposerDock({ } return ( - + - - {composer} + + + {composer} + {overlay} @@ -146,9 +227,16 @@ const dockStyles = StyleSheet.create({ surface: { flex: 1 }, content: { flex: 1, justifyContent: "flex-end" }, composer: { width: "100%", flexShrink: 1 }, + fullscreenSlot: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" }, centered: { flexShrink: 1, width: "100%" }, // Reserve the composer's own capped height before the setup scroll view shrinks. centeredComposer: { width: "100%", flexShrink: 0 }, setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, + hiddenSetup: { display: "none" }, }); + +// The fullscreen composer paints its own surface, including under the safe areas. +const themedStyles = ThemedStyleSheet.create((theme) => ({ + fullscreenComposer: { flex: 1, backgroundColor: theme.colors.surface1 }, +})); diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx index d39439f325..421002352e 100644 --- a/packages/app/src/composer/dock/index.tsx +++ b/packages/app/src/composer/dock/index.tsx @@ -1,36 +1,69 @@ export { ComposerDockBackground } from "./internal/background"; +export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen"; import { ScrollView } from "@/components/ui/scroll-view"; -import type { ReactNode } from "react"; +import { useState, useCallback, useMemo, useRef, type ReactNode } from "react"; import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { resolveInlineComposerCapacity } from "./internal/capacity"; +import { useFullscreenSlotFrame } from "./internal/fullscreen"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; interface ComposerDockProps { children: [ReactNode, ReactNode, ReactNode?]; centered?: boolean; + /** See the native dock: the same mounted composer fills the visible surface. */ + fullscreen?: boolean; } export function ComposerDock({ children: [content, composer, overlay], centered = false, + fullscreen = false, }: ComposerDockProps) { + const compact = useIsCompactFormFactor(); + const [surfaceSize, setSurfaceSize] = useState<{ width: number; height: number }>(); + const surfaceRef = useRef(null); + const fullscreenFrame = useFullscreenSlotFrame(fullscreen, surfaceRef, surfaceSize); + const constraint = useMemo( + () => ({ maxHeight: resolveInlineComposerCapacity(surfaceSize?.height, compact) }), + [surfaceSize?.height, compact], + ); + const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + if (height > 0) setSurfaceSize({ width, height }); + }, []); if (centered) { return ( - + {content} - {composer} + + {composer} + {overlay} ); } return ( - + {content} - {composer} + + {composer} + {overlay} ); @@ -40,6 +73,10 @@ const styles = StyleSheet.create((theme) => ({ surface: { flex: 1, overflow: "hidden" }, content: { flex: 1, justifyContent: "flex-end" }, composer: { width: "100%", flexShrink: 1 }, + // Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor + // never reaches the zoomed visual viewport, while a fixed box chains to the viewport + // directly. React Native Web renders `fixed`; React Native's style type omits it. + fullscreenComposer: { position: "fixed" as "absolute" }, centered: { flex: 1, alignItems: "center", diff --git a/packages/app/src/composer/dock/internal/capacity.test.ts b/packages/app/src/composer/dock/internal/capacity.test.ts index d6fa3f9606..44e79e6ef9 100644 --- a/packages/app/src/composer/dock/internal/capacity.test.ts +++ b/packages/app/src/composer/dock/internal/capacity.test.ts @@ -1,9 +1,41 @@ import { describe, expect, it } from "vitest"; -import { resolveComposerCapacity, updateComposerCapacity } from "./capacity"; +import { + resolveCenteredClearance, + resolveComposerSlotCapacity, + resolveInlineComposerCapacity, + resolveComposerCapacity, + updateComposerCapacity, +} from "./capacity"; describe("composer viewport", () => { + it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => { + const open = updateComposerCapacity(undefined, { + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 308, + }); + expect(resolveInlineComposerCapacity(open.capacity, true)).toBe(147); + expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245); + expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined(); + }); + it("lets a fullscreen composer fill the viewport while inline keeps its cap", () => { + const open = updateComposerCapacity(undefined, { + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 308, + }); + expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: true })).toBe(582); + expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: false })).toBe(147); + expect(resolveComposerSlotCapacity(undefined, { compact: true, fullscreen: true })).toBe( + undefined, + ); + }); it("preserves the editing capacity when the keyboard closes", () => { - const viewport = { height: 582, bottomInset: 24, centered: false }; + const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false }; const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }); const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }); expect(open.capacity).toBe(245); @@ -15,6 +47,7 @@ describe("composer viewport", () => { const open = updateComposerCapacity(undefined, { height: 582, bottomInset: 24, + safeAreaBottom: 0, centered: false, keyboardShift: 308, }); @@ -22,6 +55,7 @@ describe("composer viewport", () => { updateComposerCapacity(open, { height: 650, bottomInset: 24, + safeAreaBottom: 0, centered: false, keyboardShift: 0, }).capacity, @@ -30,6 +64,7 @@ describe("composer viewport", () => { updateComposerCapacity(open, { height: 0, bottomInset: 24, + safeAreaBottom: 0, centered: false, keyboardShift: 0, }), @@ -39,6 +74,7 @@ describe("composer viewport", () => { const height = resolveComposerCapacity({ height: 582, bottomInset: 24, + safeAreaBottom: 0, keyboardShift: 308, centered: false, }); @@ -46,23 +82,80 @@ describe("composer viewport", () => { expect(582 - 24 - 308 - height).toBe(5); }); - it("keeps a centered tablet form below the header after translation", () => { + it("keeps a centered tablet form below the header once the keyboard reaches it", () => { + const viewport = 1000; + const bottomInset = 80; + const keyboardShift = 300; const height = resolveComposerCapacity({ - height: 1000, - bottomInset: 80, - keyboardShift: 300, + height: viewport, + bottomInset, + safeAreaBottom: 0, + keyboardShift, centered: true, }); - expect(height).toBe(310); - expect((1000 - 80 - height) / 2 - 300).toBe(5); + expect(height).toBe(695); + const restingTop = (viewport - bottomInset - height) / 2; + const bottomClearance = resolveCenteredClearance({ + viewportHeight: viewport, + safeAreaBottom: 0, + formBottom: restingTop + height, + }); + expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5); + }); + + it("leaves a centered form room to edit on a short foldable viewport", () => { + expect( + resolveComposerCapacity({ + height: 725, + bottomInset: 72, + safeAreaBottom: 0, + keyboardShift: 313, + centered: true, + }), + ).toBe(407); + }); + + it("rests a centered form on the keyboard above the bottom safe area", () => { + // The keyboard shift excludes the safe area that the keyboard also covers. + const geometry = { + height: 660, + bottomInset: 60, + safeAreaBottom: 32, + keyboardShift: 288, + centered: true, + }; + const height = resolveComposerCapacity(geometry); + const restingTop = (geometry.height - geometry.bottomInset - height) / 2; + const formBottom = restingTop + height; + const clearance = resolveCenteredClearance({ + viewportHeight: geometry.height, + safeAreaBottom: geometry.safeAreaBottom, + formBottom, + }); + const translation = Math.max(0, geometry.keyboardShift - clearance); + const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift; + expect(formBottom - translation).toBe(keyboardTop); + expect(restingTop - translation).toBe(5); }); it("uses the measured viewport before the first keyboard opening", () => { expect( - resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }), + resolveComposerCapacity({ + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + keyboardShift: 0, + centered: false, + }), ).toBe(553); expect( - resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }), + resolveComposerCapacity({ + height: 300, + bottomInset: 0, + safeAreaBottom: 0, + keyboardShift: 200, + centered: false, + }), ).toBe(95); }); }); diff --git a/packages/app/src/composer/dock/internal/capacity.ts b/packages/app/src/composer/dock/internal/capacity.ts index d42537579e..9c460e1729 100644 --- a/packages/app/src/composer/dock/internal/capacity.ts +++ b/packages/app/src/composer/dock/internal/capacity.ts @@ -1,6 +1,8 @@ interface ComposerGeometry { height: number; bottomInset: number; + /** The keyboard also covers this space below its shift. */ + safeAreaBottom: number; keyboardShift: number; centered: boolean; } @@ -8,15 +10,31 @@ interface ComposerGeometry { export interface ComposerCapacity { keyboardReserve: number; capacity: number; + /** The stationary viewport; a fullscreen composer fills it and lays out the keyboard itself. */ + viewportHeight: number; } export function resolveComposerCapacity(input: ComposerGeometry): number { "worklet"; - // A centered form grows upward by half its height. Reserve both halves so - // translating it still leaves five layout points below the header. - const clearance = input.keyboardShift + 5; - const reservedSpace = input.centered ? clearance * 2 : clearance; - return Math.max(0, input.height - input.bottomInset - reservedSpace); + // A centered form only translates by the keyboard overlap, so once the keyboard + // reaches it the form sits on the keyboard and fits above it, not twice over. + // A bottom-anchored composer pads the safe area inside its own capacity. + const available = input.centered + ? Math.min( + input.height - input.bottomInset, + input.height - input.safeAreaBottom - input.keyboardShift, + ) + : input.height - input.bottomInset - input.keyboardShift; + return Math.max(0, available - 5); +} + +/** Space below a centered form that the keyboard fills before the form moves. */ +export function resolveCenteredClearance(input: { + viewportHeight: number; + safeAreaBottom: number; + formBottom: number; +}): number { + return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom); } export function updateComposerCapacity( @@ -32,5 +50,32 @@ export function updateComposerCapacity( return { keyboardReserve, capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }), + viewportHeight: input.height, }; } + +/** Compact editing leaves conversation space above the complete composer. */ +export function resolveInlineComposerCapacity( + capacity: number | undefined, + compact: boolean, +): number | undefined { + "worklet"; + if (capacity === undefined) return undefined; + return compact ? capacity * 0.6 : capacity; +} + +export interface ComposerSlotLayout { + compact: boolean; + fullscreen: boolean; +} + +/** The bound on the composer slot: inline capacity, or the whole viewport in fullscreen. */ +export function resolveComposerSlotCapacity( + sizing: ComposerCapacity | undefined, + layout: ComposerSlotLayout, +): number | undefined { + "worklet"; + if (sizing === undefined) return undefined; + if (layout.fullscreen) return sizing.viewportHeight; + return resolveInlineComposerCapacity(sizing.capacity, layout.compact); +} diff --git a/packages/app/src/composer/dock/internal/fullscreen.native.tsx b/packages/app/src/composer/dock/internal/fullscreen.native.tsx new file mode 100644 index 0000000000..9f27befa4c --- /dev/null +++ b/packages/app/src/composer/dock/internal/fullscreen.native.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { StyleSheet, type ViewProps } from "react-native"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { KeyboardTranslateView, useKeyboardShift } from "@/keyboard/shift"; + +/** + * The editing region of a composer. Inline it only continues the shrink chain. In + * fullscreen it fills the stationary frame and reserves the keyboard's space at + * motion boundaries, the same boundary-time layout the dock capacity uses: the + * keyboard start/end events write `layoutShift`; frames in between move the footer. + */ +export function ComposerFullscreenBody({ + fullscreen, + children, +}: { + fullscreen: boolean; + children: ReactNode; +}) { + const { layoutShift } = useKeyboardShift(); + const reservation = useAnimatedStyle(() => ({ + marginBottom: fullscreen ? layoutShift.value : 0, + })); + return ( + + {children} + + ); +} + +/** The Send row of a fullscreen composer rides the keyboard like the inline dock surface. */ +export function ComposerFullscreenFooter({ children, ...props }: ViewProps) { + return {children}; +} + +// Plain React Native styles: these land on Reanimated views (docs/unistyles.md). +const styles = StyleSheet.create({ + inline: { flexShrink: 1 }, + fullscreen: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/composer/dock/internal/fullscreen.tsx b/packages/app/src/composer/dock/internal/fullscreen.tsx new file mode 100644 index 0000000000..36291181c1 --- /dev/null +++ b/packages/app/src/composer/dock/internal/fullscreen.tsx @@ -0,0 +1,89 @@ +import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; +import { StyleSheet, View, type ViewProps } from "react-native"; + +/** The editing region of a composer; see the native file for the keyboard contract. */ +export function ComposerFullscreenBody({ + fullscreen, + children, +}: { + fullscreen: boolean; + children: ReactNode; +}) { + return {children}; +} + +export function ComposerFullscreenFooter({ children, ...props }: ViewProps) { + return {children}; +} + +export interface ComposerSlotFrame { + top: number; + left: number; + width: number; + height: number; +} + +/** + * Where a fullscreen composer sits, in layout-viewport coordinates for a fixed box: the + * part of the dock surface the browser's visible viewport shows. A mobile browser + * keyboard shrinks or pans that viewport without resizing the page, so the frame + * follows it instead of the layout. + */ +export function useFullscreenSlotFrame( + active: boolean, + surfaceRef: RefObject, + surfaceSize: { width: number; height: number } | undefined, +): ComposerSlotFrame | undefined { + const [frame, setFrame] = useState(); + useLayoutEffect(() => { + if (!active) { + setFrame(undefined); + return; + } + const update = () => { + const surface = surfaceRef.current as unknown as HTMLElement | null; + if (!surface) return; + setFrame(resolveVisibleSurfaceFrame(surface.getBoundingClientRect(), readVisibleViewport())); + }; + const visualViewport = window.visualViewport; + update(); + visualViewport?.addEventListener("resize", update); + visualViewport?.addEventListener("scroll", update); + window.addEventListener("resize", update); + return () => { + visualViewport?.removeEventListener("resize", update); + visualViewport?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, [active, surfaceRef, surfaceSize]); + return frame; +} + +function readVisibleViewport() { + const viewport = window.visualViewport; + return { + top: viewport?.offsetTop ?? 0, + left: viewport?.offsetLeft ?? 0, + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight, + }; +} + +function resolveVisibleSurfaceFrame( + surface: { top: number; left: number; right: number; bottom: number }, + viewport: { top: number; left: number; width: number; height: number }, +): ComposerSlotFrame { + const top = Math.max(viewport.top, surface.top); + const left = Math.max(viewport.left, surface.left); + return { + top, + left, + width: Math.max(0, Math.min(viewport.left + viewport.width, surface.right) - left), + height: Math.max(0, Math.min(viewport.top + viewport.height, surface.bottom) - top), + }; +} + +const styles = StyleSheet.create({ + inline: { flexShrink: 1 }, + fullscreen: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 33041cceb6..8bc31bb176 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -210,6 +210,8 @@ describe("useAgentInputDraft live contract", () => { }); const hydratedTextReplacement = getLatest().textReplacement; + // The editor applies a retained caret to the hydrated text, so hydration announces itself. + expect(hydratedTextReplacement.kind).toBe("hydrated"); await act(async () => { getLatest().editText("hello world"); @@ -223,6 +225,7 @@ describe("useAgentInputDraft live contract", () => { }); expect(getLatest().textReplacement).not.toBe(hydratedTextReplacement); + expect(getLatest().textReplacement.kind).toBe("replace"); expect(getLatest().textReplacement.text).toBe("replacement text"); await act(async () => { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 0812fb280d..c14db9a119 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -17,6 +17,7 @@ import { } from "@/composer/draft/input-draft-core"; import { buildDraftCommandTarget, + resolveEffectiveComposerModeId, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, type ProviderSelectionState, @@ -46,6 +47,7 @@ interface UseAgentInputDraftInput { type DraftComposerState = UseAgentFormStateResult & { workingDir: string; + effectiveModeId: string; effectiveModelId: string; effectiveThinkingOptionId: string; featureValues: Record | undefined; @@ -115,14 +117,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const isHydrated = hydratedDraftKey === draftKey; const textReplacementRevisionRef = useRef(0); const [textReplacement, setTextReplacement] = useState(() => ({ + kind: "initial", key: `${draftKey}:0`, text: textSource.getSnapshot(), })); const publishTextReplacement = useCallback( - (nextText: string) => { + (kind: "hydrated" | "replace", nextText: string) => { textReplacementRevisionRef.current += 1; setTextReplacement({ + kind, key: `${draftKey}:${textReplacementRevisionRef.current}`, text: nextText, }); @@ -172,7 +176,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr (nextText: string) => { textPublication.cancel(); useDraftStore.getState().editDraftText({ draftKey, text: nextText }); - publishTextReplacement(nextText); + publishTextReplacement("replace", nextText); }, [draftKey, publishTextReplacement, textPublication], ); @@ -225,7 +229,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr await useDraftStore.getState().hydrateDraftInput({ draftKey }); if (!cancelled) { const hydratedText = useDraftStore.getState().getDraftInput(draftKey)?.text ?? ""; - publishTextReplacement(hydratedText); + publishTextReplacement("hydrated", hydratedText); setHydratedDraftKey(draftKey); } })(); @@ -254,6 +258,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ], ); + const effectiveModeId = useMemo( + () => resolveEffectiveComposerModeId(providerSelection, formState.isModelLoading), + [formState.isModelLoading, providerSelection], + ); + const effectiveModelId = useMemo( () => resolveEffectiveComposerModelId(providerSelection), [providerSelection], @@ -313,6 +322,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return { ...formState, workingDir, + effectiveModeId, effectiveModelId, effectiveThinkingOptionId, featureValues: draftFeatureValues, @@ -327,6 +337,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }, [ commandDraft, composerOptions, + effectiveModeId, effectiveModelId, effectiveThinkingOptionId, draftFeatures, @@ -353,6 +364,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr export const __private__ = { resolveDraftKey, + resolveEffectiveComposerModeId, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, buildDraftCommandTarget, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 6e9c45eab5..b54b344a36 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -6,6 +6,7 @@ import { ComposerDock } from "@/composer/dock"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; +import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { ComposerImportPill } from "@/composer/draft/import-pill"; import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles"; @@ -86,46 +87,30 @@ function resolveAutoSubmitConfig( }; } -// Reconcile the form's selected mode against the currently discovered modes. -// The mode picker displays modeOptions[0] when the stored mode isn't in the -// list (e.g. a globally-remembered "plan" that this workspace's OpenCode config -// no longer defines), so the submitted mode must match that display — otherwise -// we'd send a stale mode the provider rejects while the UI showed a valid one. -function reconcileSelectedMode(modeOptionIds: readonly string[], selectedMode: string): string { - if (modeOptionIds.length === 0) { - return ""; - } - return modeOptionIds.includes(selectedMode) ? selectedMode : (modeOptionIds[0] ?? ""); -} - function resolveDraftModeIdOverride(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionIds: readonly string[]; - selectedMode: string; + effectiveModeId: string; }): { modeId: string } | Record { - const { autoSubmitConfig, modeOptionIds, selectedMode } = input; + const { autoSubmitConfig, effectiveModeId } = input; if (autoSubmitConfig?.modeId) { return { modeId: autoSubmitConfig.modeId }; } - const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); - if (reconciled !== "") { - return { modeId: reconciled }; + if (effectiveModeId !== "") { + return { modeId: effectiveModeId }; } return {}; } function resolveDraftModeId(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionIds: readonly string[]; - selectedMode: string; + effectiveModeId: string; }): string | null { - const { autoSubmitConfig, modeOptionIds, selectedMode } = input; + const { autoSubmitConfig, effectiveModeId } = input; if (autoSubmitConfig?.modeId !== undefined) { return autoSubmitConfig.modeId; } - const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); - if (reconciled !== "") { - return reconciled; + if (effectiveModeId !== "") { + return effectiveModeId; } return null; } @@ -143,8 +128,7 @@ async function submitDraftCreateRequest(input: { autoSubmitConfig: AutoSubmitConfig | null; composerState: { selectedProvider: string | null; - selectedMode: string; - modeOptions: readonly { id: string }[]; + effectiveModeId: string; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; featureValues: Record | undefined; @@ -177,8 +161,7 @@ async function submitDraftCreateRequest(input: { } const modeIdOverride = resolveDraftModeIdOverride({ autoSubmitConfig, - modeOptionIds: composerState.modeOptions.map((mode) => mode.id), - selectedMode: composerState.selectedMode, + effectiveModeId: composerState.effectiveModeId, }); const config = buildWorkspaceDraftAgentConfig({ provider, @@ -219,8 +202,7 @@ function buildDraftAgentSnapshot(input: { composerState: { effectiveModelId: string | null; effectiveThinkingOptionId: string | null; - modeOptions: readonly { id: string }[]; - selectedMode: string; + effectiveModeId: string; selectedProvider: string | null; agentControls: { features?: Agent["features"] }; }; @@ -234,8 +216,7 @@ function buildDraftAgentSnapshot(input: { autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null); const modeId = resolveDraftModeId({ autoSubmitConfig, - modeOptionIds: composerState.modeOptions.map((mode) => mode.id), - selectedMode: composerState.selectedMode, + effectiveModeId: composerState.effectiveModeId, }); const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; if (!provider) { @@ -405,6 +386,8 @@ export function WorkspaceDraftAgentTab({ }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); + const editingSession = useComposerEditingSession("chat"); + const isComposerFullscreen = useComposerFullscreen(editingSession); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( COMPACT_FORM_FACTOR_WIDTH, { initialIsBelow: isCompactFormFactor }, @@ -648,10 +631,10 @@ export function WorkspaceDraftAgentTab({ return ( - + {dockContent} - {importPillPress ? ( + {importPillPress && !isComposerFullscreen ? ( @@ -675,6 +658,7 @@ export function WorkspaceDraftAgentTab({ onChangeAttachments={draftInput.setAttachments} cwd={composerState.workingDir} clearDraft={draftInput.clear} + editingSession={editingSession} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} autoFocusKey={String(draftInput.attachmentFocusRequestId)} onFocusInput={handleFocusInputCallback} @@ -692,6 +676,8 @@ const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", flexShrink: 1, + // Content-sized inline; fills the dock's fullscreen slot. + flexGrow: 1, }, }); diff --git a/packages/app/src/composer/editing-session/index.test.ts b/packages/app/src/composer/editing-session/index.test.ts new file mode 100644 index 0000000000..31c3765426 --- /dev/null +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { ComposerEditingSession, ComposerEditingSessions } from "./index"; + +describe("composer editing session", () => { + it("publishes fullscreen changes once per transition", () => { + const session = new ComposerEditingSession(); + const changes: boolean[] = []; + session.subscribe(() => changes.push(session.getFullscreen())); + session.setFullscreen(true); + session.setFullscreen(true); + session.setFullscreen(false); + expect(changes).toEqual([true, false]); + }); + + it("hands the retained caret and focus intent to exactly one later mount", () => { + const session = new ComposerEditingSession(); + expect(session.takeEditor()).toBeNull(); + session.retainEditor({ selection: { start: 4, end: 17 }, focused: true }); + expect(session.takeEditor()).toEqual({ selection: { start: 4, end: 17 }, focused: true }); + expect(session.takeEditor()).toBeNull(); + }); +}); + +describe("composer editing sessions", () => { + const workspace = "server:workspace"; + const target = JSON.stringify({ kind: "agent", agentId: "a" }); + + it("keeps one session per tab and purpose until the tab target changes", () => { + const sessions = new ComposerEditingSessions(); + const chat = sessions.get(workspace, "tab-1", target, "chat"); + expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(chat); + expect(sessions.get(workspace, "tab-1", target, "terminal")).not.toBe(chat); + const retargeted = JSON.stringify({ kind: "agent", agentId: "b" }); + expect(sessions.get(workspace, "tab-1", retargeted, "chat")).not.toBe(chat); + }); + + it("reports a tab as fullscreen while any of its composers is fullscreen", () => { + const sessions = new ComposerEditingSessions(); + const notifications: boolean[] = []; + sessions.subscribe(() => notifications.push(sessions.isTabFullscreen(workspace, "tab-1"))); + const chat = sessions.get(workspace, "tab-1", target, "chat"); + sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true); + expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(false); + expect(sessions.isTabFullscreen(workspace, null)).toBe(false); + chat.setFullscreen(true); + expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(true); + chat.setFullscreen(false); + expect(notifications).toEqual([false, true, false]); + }); + + it("drops sessions for closed tabs and keeps retained ones", () => { + const sessions = new ComposerEditingSessions(); + const kept = sessions.get(workspace, "tab-1", target, "chat"); + kept.setFullscreen(true); + sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true); + sessions.retain(workspace, ["tab-1"]); + expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(kept); + expect(sessions.isTabFullscreen(workspace, "tab-2")).toBe(false); + expect(sessions.get(workspace, "tab-2", target, "chat").getFullscreen()).toBe(false); + }); +}); diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx new file mode 100644 index 0000000000..6ad84d632d --- /dev/null +++ b/packages/app/src/composer/editing-session/index.tsx @@ -0,0 +1,180 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { ComposerInputMode } from "@/composer/input-mode"; + +export interface ComposerSelection { + start: number; + end: number; +} + +/** What an unmounting editor leaves for the next mount of the same session. */ +export interface ComposerRetainedEditor { + selection: ComposerSelection; + /** The editor was focused, or meant to regain focus once its pane became active. */ + focused: boolean; +} + +/** + * Editing state that outlives the editor's mount. Draft text belongs to its draft + * owner; the editor keeps its own live buffer across layout modes. Switching between + * the compact and desktop screens remounts the editor, so the session carries the + * caret and focus intent across that remount while the draft owner carries the text. + */ +export class ComposerEditingSession { + private fullscreen = false; + private retained: ComposerRetainedEditor | null = null; + private listeners = new Set<() => void>(); + constructor(private readonly onChange?: () => void) {} + getFullscreen = () => this.fullscreen; + /** Called by the unmounting editor; the next mount of this session takes it. */ + retainEditor(retained: ComposerRetainedEditor) { + this.retained = retained; + } + takeEditor(): ComposerRetainedEditor | null { + const retained = this.retained; + this.retained = null; + return retained; + } + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + setFullscreen(fullscreen: boolean) { + if (fullscreen === this.fullscreen) return; + this.fullscreen = fullscreen; + for (const listener of this.listeners) listener(); + this.onChange?.(); + } +} + +interface TabSessions { + targetKey: string; + sessions: Map; +} + +export class ComposerEditingSessions { + private workspaces = new Map>(); + private listeners = new Set<() => void>(); + private notify = () => { + for (const listener of this.listeners) listener(); + }; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) { + let workspace = this.workspaces.get(workspaceKey); + if (!workspace) { + workspace = new Map(); + this.workspaces.set(workspaceKey, workspace); + } + let tab = workspace.get(tabId); + if (!tab || tab.targetKey !== targetKey) { + tab = { targetKey, sessions: new Map() }; + workspace.set(tabId, tab); + } + let session = tab.sessions.get(purpose); + if (!session) { + session = new ComposerEditingSession(this.notify); + tab.sessions.set(purpose, session); + } + return session; + } + /** A tab is fullscreen when any of its composers is; the screen hides its chrome for it. */ + isTabFullscreen(workspaceKey: string, tabId: string | null): boolean { + if (tabId === null) return false; + const tab = this.workspaces.get(workspaceKey)?.get(tabId); + if (!tab) return false; + for (const session of tab.sessions.values()) if (session.getFullscreen()) return true; + return false; + } + retain(workspaceKey: string, tabIds: readonly string[]) { + const workspace = this.workspaces.get(workspaceKey); + if (!workspace) return; + const retained = new Set(tabIds); + let changed = false; + for (const tabId of workspace.keys()) { + if (retained.has(tabId)) continue; + workspace.delete(tabId); + changed = true; + } + if (workspace.size === 0) this.workspaces.delete(workspaceKey); + if (changed) this.notify(); + } +} + +const SessionsContext = createContext(null); +const WorkspaceContext = createContext(null); +const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null); + +/** App-container ownership survives replacement of both the gesture shell and tab layout. */ +export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) { + const [sessions] = useState(() => new ComposerEditingSessions()); + return {children}; +} +export function WorkspaceComposerEditingSessionsProvider({ + workspaceKey, + tabIds, + children, +}: { + workspaceKey: string; + tabIds: readonly string[]; + children: ReactNode; +}) { + const sessions = useContext(SessionsContext); + useEffect(() => sessions?.retain(workspaceKey, tabIds), [sessions, workspaceKey, tabIds]); + return {children}; +} +export function ComposerEditingScope({ + tabId, + targetKey, + children, +}: { + tabId: string; + targetKey: string; + children: ReactNode; +}) { + const scope = useMemo(() => ({ tabId, targetKey }), [tabId, targetKey]); + return {children}; +} +export function useComposerEditingSession( + purpose: ComposerInputMode, + provided?: ComposerEditingSession, +): ComposerEditingSession { + const sessions = useContext(SessionsContext); + const scope = useContext(ScopeContext); + const workspaceKey = useContext(WorkspaceContext); + const [local] = useState(() => provided ?? new ComposerEditingSession()); + if (provided) return provided; + return sessions && scope && workspaceKey + ? sessions.get(workspaceKey, scope.tabId, scope.targetKey, purpose) + : local; +} +export function useComposerFullscreen(session: ComposerEditingSession): boolean { + return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen); +} +/** Whether the composer of a workspace tab is fullscreen, for the chrome that tab sits under. */ +export function useComposerTabFullscreen(workspaceKey: string, tabId: string | null): boolean { + const sessions = useContext(SessionsContext); + const subscribe = useCallback( + (listener: () => void) => sessions?.subscribe(listener) ?? (() => {}), + [sessions], + ); + const read = useCallback( + () => sessions?.isTabFullscreen(workspaceKey, tabId) ?? false, + [sessions, workspaceKey, tabId], + ); + return useSyncExternalStore(subscribe, read, read); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c46e2ac15a..10d670fcae 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,3 +1,8 @@ +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "./editing-session"; import type { ComposerTextSource } from "./text-source"; import { createStore, type StoreApi } from "zustand/vanilla"; import { useStore } from "zustand"; @@ -22,11 +27,12 @@ import { useImperativeHandle, memo, type ReactElement, - type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { usePanelStore } from "@/stores/panel-store"; +import { useHasFinePointer } from "@/hooks/use-fine-pointer"; import { useShallow } from "zustand/shallow"; import { ArrowUp, @@ -278,37 +284,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) { }; } -function renderContextWindowMeter( - contextWindowMaxTokens: number | null, - contextWindowUsedTokens: number | null, - totalCostUsd: number | null, - showPercentage: boolean, - pending: boolean, - glyphSize: number, -): ReactElement | null { - const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null; - if (!hasData && !pending) { - return null; - } - return ( - - ); -} - -function resolveContextWindowPlacement( - meter: ReactElement | null, - reserveSlot: boolean, -): ReactNode { - return reserveSlot ? {meter} : null; -} - interface RenderLeftContentArgs { agentControls: DraftAgentControlsProps | undefined; agentId: string; @@ -997,21 +972,17 @@ interface ComposerProps { submitLabel?: string; /** Overrides the mode's default placeholder, for text only the caller can build. */ placeholder?: string; + /** + * The host that also lays out the dock and chrome shares this session so its + * fullscreen state and the composer's agree. Defaults to the tab's session for + * `inputMode`. + */ + editingSession?: ComposerEditingSession; } const EMPTY_ARRAY: readonly QueuedMessage[] = []; const StableMessageInput = memo(MessageInput); -function resolveContextWindowValues( - rawMax: number | null, - rawUsed: number | null, -): { contextWindowMaxTokens: number | null; contextWindowUsedTokens: number | null } { - if (typeof rawMax === "number" && typeof rawUsed === "number") { - return { contextWindowMaxTokens: rawMax, contextWindowUsedTokens: rawUsed }; - } - return { contextWindowMaxTokens: null, contextWindowUsedTokens: null }; -} - interface ComposerAutocompleteHandle { onKeyPress: (event: ComposerKeyPressEvent) => boolean; } @@ -1044,8 +1015,10 @@ function ComposerAutocompleteBinding({ cursorIndex: Math.min(cursorIndex, userInput.length), onAutocompleteApplied: () => inputRef.current?.focus(), }); - useImperativeHandle(ref, () => ({ onKeyPress: autocomplete.onKeyPress }), [ - autocomplete.onKeyPress, + const handleKeyPress = autocomplete.onKeyPress; + useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [ + show, + handleKeyPress, ]); const selectOption = autocomplete.onSelectOption; const onSelect = useCallback( @@ -1220,8 +1193,19 @@ function ComposerVoiceModeButton({ } export function Composer({ isPaneFocused, ...props }: ComposerProps) { + // A compact overlay panel (agent list, explorer) covers the pane without taking its + // focus. The composer gives up input from the moment the user asks for the panel, + // not when its motion settles, so the exit action and autocomplete never race it. + // Pane focus stays put, so the keyboard the panel dismisses is a blur in the + // user's own pane: closing the panel does not bring focus back. + const isCompact = useIsCompactFormFactor(); + const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent"); + const coveredByOverlayPanel = isCompact && overlayPanelRequested; return ( - + @@ -1272,6 +1256,7 @@ function ComposerContentImpl({ inputWrapperStyle, isCompactLayout: isCompactLayoutOverride, inputMode = "chat", + editingSession: providedEditingSession, readOnly = false, submitLabel, placeholder, @@ -1308,6 +1293,7 @@ function ComposerContentImpl({ const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); + const hasFinePointer = useHasFinePointer(); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder); const hasText = useSyncExternalStore( @@ -1376,6 +1362,8 @@ function ComposerContentImpl({ const nextPendingFileId = useRef(0); const isUploadingFile = pendingFiles.length > 0; const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); + const editingSession = useComposerEditingSession(inputMode, providedEditingSession); + const isFullscreen = useComposerFullscreen(editingSession); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); @@ -2065,36 +2053,31 @@ function ComposerContentImpl({ ], ); - const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues( - agentState.contextWindowMaxTokens, - agentState.contextWindowUsedTokens, - ); - - const contextWindowPending = agentState.status === "initializing" || isAgentRunning; const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize; - - const contextWindowMeter = useMemo( + const beforeVoiceContent = useMemo( () => - renderContextWindowMeter( - contextWindowMaxTokens, - contextWindowUsedTokens, - agentState.totalCostUsd, - false, - contextWindowPending, - contextWindowMeterGlyphSize, - ), + hasAgent ? ( + + + + ) : null, [ - contextWindowMaxTokens, - contextWindowUsedTokens, + hasAgent, + serverId, + agentId, + agentState.contextWindowMaxTokens, + agentState.contextWindowUsedTokens, agentState.totalCostUsd, - contextWindowPending, contextWindowMeterGlyphSize, ], ); - const beforeVoiceContent = useMemo( - () => <>{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}, - [contextWindowMeter, hasAgent], - ); const hasGithubAttachment = useMemo( () => @@ -2290,9 +2273,23 @@ function ComposerContentImpl({ [githubSearchItems, selectedAttachments, handleToggleGithubItem], ); + // Fullscreen fills the dock slot: the chain down to the input stretches and drops + // the inline padding, which the fullscreen presentation supplies itself. const inputAreaContainerStyle = useMemo( - () => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked], - [isComposerLocked], + () => [ + styles.inputAreaContainer, + isComposerLocked && styles.inputAreaLocked, + isFullscreen && styles.inputAreaFullscreen, + ], + [isComposerLocked, isFullscreen], + ); + const inputAreaContentStyle = useMemo( + () => [styles.inputAreaContent, isFullscreen && styles.fullscreenFill], + [isFullscreen], + ); + const messageInputContainerStyle = useMemo( + () => [styles.messageInputContainer, isFullscreen && styles.fullscreenFill], + [isFullscreen], ); const attachmentTray = useMemo( @@ -2375,7 +2372,8 @@ function ComposerContentImpl({ { disabled: isSubmitLoadingVisible }, ); - const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint; + // Focusing the composer on a touch screen raises the on-screen keyboard over the conversation. + const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint && hasFinePointer; const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined; const sendErrorNode = useMemo( () => @@ -2402,21 +2400,25 @@ function ComposerContentImpl({ focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction} isMessageInputFocused={isMessageInputFocused} /> - + {/* Input area */} - - {queueList} - {sendErrorNode} + + {isFullscreen ? null : queueList} + {!isFullscreen && sendErrorNode} - + @@ -2431,6 +2433,8 @@ function ComposerContentImpl({ ({ @@ -2533,6 +2543,18 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputAreaLocked: { opacity: 0.6, }, + inputAreaFullscreen: { + flex: 1, + minHeight: 0, + alignItems: "stretch", + paddingHorizontal: 0, + paddingBottom: 0, + }, + fullscreenFill: { + flex: 1, + minHeight: 0, + maxWidth: "100%", + }, inputAreaContent: { flexShrink: 1, width: "100%", diff --git a/packages/app/src/composer/input/fullscreen-exit.native.ts b/packages/app/src/composer/input/fullscreen-exit.native.ts new file mode 100644 index 0000000000..c2344d50b0 --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.native.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { BackHandler } from "react-native"; + +/** The hardware back button leaves fullscreen instead of the screen. */ +export function useComposerFullscreenExit( + active: boolean, + onExit: () => void, +): ((node: unknown) => void) | undefined { + useEffect(() => { + if (!active) return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + onExit(); + return true; + }); + return () => subscription.remove(); + }, [active, onExit]); + return undefined; +} diff --git a/packages/app/src/composer/input/fullscreen-exit.ts b/packages/app/src/composer/input/fullscreen-exit.ts new file mode 100644 index 0000000000..b76eeed5fc --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.ts @@ -0,0 +1,23 @@ +import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root"; + +/** + * Escape leaves fullscreen before app shortcuts see it. The registration only claims + * keys; focus stays where the user left it because the editor never remounts. + */ +export function useComposerFullscreenExit( + active: boolean, + onExit: () => void, +): ((node: unknown) => void) | undefined { + const layer = useOverlayLayer("modal"); + return useWebOverlayRegistration({ + active, + layer, + manageFocus: false, + onKeyDown: (event) => { + if (event.key !== "Escape") return false; + event.preventDefault(); + onExit(); + return true; + }, + }); +} diff --git a/packages/app/src/composer/input/height.native.ts b/packages/app/src/composer/input/height.native.ts index 21178db4e1..a36839e2e7 100644 --- a/packages/app/src/composer/input/height.native.ts +++ b/packages/app/src/composer/input/height.native.ts @@ -7,6 +7,8 @@ interface ComposerHeightArgs { textareaRef: RefObject; minHeight: number; maxHeight: number; + onRenderedLinesChange?: (lines: number) => void; + presentationKey?: boolean; } export function useComposerHeight({ diff --git a/packages/app/src/composer/input/height.web.ts b/packages/app/src/composer/input/height.web.ts index c732bf3599..6b8c1ec561 100644 --- a/packages/app/src/composer/input/height.web.ts +++ b/packages/app/src/composer/input/height.web.ts @@ -7,6 +7,8 @@ interface ComposerHeightArgs { textareaRef: RefObject; minHeight: number; maxHeight: number; + onRenderedLinesChange?: (lines: number) => void; + presentationKey?: boolean; } const COPIED_STYLES = [ @@ -36,11 +38,13 @@ export function useComposerHeight({ textareaRef, minHeight, maxHeight, + onRenderedLinesChange, + presentationKey, }: ComposerHeightArgs): ComposerHeightResult { const [height, setHeight] = useState(minHeight); const heightRef = useRef(minHeight); - const paramsRef = useRef({ getText, minHeight, maxHeight }); - paramsRef.current = { getText, minHeight, maxHeight }; + const paramsRef = useRef({ getText, minHeight, maxHeight, onRenderedLinesChange }); + paramsRef.current = { getText, minHeight, maxHeight, onRenderedLinesChange }; const mirrorRef = useRef(null); const setBoundedHeight = useCallback((nextHeight: number) => { @@ -65,6 +69,13 @@ export function useComposerHeight({ } mirror.style.width = `${sourceWidth}px`; mirror.value = text.endsWith("\n") ? `${text} ` : text; + const padding = + parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom); + const lineHeight = parseFloat(computedStyle.lineHeight); + if (lineHeight > 0) + paramsRef.current.onRenderedLinesChange?.( + Math.round((mirror.scrollHeight - padding) / lineHeight), + ); setBoundedHeight(mirror.scrollHeight); }, [setBoundedHeight, textareaRef], @@ -101,7 +112,7 @@ export function useComposerHeight({ useLayoutEffect(() => { measure(getText()); - }, [maxHeight, minHeight, getText, measure]); + }, [maxHeight, minHeight, getText, measure, presentationKey]); useEffect(() => { const source = textareaRef.current; @@ -115,7 +126,7 @@ export function useComposerHeight({ }); observer.observe(source); return () => observer.disconnect(); - }, [measure, textareaRef]); + }, [measure, textareaRef, presentationKey]); const onTextChange = useCallback( (_previousText: string, nextText: string) => measure(nextText), diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 0300dd518c..7346095d5b 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,8 @@ +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, @@ -56,6 +61,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; import { useComposerHeight } from "./height"; +import { ComposerInputPresentation } from "./presentation"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import type { NativePastedFile } from "@/composer/native-pasted-image"; import { @@ -77,6 +83,8 @@ import { applyDictationTranscript, computeCanStartDictation, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, + resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -106,6 +114,8 @@ export interface ComposerKeyPressEvent { } export interface MessageInputProps { + editingSession?: ComposerEditingSession; + submissionError?: React.ReactNode; value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; @@ -206,6 +216,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< >; interface TextAreaHandle { + value?: string; scrollHeight?: number; clientHeight?: number; offsetHeight?: number; @@ -215,7 +226,7 @@ interface TextAreaHandle { style?: { height?: string; overflowY?: string; - } & Record; + }; } function AttachButtonIcon({ @@ -433,7 +444,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM } interface PasteImagesEffectArgs { - getWebTextArea: () => TextAreaHandle | null; + textarea: HTMLElement | null; isConnected: boolean; disabled: boolean; isDictating: boolean; @@ -443,7 +454,7 @@ interface PasteImagesEffectArgs { function usePasteImagesEffect(args: PasteImagesEffectArgs): void { const { - getWebTextArea, + textarea, isConnected, disabled, isDictating, @@ -454,19 +465,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { useEffect(() => { if (!isWeb || !onAddImages) return; - const textarea = getWebTextArea() as - | (TextAreaHandle & { - addEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; - removeEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; - }) - | null; - if ( - !textarea || - typeof textarea.addEventListener !== "function" || - typeof textarea.removeEventListener !== "function" - ) { - return; - } + if (!textarea) return; let disposed = false; const handlePaste = (event: ClipboardEvent) => { @@ -493,14 +492,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { disposed = true; textarea.removeEventListener?.("paste", handlePaste); }; - }, [ - disabled, - getWebTextArea, - isConnected, - isDictating, - isRealtimeVoiceForCurrentAgent, - onAddImages, - ]); + }, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]); } function useAutoFocusOnWebEffect( @@ -532,8 +524,8 @@ function MessageInputAutoFocus({ autoFocusKey: string | undefined; textInputRef: React.MutableRefObject; }) { - const { isActiveComposer } = useComposerKeyboardScope(); - useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey); + const { isPaneFocused } = useComposerKeyboardScope(); + useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey); return null; } @@ -628,6 +620,7 @@ function FocusHint({ interface ComposerTextSurfaceProps { readOnly: boolean; value: string; + textReplacementKey: string; textInputRef: React.Ref; textInputStyle: EditingTextInputProps["style"]; readOnlyTextStyle: React.ComponentProps["style"]; @@ -643,6 +636,9 @@ interface ComposerTextSurfaceProps { onSelectionChange: (event: NativeSyntheticEvent) => void; onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined; onPasteError: (message: string) => void; + fullscreen: boolean; + compact: boolean; + onRenderedLinesChange: (lines: number) => void; focusHintVisible: boolean; focusInputKeys: ShortcutChord | null | undefined; focusHintLabel: string; @@ -654,6 +650,23 @@ interface ComposerTextSurfaceProps { * state of this composer rather than a second one. */ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement { + const { onChangeText, onRenderedLinesChange } = props; + const [measurementText, setMeasurementText] = useState(props.value); + useEffect(() => { + if (!isWeb) setMeasurementText(props.value); + }, [props.value, props.textReplacementKey]); + const handleTextChange = useCallback( + (text: string) => { + if (!isWeb) setMeasurementText(text); + onChangeText(text); + }, + [onChangeText, setMeasurementText], + ); + const measureLines = useCallback( + (event: import("react-native").TextLayoutEvent) => + onRenderedLinesChange(event.nativeEvent.lines.length), + [onRenderedLinesChange], + ); if (props.readOnly) { return ( @@ -664,12 +677,22 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen ); } return ( - + + {!isWeb && props.compact && !props.fullscreen ? ( + + {`${measurementText}\u200b`} + + ) : null} Promise | void; toast: { error: (msg: string) => void }; interruptBeforeVoiceMessage: string; + exitFullscreen: () => void; } function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { @@ -858,6 +882,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { ctx.toast.error(ctx.interruptBeforeVoiceMessage); return; } + ctx.exitFullscreen(); void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => { console.error("[MessageInput] Failed to start realtime voice", error); const message = extractErrorMessage(error); @@ -872,6 +897,7 @@ interface StartDictationContext { canStartDictation: () => boolean; toast: { error: (msg: string) => void }; startDictation: () => Promise; + exitFullscreen: () => void; } async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise { @@ -882,6 +908,7 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis if (!ctx.canStartDictation()) { return; } + ctx.exitFullscreen(); await ctx.startDictation(); } @@ -915,6 +942,7 @@ interface SendMessageContext { onSubmit: (payload: MessagePayload) => void; onMinimizeHeight: () => void; preserveHeightOnSubmit: boolean; + exitFullscreen: () => void; } function sendMessageImpl(ctx: SendMessageContext): void { @@ -927,6 +955,7 @@ function sendMessageImpl(ctx: SendMessageContext): void { ) { return; } + ctx.exitFullscreen(); ctx.onSubmit({ text: trimmed, attachments: ctx.attachments, @@ -947,12 +976,14 @@ interface QueueMessageContext { onQueue: ((payload: MessagePayload) => void) | undefined; replaceText: (text: string) => void; onMinimizeHeight: () => void; + exitFullscreen: () => void; } function queueMessageImpl(ctx: QueueMessageContext): void { if (!ctx.onQueue) return; const trimmed = ctx.value.trim(); if (!trimmed && ctx.attachments.length === 0) return; + ctx.exitFullscreen(); ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd }); ctx.replaceText(""); ctx.onMinimizeHeight(); @@ -983,7 +1014,8 @@ function computeIsDictationStartEnabled( return (isReadyForDictation ?? isConnected) && !disabled; } -function resolveMaxInputHeight(windowHeight: number): number { +function resolveMaxInputHeight(windowHeight: number, compact: boolean): number { + if (compact) return DEFAULT_MAX_INPUT_HEIGHT; if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT; return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO)); } @@ -1008,8 +1040,8 @@ function getComposerInputSnapshot( fallbackText: string, fallbackSelection: ComposerInputSnapshot["selection"], ): ComposerInputSnapshot { - const text = current?.getText() ?? fallbackText; const textArea = getWebTextAreaImpl(current); + const text = textArea?.value ?? current?.getText() ?? fallbackText; const start = textArea?.selectionStart ?? fallbackSelection.start; const end = textArea?.selectionEnd ?? fallbackSelection.end; return { text, selection: { start, end } }; @@ -1139,6 +1171,28 @@ function extractErrorMessage(error: unknown): string | null { return null; } +function resolvePresentationSendDisabled(input: { + fullscreen: boolean; + disabled: boolean; + hasText: boolean; + attachments: ComposerAttachment[]; + hasExternalContent: boolean; + allowEmpty: boolean; +}): boolean { + return ( + input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input)) + ); +} + +function shouldShowFullscreenControl( + compact: boolean, + readOnly: boolean, + overlayVisible: boolean, + renderedLines: number, +) { + return compact && !readOnly && !overlayVisible && renderedLines > 2; +} + export const MessageInput = forwardRef( function MessageInput(props, ref) { const { @@ -1186,11 +1240,15 @@ export const MessageInput = forwardRef( textReplacement, submitLabel, } = resolveMessageInputProps(props); + const { isPaneFocused } = useComposerKeyboardScope(); const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const { height: windowHeight } = useWindowDimensions(); - const maxInputHeight = resolveMaxInputHeight(windowHeight); + const editingSession = useComposerEditingSession(inputMode, props.editingSession); + const isFullscreen = useComposerFullscreen(editingSession); + const [renderedLines, setRenderedLines] = useState(1); + const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const toast = useToast(); const voice = useVoiceOptional(); @@ -1207,27 +1265,14 @@ export const MessageInput = forwardRef( const inputWrapperRef = useRef(null); const textInputRef = useRef(null); const isInputFocusedRef = useRef(false); + const focusIntentRef = useRef(false); const valueRef = useRef(value); const selectionRef = useRef({ start: value.length, end: value.length }); + const paneFocusedRef = useRef(isPaneFocused); + paneFocusedRef.current = isPaneFocused; + const [webTextArea, setWebTextArea] = useState(null); const appliedTextReplacementKeyRef = useRef(textReplacement.key); const webTextareaRef = useRef(null); - const getLiveText = useCallback(() => valueRef.current, []); - const composerHeight = useComposerHeight({ - getText: getLiveText, - textareaRef: webTextareaRef, - minHeight: MIN_INPUT_HEIGHT, - maxHeight: maxInputHeight, - }); - const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight; - const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined; - const updateComposerHeightForText = measuredComposerHeight?.onTextChange; - const resetComposerHeight = measuredComposerHeight?.reset; - - const handleComposerLayout = useCallback( - (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height), - [onHeightChange], - ); - const updateLiveTextPresence = useCallback((text: string) => { const nextHasLiveText = text.trim().length > 0; if (hasLiveTextRef.current === nextHasLiveText) return; @@ -1235,6 +1280,78 @@ export const MessageInput = forwardRef( setHasLiveText(nextHasLiveText); }, []); + // One editor stays mounted through every layout mode, so this ref only follows + // the editor's own mount and unmount. + const assignTextInput = useCallback((input: ComposerTextInputHandle | null) => { + textInputRef.current = input; + if (!isWeb) return; + const element = getWebTextAreaImpl(input) as HTMLElement | null; + webTextareaRef.current = element; + setWebTextArea(element); + }, []); + // Focus intent outlives a blur only while the pane is not the user's pane (a retained + // tab hidden, a sheet over it); the pane coming back restores focus. A blur while the + // pane is theirs drops it, whatever the layout mode: the user dismissed the keyboard, + // or a compact overlay panel did on their behalf, and nothing later refocuses. + useLayoutEffect(() => { + if (isPaneFocused && focusIntentRef.current) textInputRef.current?.focus(); + }, [isPaneFocused]); + // The compact and desktop screens mount the editor in different trees, so a + // viewport that crosses the breakpoint remounts it. The draft owner brings the + // text back; the session brings the caret and focus intent back. On web the + // owner may still hold an unpublished edit while the new editor mounts, so the + // caret is placed now against the text on screen and again when the owner's + // hydrated text arrives. + const retainedSelectionRef = useRef<{ start: number; end: number } | null>(null); + useLayoutEffect(() => { + const retained = editingSession.takeEditor(); + if (retained) { + const text = textInputRef.current?.getText() ?? valueRef.current; + const selection = resolveRetainedSelection(retained.selection, text.length); + retainedSelectionRef.current = retained.selection; + selectionRef.current = selection; + textInputRef.current?.setSelection(selection); + if (retained.focused) { + focusIntentRef.current = true; + if (paneFocusedRef.current) textInputRef.current?.focus(); + } + } + return () => { + editingSession.retainEditor({ + selection: getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ).selection, + focused: isInputFocusedRef.current || focusIntentRef.current, + }); + }; + }, [editingSession]); + const getLiveText = useCallback(() => valueRef.current, []); + const composerHeight = useComposerHeight({ + getText: getLiveText, + textareaRef: webTextareaRef, + minHeight: MIN_INPUT_HEIGHT, + maxHeight: maxInputHeight, + onRenderedLinesChange: setRenderedLines, + presentationKey: isFullscreen, + }); + const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight; + const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined; + const updateComposerHeightForText = measuredComposerHeight?.onTextChange; + const resetComposerHeight = measuredComposerHeight?.reset; + + const exitFullscreen = useCallback(() => editingSession.setFullscreen(false), [editingSession]); + const enterFullscreen = useCallback(() => editingSession.setFullscreen(true), [editingSession]); + + // The conversation sizes itself against the inline composer; fullscreen covers it. + const handleComposerLayout = useCallback( + (event: LayoutChangeEvent) => { + if (!isFullscreen) onHeightChange?.(event.nativeEvent.layout.height); + }, + [isFullscreen, onHeightChange], + ); + const replaceText = useCallback( (nextText: string, selection?: { start: number; end: number }) => { updateComposerHeightForText?.(valueRef.current, nextText); @@ -1297,8 +1414,15 @@ export const MessageInput = forwardRef( updateComposerHeightForText?.(valueRef.current, textReplacement.text); valueRef.current = textReplacement.text; updateLiveTextPresence(textReplacement.text); + const retainedSelection = + textReplacement.kind === "hydrated" ? retainedSelectionRef.current : null; + retainedSelectionRef.current = null; if (textReplacement.text === "") { textInputRef.current?.reset(); + } else if (retainedSelection) { + const selection = resolveRetainedSelection(retainedSelection, textReplacement.text.length); + selectionRef.current = selection; + textInputRef.current?.replaceText(textReplacement.text, selection); } else { textInputRef.current?.replaceText(textReplacement.text); } @@ -1412,8 +1536,9 @@ export const MessageInput = forwardRef( canStartDictation, toast, startDictation, + exitFullscreen, }), - [canStartDictation, dictationUnavailableMessage, startDictation, toast], + [canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen], ); const handleVoicePress = useCallback( @@ -1485,10 +1610,12 @@ export const MessageInput = forwardRef( handleStopRealtimeVoice, toast, interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"), + exitFullscreen, }); }, [ disabled, handleStopRealtimeVoice, + exitFullscreen, isAgentRunning, isConnected, t, @@ -1517,6 +1644,7 @@ export const MessageInput = forwardRef( onSubmit, onMinimizeHeight: minimizeInputHeight, preserveHeightOnSubmit, + exitFullscreen, }); }, [ allowEmptySubmit, @@ -1528,6 +1656,7 @@ export const MessageInput = forwardRef( minimizeInputHeight, preserveHeightOnSubmit, updateLiveTextPresence, + exitFullscreen, ]); const handleQueueMessage = useCallback( @@ -1539,8 +1668,9 @@ export const MessageInput = forwardRef( onQueue, replaceText, onMinimizeHeight: minimizeInputHeight, + exitFullscreen, }), - [attachments, cwd, onQueue, replaceText, minimizeInputHeight], + [attachments, cwd, onQueue, replaceText, minimizeInputHeight, exitFullscreen], ); const handleDefaultSendAction = useCallback(() => { @@ -1563,19 +1693,8 @@ export const MessageInput = forwardRef( }); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); - const getWebTextArea = useCallback( - (): TextAreaHandle | null => getWebTextAreaImpl(textInputRef.current), - [], - ); - - useLayoutEffect(() => { - if (isWeb) { - webTextareaRef.current = getWebTextArea() as HTMLElement | null; - } - }, [getWebTextArea]); - usePasteImagesEffect({ - getWebTextArea, + textarea: webTextArea, isConnected, disabled, isDictating, @@ -1596,16 +1715,30 @@ export const MessageInput = forwardRef( const shouldHandleWebKeyPress = isWeb; const shouldSubmitOnEnter = isWeb && !isCompact; - function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { - if (!shouldHandleWebKeyPress) return; - handleDesktopKeyPressImpl(event, { + const handleDesktopKeyPress = useCallback( + (event: WebTextInputKeyPressEvent) => { + if (!shouldHandleWebKeyPress) return; + handleDesktopKeyPressImpl(event, { + onKeyPressCallback, + input: getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ), + submitOnEnter: shouldSubmitOnEnter, + isAgentRunning, + onQueue, + isSubmitDisabled, + isSubmitLoading, + disabled, + handleAlternateSendAction, + handleDefaultSendAction, + }); + }, + [ + shouldHandleWebKeyPress, onKeyPressCallback, - input: getComposerInputSnapshot( - textInputRef.current, - valueRef.current, - selectionRef.current, - ), - submitOnEnter: shouldSubmitOnEnter, + shouldSubmitOnEnter, isAgentRunning, onQueue, isSubmitDisabled, @@ -1613,8 +1746,8 @@ export const MessageInput = forwardRef( disabled, handleAlternateSendAction, handleDefaultSendAction, - }); - } + ], + ); const primaryActionKind = resolvePrimaryActionKind({ hasSendableContent: hasSendableComposerContent({ @@ -1678,12 +1811,14 @@ export const MessageInput = forwardRef( ); const handleInputFocus = useCallback(() => { + focusIntentRef.current = true; isInputFocusedRef.current = true; setIsInputFocused(true); onFocusChange?.(true); }, [onFocusChange]); const handleInputBlur = useCallback(() => { + if (paneFocusedRef.current) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); @@ -1735,8 +1870,13 @@ export const MessageInput = forwardRef( // `fontFamily` here is silently dropped while every other property lands. // An inline style outranks both classes. See docs/unistyles.md. const textInputStyle = useMemo( - () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle], - [composerHeightStyle, mode.isMonospace], + () => [ + styles.textInput, + mode.isMonospace && styles.textInputMonospace, + isFullscreen ? styles.fullscreenTextInput : composerHeightStyle, + isCompact && !isFullscreen && styles.compactTextInput, + ], + [composerHeightStyle, mode.isMonospace, isFullscreen, isCompact], ); // Static content has no textarea to mirror, so it grows with its own text // instead of the measured input height. @@ -1744,13 +1884,22 @@ export const MessageInput = forwardRef( () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText], [mode.isMonospace], ); + // Fullscreen always shows Send; without content it is disabled, and looks it. + const isPresentedSendDisabled = resolvePresentationSendDisabled({ + fullscreen: isFullscreen, + disabled: isSendButtonDisabled, + hasText: hasLiveText, + attachments, + hasExternalContent, + allowEmpty: allowEmptySubmit, + }); const sendButtonCombinedStyle = useMemo( () => [ styles.sendButton, submitLabel ? styles.sendButtonLabeled : undefined, - isSendButtonDisabled && styles.buttonDisabled, + isPresentedSendDisabled && styles.buttonDisabled, ], - [isSendButtonDisabled, submitLabel], + [isPresentedSendDisabled, submitLabel], ); const overlayContainerStyle = useMemo( () => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }], @@ -1780,107 +1929,177 @@ export const MessageInput = forwardRef( [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], ); - return ( - - ( + - {/* Regular input */} - - {attachmentSlot} - {/* Text input */} - - ( + + + + ), + [ + readOnly, + textReplacement, + assignTextInput, + textInputStyle, + readOnlyTextStyle, + placeholder, + t, + mode.accessibilityLabelKey, + handleInputChange, + handleInputFocus, + handleInputBlur, + isDictating, + isRealtimeVoiceForCurrentAgent, + disabled, + isComposerScrollEnabled, + shouldHandleWebKeyPress, + handleDesktopKeyPress, + handleSelectionChange, + onPasteImages, + handlePasteError, + isFullscreen, + isCompact, + isInputFocused, + value, + focusInputKeys, + ], + ); + const toolbar = useMemo( + () => ( + + {/* Toolbar left: attachment button + agent controls */} + + - + {leftContent} + - {/* Button row */} - - {/* Toolbar left: attachment button + agent controls */} - - - {leftContent} - - - {/* Right: voice button, contextual button (realtime/send/cancel) */} - - {beforeVoiceContent} - - {rightContent} - - + {/* Right: voice button, contextual button (realtime/send/cancel) */} + + {beforeVoiceContent} + + {rightContent} + {sendButton} - + ), + [ + mode.showAttachments, + isConnected, + disabled, + attachButtonStyle, + renderAttachButtonIcon, + attachmentMenuItems, + t, + leftContent, + beforeVoiceContent, + mode.showVoice, + handleVoicePress, + isDictationStartEnabled, + voiceButtonAccessibilityLabel, + voiceButtonStyle, + renderVoiceButtonIcon, + voiceTooltipText, + isRealtimeVoiceForCurrentAgent, + voiceMuteToggleKeys, + dictationToggleKeys, + rightContent, + sendButton, + ], + ); + const voiceOverlay = useMemo( + () => ( ( onRealtimeVoiceStop={handleRealtimeVoiceStop} /> + ), + [ + overlayContainerStyle, + surfacePresentation.overlay.pointerEvents, + showDictationOverlay, + showRealtimeOverlay, + voice, + dictationVolume, + dictationDuration, + isDictating, + isDictationProcessing, + dictationStatus, + dictationError, + handleCancelRecording, + handleAcceptRecording, + handleAcceptAndSendRecording, + handleRetryFailedRecording, + handleDiscardFailedRecording, + handleRealtimeVoiceStop, + ], + ); + return ( + + + ); }, @@ -1913,6 +2186,10 @@ const styles = StyleSheet.create((theme: Theme) => ({ flexShrink: 1, position: "relative", }, + fullscreenContainer: { + flex: 1, + position: "relative", + }, inputWrapper: { flexShrink: 1, flexDirection: "column", @@ -1921,10 +2198,15 @@ const styles = StyleSheet.create((theme: Theme) => ({ borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius["2xl"], - paddingVertical: { + paddingTop: { xs: theme.spacing[2], md: theme.spacing[4], }, + // The button row bleeds 6px horizontally, so match its corner inset at the bottom. + paddingBottom: { + xs: theme.spacing[2], + md: theme.spacing[3], + }, paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[4], @@ -1942,6 +2224,23 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputWrapperReadOnly: { borderStyle: "dotted", }, + // The band the fullscreen glyph sits in is the input's own padding, so a tap + // anywhere in it (under the glyph, or where the glyph would be) is a tap on the + // editor: it focuses, shows the keyboard, and places the caret. Reserved whether + // or not the glyph shows, so text never reflows when it appears. + compactTextInput: { paddingRight: 36 }, + fullscreenTextSurface: { flex: 1, minHeight: 0 }, + fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" }, + lineMeasurement: { + position: "absolute", + top: 0, + left: 0, + right: 0, + opacity: 0, + maxHeight: undefined, + minHeight: undefined, + height: undefined, + }, textInputScrollWrapper: { flexShrink: 1, position: "relative", diff --git a/packages/app/src/composer/input/presentation.tsx b/packages/app/src/composer/input/presentation.tsx new file mode 100644 index 0000000000..64697999f5 --- /dev/null +++ b/packages/app/src/composer/input/presentation.tsx @@ -0,0 +1,171 @@ +import { useCallback, useMemo, type ReactNode, type Ref } from "react"; +import { View, type ViewProps } from "react-native"; +import { Maximize2, Minimize2 } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock"; +import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; +import { isWeb } from "@/constants/platform"; +import { ICON_SIZE } from "@/styles/theme"; +import { StyleSheet } from "react-native-unistyles"; +import { useComposerFullscreenExit } from "./fullscreen-exit"; +import { resolveGlyphRailOffset } from "./state"; + +const CONTROL_SIZE = 28; +const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md; + +function keepEditorFocused(event: import("react-native").PointerEvent) { + if (isWeb) event.preventDefault(); +} + +/** + * One tree for both layout modes. The editing surface keeps its position so the + * mounted editor, its native buffer, selection, and focus survive every toggle; + * only the chrome around it changes. Fullscreen shows exit top right and Send + * bottom right, both on the rail the inline Send button uses; a send error sits + * in the same footer as Send. + */ +export function ComposerInputPresentation({ + fullscreen, + sendIconSize, + surface, + toolbar, + send, + error, + onExit, + inputWrapperRef, + inputWrapperStyle, + inputPointerEvents, + attachmentSlot, + showFullscreenButton, + onEnter, + overlay, +}: { + fullscreen: boolean; + sendIconSize: number; + surface: ReactNode; + toolbar: ReactNode; + send: ReactNode; + error: ReactNode; + onExit: () => void; + inputWrapperRef: Ref; + inputWrapperStyle: ViewProps["style"]; + inputPointerEvents: ViewProps["pointerEvents"]; + attachmentSlot: ReactNode; + showFullscreenButton: boolean; + onEnter: () => void; + overlay: ReactNode; +}) { + const { t } = useTranslation(); + // A retained tab keeps its fullscreen editor mounted while hidden; the global exit + // action (Escape, hardware back) belongs only to the pane the user is in. + const { isActiveComposer } = useComposerKeyboardScope(); + const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit); + const railOffset = resolveGlyphRailOffset({ + controlSize: CONTROL_SIZE, + sendIconSize, + glyphIconSize: FULLSCREEN_GLYPH_SIZE, + }); + const maximizeIcon = useCallback( + (color: string) => , + [], + ); + const minimizeIcon = useCallback( + (color: string) => , + [], + ); + // The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge. + const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]); + const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]); + return ( + + {fullscreen ? ( + + + + ) : null} + + + {fullscreen ? null : attachmentSlot} + + {surface} + {showFullscreenButton && !fullscreen ? ( + + ) : null} + + {fullscreen ? null : toolbar} + + + {fullscreen ? ( + + {error} + {send} + + ) : null} + {overlay} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + inline: { flexShrink: 1, position: "relative" }, + inlineTextSurface: { flexShrink: 1, position: "relative" }, + iconButton: { + width: CONTROL_SIZE, + height: CONTROL_SIZE, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + enterButton: { + position: "absolute", + top: 0, + width: CONTROL_SIZE, + height: CONTROL_SIZE, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + fullscreen: { + flex: 1, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[4], + gap: theme.spacing[3], + }, + fullscreenInputWrapper: { flex: 1, minHeight: 0 }, + fullscreenTextSurface: { flex: 1, minHeight: 0 }, + exitRow: { alignItems: "flex-end", flexShrink: 0 }, + // The send error rides the keyboard with Send; below the body it would sit behind it. + footer: { flexShrink: 0, gap: theme.spacing[3] }, + sendRow: { alignItems: "flex-end" }, +})); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 5503ea1512..6f2cdef74d 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -4,6 +4,9 @@ import { computeCanStartDictation, resolveActiveSendBehavior, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, + resolveGlyphRailOffset, + resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -316,3 +319,46 @@ describe("stopRealtimeVoice", () => { expect(calls).toEqual(["cancel agent", "stop voice"]); }); }); + +describe("composer surface text", () => { + it("measures a new nonempty replacement before it reaches the editing handle", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Short draft", replacement, "draft:1")).toBe( + replacement.text, + ); + }); + + it("keeps live typing once the replacement is applied", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Edited live draft", replacement, "draft:2")).toBe( + "Edited live draft", + ); + }); + + it("keeps the live buffer when the initial publication is already applied", () => { + const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" }; + expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe( + "Live edited draft", + ); + }); +}); + +describe("retained selection", () => { + it("keeps a caret that fits and clamps one that no longer does", () => { + expect(resolveRetainedSelection({ start: 4, end: 17 }, 40)).toEqual({ start: 4, end: 17 }); + expect(resolveRetainedSelection({ start: 4, end: 17 }, 10)).toEqual({ start: 4, end: 10 }); + expect(resolveRetainedSelection({ start: 12, end: 17 }, 10)).toEqual({ start: 10, end: 10 }); + expect(resolveRetainedSelection({ start: -1, end: 3 }, 10)).toEqual({ start: 0, end: 3 }); + }); +}); + +describe("fullscreen glyph rail", () => { + it("nudges 16px fullscreen glyphs onto the Send arrow's ink edge", () => { + expect( + resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 16, glyphIconSize: 16 }), + ).toBeCloseTo(4 / 3); + expect( + resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 20, glyphIconSize: 16 }), + ).toBeCloseTo(1 / 6); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index a63aeb02dc..698965b5ac 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,6 +1,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; -import type { MessagePayload } from "@/composer/types"; +import type { MessagePayload, TextReplacement } from "@/composer/types"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; export type SendBehavior = ActiveTurnBehavior | "queue"; @@ -192,3 +192,39 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise< await ctx.voice.stopVoice(); } + +/** + * Offset that aligns a glyph's visible ink with the Send arrow's ink when both sit in + * equal controls on the same right rail. In Lucide's 24px viewBox ArrowUp ends at + * x19 while Maximize2/Minimize2 end at x21, so equal boxes leave the fullscreen + * glyphs visibly past the arrow. + */ +export function resolveGlyphRailOffset(input: { + controlSize: number; + sendIconSize: number; + glyphIconSize: number; +}): number { + const arrowInkInset = + (input.controlSize - input.sendIconSize) / 2 + (5 * input.sendIconSize) / 24; + const glyphInkInset = + (input.controlSize - input.glyphIconSize) / 2 + (3 * input.glyphIconSize) / 24; + return arrowInkInset - glyphInkInset; +} + +export function resolveComposerSurfaceText( + liveText: string, + replacement: TextReplacement, + appliedReplacementKey: string, +): string { + return replacement.key === appliedReplacementKey ? liveText : replacement.text; +} + +/** A retained caret must fit the text the remounted editor shows. */ +export function resolveRetainedSelection( + retained: { start: number; end: number }, + textLength: number, +): { start: number; end: number } { + const end = Math.min(Math.max(retained.end, 0), textLength); + const start = Math.min(Math.max(retained.start, 0), end); + return { start, end }; +} diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 6e70fa3edf..29df20012c 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -96,6 +96,35 @@ describe("ComposerTextInput web IME composition", () => { expect(document.activeElement).toBe(mounted.textarea); }); + it("keeps its editing handle and selection across a rerender during composition", () => { + const recorder = createTextRecorder(); + const mounted = mountInput(recorder.onChangeText); + const handle = mounted.inputRef.current; + act(() => { + handle?.focus(); + dispatchComposition(mounted.textarea, "compositionstart"); + typeFromIme(mounted.textarea, "你好"); + mounted.textarea.setSelectionRange(1, 2); + mounted.root.render( + , + ); + }); + expect(mounted.inputRef.current).toBe(handle); + expect(handle?.getNativeRef()).toBe(mounted.textarea); + expect([mounted.textarea.selectionStart, mounted.textarea.selectionEnd]).toEqual([1, 2]); + expect(document.activeElement).toBe(mounted.textarea); + expect(recorder.changes).toEqual([]); + act(() => dispatchComposition(mounted.textarea, "compositionend")); + expect(recorder.changes).toEqual(["你好"]); + expect(handle?.getText()).toBe("你好"); + }); + it("keeps locally typed text when its parent rerenders with a stale value", () => { const recorder = createTextRecorder(); const mounted = mountInput(recorder.onChangeText); diff --git a/packages/app/src/composer/keyboard-scope.tsx b/packages/app/src/composer/keyboard-scope.tsx index b254c53a51..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/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index 0b74c7e1da..9ebc92ed20 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -1,7 +1,8 @@ -import React, { act, type ReactNode } from "react"; +import React, { act, useCallback, useState, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { Pressable, Text } from "react-native"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RetainedPanelActivity } from "@/components/retained-panel"; import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks"; const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }]; @@ -109,6 +110,43 @@ describe("composer track panel", () => { expect(row("row")).not.toBeNull(); }); + it("closes for good when the panel it belongs to leaves the screen", () => { + const onPress = vi.fn(); + function Harness() { + const [active, setActive] = useState(true); + const hide = useCallback(() => setActive(false), []); + const show = useCallback(() => setActive(true), []); + return ( + <> + + + + + + Subagent one + + + + + ); + } + mount(); + + openPanel(); + expect(isPanelOpen()).toBe(true); + + click(row("hide") as Element); + click(row("show") as Element); + + expect(isPanelOpen()).toBe(false); + expect(row("row")).toBeNull(); + }); + it("leaves the panel alone when a row's own action button is pressed", () => { const onPress = vi.fn(); const onAction = vi.fn(); diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index 2f03b7e89a..cb46412de0 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -9,7 +9,12 @@ export interface MessagePayload { forceSend?: boolean; } +/** + * Text the draft owner hands the editor. `hydrated` is the owner's answer to a mount: + * the draft's text as persisted or still pending from the editor that unmounted. + */ export interface TextReplacement { + kind: "initial" | "hydrated" | "replace"; key: string; text: string; } diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 721708715c..9e9a15f6b4 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -33,13 +33,18 @@ export { getIsElectronMac as getIsElectronRuntimeMac, } from "./platform"; +/** Compact layouts are the breakpoints below `md`. */ +export function isCompactBreakpoint(breakpoint: string | undefined): boolean { + return breakpoint === "xs" || breakpoint === "sm"; +} + /** * Reactive hook — re-renders the component when the breakpoint changes. * Always use this instead of reading UnistylesRuntime.breakpoint directly. */ export function useIsCompactFormFactor(): boolean { const { rt } = useUnistyles(); - return rt.breakpoint === "xs" || rt.breakpoint === "sm"; + return isCompactBreakpoint(rt.breakpoint); } // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 6a2c42a617..430e4ade74 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -29,7 +29,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; -import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; +import type { AudioPlaybackSource } from "@/audio"; import { selectAgentTimelineState, toDaemonServerInfo, @@ -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/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 7bb0b9b7e1..4c8d04e2f1 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -9,8 +9,8 @@ import { } from "react"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useSessionStore } from "@/stores/session-store"; -import { createAudioEngine } from "@/voice/audio-engine"; -import type { AudioEngine } from "@/voice/audio-engine-types"; +import { createAudioEngine } from "@/audio"; +import type { AudioEngine } from "@/audio"; import { createVoiceRuntime, type VoiceRuntime, diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 1fdb1c6f5d..1bd32db53d 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -8,6 +8,7 @@ export interface AcpProviderCatalogEntry { iconSvg: string | null; installLink: string; command: readonly [string, ...string[]]; + bundledProvider?: boolean; env?: Readonly>; params?: Readonly>; } @@ -238,6 +239,16 @@ const CATALOG_DATA = [ installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", command: ["hermes", "acp"], }, + { + id: "hoplite", + bundledProvider: true, + title: "Hoplite", + description: "Cloud coding agent through the local hoplite acp bridge", + version: "manual", + iconId: "hoplite", + installLink: "https://hoplite.sh/docs/cli#install", + command: ["hoplite", "acp"], + }, { id: "junie", title: "Junie", @@ -389,6 +400,7 @@ export const ACP_PROVIDER_CATALOG: AcpProviderCatalogEntry[] = CATALOG_DATA.map( version: entry.version, installLink: entry.installLink, command: entry.command, + bundledProvider: "bundledProvider" in entry ? entry.bundledProvider : undefined, env: "env" in entry ? entry.env : undefined, params: "params" in entry ? entry.params : undefined, iconSvg: entry.iconId ? (ACP_PROVIDER_ICON_SVGS[entry.iconId] ?? null) : null, diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index 01fba418a9..3bf9e99f9b 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -199,6 +199,71 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage { } describe("server data push router", () => { + it("keeps a visible diff subscribed when a hidden panel switches to the same comparison", () => { + const queryClient = new QueryClient(); + const fake = createFakeClient(); + const serverId = "server-1"; + const cwd = "/repo"; + const options = (mode: "base" | "uncommitted", enabled: boolean) => { + const queryKey = checkoutDiffQueryKey( + serverId, + cwd, + mode, + mode === "base" ? "main" : undefined, + ); + return { + queryKey, + queryFn: skipToken as typeof skipToken, + gcTime: Infinity, + meta: checkoutDiffPushRoute({ + enabled, + serverId, + cwd, + subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`, + compare: { mode, ...(mode === "base" ? { baseRef: "main" } : {}) }, + }), + }; + }; + const visible = new QueryObserver(queryClient, options("base", true)); + const hidden = new QueryObserver(queryClient, options("base", false)); + const unsubscribeVisible = visible.subscribe(() => {}); + const unsubscribeHidden = hidden.subscribe(() => {}); + const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId }); + try { + visible.setOptions(options("uncommitted", true)); + hidden.setOptions(options("uncommitted", false)); + const subscription = fake.subscribeCheckoutDiffCalls.findLast( + (call) => call.compare.mode === "uncommitted", + )!; + const files = [ + { + path: "README.md", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 0, + hunks: [], + }, + ]; + fake.emit({ + type: "checkout_diff_update", + payload: { subscriptionId: subscription.subscriptionId, cwd, files, error: null }, + }); + expect(queryClient.getQueryData(options("uncommitted", true).queryKey)).toMatchObject({ + files, + }); + expect(fake.unsubscribeCheckoutDiffCalls).not.toContain(subscription.subscriptionId); + + unsubscribeVisible(); + expect(fake.unsubscribeCheckoutDiffCalls).toContain(subscription.subscriptionId); + } finally { + unsubscribeVisible(); + unsubscribeHidden(); + unmount(); + queryClient.clear(); + } + }); + it("retains equal diff files without reconstructing changed file bodies", () => { const queryClient = new QueryClient(); const fake = createFakeClient(); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index fc6d51ce4f..5ab3069bd0 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -494,6 +494,14 @@ function getActiveServerDataRoute( if (query.getObserversCount() === 0) { return null; } + // Query metadata belongs to the last observer that updated its options. A + // retained hidden panel must not cancel demand from another visible panel. + const observerRoutes = query.observers + .map((observer) => getServerDataRoute({ meta: observer.options.meta })) + .filter((route): route is ServerDataRoute => route !== null); + if (observerRoutes.length > 0) { + return observerRoutes.find((route) => route.enabled && route.serverId === serverId) ?? null; + } const route = getServerDataRoute(query); if (route) { return route.enabled && route.serverId === serverId ? route : null; @@ -590,7 +598,7 @@ function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"]) ); } -function getServerDataRoute(query: Query): ServerDataRoute | null { +function getServerDataRoute(query: Pick): ServerDataRoute | null { const meta = query.meta; if (!isRecord(meta) || !isRecord(meta.serverData)) { return null; diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index 9209f6fcbf..982469008a 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -80,7 +80,6 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps) type: "svg", errorCorrectionLevel: "M", margin: 1, - width: 480, }), enabled: Boolean(pairingQuery.data?.url), dataShape: "value", @@ -275,7 +274,8 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) { return ( @@ -365,10 +365,6 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.border, backgroundColor: theme.colors.palette.white, }, - qrImage: { - width: "100%", - height: "100%", - }, linkRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index d396697c41..8b04590d28 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -165,4 +165,7 @@ const FRAME_STYLE = { minHeight: 0, minWidth: 0, } as const; -const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const; +// CodeMirror's scrollIntoView scrolls every overflowing ancestor and stops at a fixed or sticky +// one. Sticky (with no offsets it lays out like relative) keeps line reveals inside the editor, so +// they don't shift the host around it, such as the compact workspace sheet. +const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden", position: "sticky" } as const; diff --git a/packages/app/src/file-pane/markdown-preview/image-source.test.ts b/packages/app/src/file-pane/markdown-preview/image-source.test.ts new file mode 100644 index 0000000000..8277e57492 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source"; + +describe("resolveMarkdownFileDirectory", () => { + it("uses the parent of an absolute markdown path", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "/Users/test/project/docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs"); + }); + + it("joins a workspace-relative markdown path to the workspace root", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "docs/星谷云/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/星谷云"); + }); + + it("preserves the host home directory for home-relative markdown paths", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans"); + }); +}); + +describe("resolveMarkdownPreviewImageSource", () => { + it("passes through remote and data URIs", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "https://example.com/image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("https://example.com/image.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "data:image/png;base64,abc", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("data:image/png;base64,abc"); + }); + + it("resolves a sibling image against the markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/images/hero.png"); + }); + + it("ignores local URL query strings and fragments", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/images/hero.png"); + }); + + it("preserves encoded filename characters when stripping URL suffixes", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./hero%23draft%3Fv.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/hero#draft?v.png"); + }); + + it("preserves remote URL query strings and fragments", () => { + const source = "https://example.com/hero.png?v=2#preview"; + expect(resolveMarkdownPreviewImageSource({ source, markdownPath: "docs/guide.md" })).toBe( + source, + ); + }); + + it("resolves parent-relative images that stay inside the workspace", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../../data/cache/xinggu_docs/12-linkedin/token.png", + markdownPath: "docs/星谷云/12-LinkedIn.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png"); + }); + + it("keeps absolute and home-relative image paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "/tmp/screenshot.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/screenshot.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "~/.paseo/screenshots/output.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/screenshots/output.png"); + }); + + it("normalizes file URIs into host paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/paseo-preview.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/paseo-preview.png"); + }); + + it("resolves images beside a home-relative document without using the workspace root", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "screenshots/output.png", + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans/screenshots/output.png"); + }); + + it("decodes a file URI exactly once", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/hero%2520image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/tmp/hero%20image.png"); + }); + + it("resolves parent segments from a home-relative document", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../images/hero.png", + markdownPath: "~/docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("~/images/hero.png"); + }); + + it("resolves Windows relative images against a drive-rooted markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "..\\images\\hero.png", + markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md", + workspaceRoot: "C:\\Users\\test\\repo", + }), + ).toBe("C:/Users/test/repo/images/hero.png"); + }); +}); diff --git a/packages/app/src/file-pane/markdown-preview/image-source.ts b/packages/app/src/file-pane/markdown-preview/image-source.ts new file mode 100644 index 0000000000..5316d94279 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.ts @@ -0,0 +1,150 @@ +import { localFileSourceToPath } from "@/attachments/utils"; +import { isAbsolutePath } from "@/utils/path"; + +function trimTrailingSeparators(value: string): string { + if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) { + return value.replace(/\\/g, "/"); + } + return value.replace(/[\\/]+$/, ""); +} + +function normalizePathSegments(value: string): string { + const segments: string[] = []; + for (const segment of value.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length > 0) { + segments.pop(); + } + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + +function normalizeAbsolutePath(value: string): string | null { + const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/")); + if (isHomeRelativePath(normalizedInput)) { + return `~/${normalizePathSegments(normalizedInput.slice(2))}`; + } + if (!isAbsolutePath(normalizedInput)) { + return null; + } + + const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput); + if (drivePath) { + return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`); + } + + const prefix = normalizedInput.startsWith("//") ? "//" : "/"; + return trimTrailingSeparators( + `${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`, + ); +} + +function parentDirectory(path: string): string | null { + const normalized = trimTrailingSeparators(path.replace(/\\/g, "/")); + if (!normalized) { + return null; + } + if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) { + return normalized.endsWith("/") ? normalized : `${normalized}/`; + } + + const index = normalized.lastIndexOf("/"); + if (index < 0) { + return null; + } + if (index === 0) { + return "/"; + } + const parent = normalized.slice(0, index); + if (/^[A-Za-z]:$/.test(parent)) { + return `${parent}/`; + } + return parent; +} + +function joinAbsolute(baseDirectory: string, relativePath: string): string | null { + const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/")); + const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); + if (!directory) { + return null; + } + return normalizeAbsolutePath(`${directory}/${relative}`); +} + +function isHomeRelativePath(value: string): boolean { + return value === "~" || value.startsWith("~/") || value.startsWith("~\\"); +} + +function decodeRelativeSource(source: string): string { + try { + return decodeURIComponent(source); + } catch { + return source; + } +} + +export function resolveMarkdownFileDirectory(input: { + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const markdownPath = input.markdownPath.trim().replace(/\\/g, "/"); + if (!markdownPath) { + return null; + } + + if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) { + const absolute = normalizeAbsolutePath(markdownPath); + return absolute ? parentDirectory(absolute) : null; + } + + const workspaceRoot = input.workspaceRoot?.trim(); + if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) { + return null; + } + + const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath); + return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null; +} + +export function resolveMarkdownPreviewImageSource(input: { + source: string; + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const source = input.source.trim(); + if (!source) { + return null; + } + + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } + + const pathSource = source.replace(/[?#].*$/, ""); + const path = localFileSourceToPath( + pathSource.startsWith("file://") ? pathSource : decodeRelativeSource(pathSource), + ); + if (!path) { + return null; + } + + if (isHomeRelativePath(path) || isAbsolutePath(path)) { + return path; + } + + const directory = resolveMarkdownFileDirectory({ + markdownPath: input.markdownPath, + workspaceRoot: input.workspaceRoot, + }); + if (!directory) { + return path; + } + + return joinAbsolute(directory, path); +} diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx index e8d172c61b..a79705323f 100644 --- a/packages/app/src/file-pane/markdown-preview/index.tsx +++ b/packages/app/src/file-pane/markdown-preview/index.tsx @@ -1,11 +1,59 @@ import { useMemo } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ASTNode, RenderRules } from "react-native-markdown-display"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { MarkdownRenderer } from "@/components/markdown/renderer"; +import { + createSharedMarkdownRules, + MarkdownRenderer, + type MarkdownStyles, +} from "@/components/markdown/renderer"; import { parseMarkdownPreviewDocument } from "./document"; +import { resolveMarkdownPreviewImageSource } from "./image-source"; +import { FileMarkdownPreviewImage } from "./preview-image"; -export function FileMarkdownPreview({ source }: { source: string }) { +export function FileMarkdownPreview({ + source, + filePath, + workspaceRoot, + client, + serverId, +}: { + source: string; + filePath: string; + workspaceRoot?: string; + client?: DaemonClient | null; + serverId?: string; +}) { const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]); + const rules = useMemo( + () => ({ + ...createSharedMarkdownRules(), + image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => { + const resolved = resolveMarkdownPreviewImageSource({ + source: String(node.attributes?.src ?? ""), + markdownPath: filePath, + workspaceRoot, + }); + if (!resolved) { + return null; + } + + return ( + + ); + }, + }), + [client, filePath, serverId, workspaceRoot], + ); return ( @@ -31,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) { ))} ) : null} - + ); diff --git a/packages/app/src/file-pane/markdown-preview/preview-image.tsx b/packages/app/src/file-pane/markdown-preview/preview-image.tsx new file mode 100644 index 0000000000..ef27c14978 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/preview-image.tsx @@ -0,0 +1,151 @@ +import { useCallback, useMemo, useState } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useTranslation } from "react-i18next"; +import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import type { Theme } from "@/styles/theme"; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const PREVIEW_IMAGE_MIN_HEIGHT = 160; + +export function FileMarkdownPreviewImage({ + source, + occurrenceKey, + alt, + client, + workspaceRoot, + serverId, +}: { + source: string; + occurrenceKey: string; + alt?: string; + client?: DaemonClient | null; + workspaceRoot?: string; + serverId?: string; +}) { + const { t } = useTranslation(); + const [viewerOpen, setViewerOpen] = useState(false); + const openViewer = useCallback(() => setViewerOpen(true), []); + const closeViewer = useCallback(() => setViewerOpen(false), []); + const image = useAssistantImage({ + source, + occurrenceKey, + client, + workspaceRoot, + serverId, + }); + const binding = image.status === "failed" ? null : image.binding; + const aspectRatio = image.status === "failed" ? null : image.aspectRatio; + const imageUri = binding?.uri ?? ""; + const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]); + const imageSizeStyle = useMemo(() => { + if (aspectRatio) { + return { aspectRatio }; + } + return { height: PREVIEW_IMAGE_MIN_HEIGHT }; + }, [aspectRatio]); + const surfaceStyle = useMemo>( + () => [styles.surface, imageSizeStyle], + [imageSizeStyle], + ); + const lightboxSource = useMemo(() => { + if (!viewerOpen || !imageUri) return null; + return { + type: "uri", + uri: imageUri, + contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined, + }; + }, [aspectRatio, imageUri, viewerOpen]); + + if (image.status === "failed") { + return ( + + {image.message} + + ); + } + + if (!binding) { + return ( + + + + ); + } + + return ( + + + + + {image.status === "loading" ? ( + + + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + frame: { + width: "100%", + marginBottom: theme.spacing[4], + }, + surface: { + width: "100%", + overflow: "hidden", + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + image: { + width: "100%", + height: "100%", + }, + state: { + minHeight: PREVIEW_IMAGE_MIN_HEIGHT, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[6], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + }, + loadingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: "center", + justifyContent: "center", + }, + errorText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + textAlign: "center", + }, +})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index a5630ed82f..ea3c5b1edb 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -54,6 +54,9 @@ interface FilePreviewBodyProps { location: WorkspaceFileLocation; navigationRevision: number; imagePreviewUri: string | null; + workspaceRoot: string; + client: DaemonClient | null; + serverId: string; } type TextExplorerFile = ExplorerFile & { kind: "text" }; @@ -135,6 +138,9 @@ function FilePreviewBody({ location, navigationRevision, imagePreviewUri, + workspaceRoot, + client, + serverId, }: FilePreviewBodyProps) { const { t } = useTranslation(); const filePath = location.path; @@ -182,7 +188,13 @@ function FilePreviewBody({ style={styles.previewContent} showsVerticalScrollIndicator > - + ); @@ -297,6 +309,7 @@ export function FilePane({ return ( ); } function EditableFilePane({ + serverId, + workspaceRoot, client, cwd, path, @@ -470,6 +492,8 @@ function EditableFilePane({ location, navigationRevision, }: { + serverId: string; + workspaceRoot: string; client: DaemonClient; cwd: string; path: string; @@ -622,6 +646,9 @@ function EditableFilePane({ location={location} navigationRevision={navigationRevision} imagePreviewUri={null} + workspaceRoot={workspaceRoot} + client={client} + serverId={serverId} /> )} diff --git a/packages/app/src/git/diff-document/hit-testing.test.ts b/packages/app/src/git/diff-document/hit-testing.test.ts index a0ed9c1578..d728c2f2d7 100644 --- a/packages/app/src/git/diff-document/hit-testing.test.ts +++ b/packages/app/src/git/diff-document/hit-testing.test.ts @@ -315,6 +315,7 @@ function reviewActionsForFirstAddition(): NonNullable< commentsByTarget: new Map(), editor: { target, commentId: null, body: "" }, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts index c2b6d86c9c..ace965c1ce 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -389,6 +389,7 @@ describe("diff document model", () => { commentId: null, }, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, @@ -790,6 +791,7 @@ function reviewActionsWithEditor( commentsByTarget: new Map(), editor: target ? { target, body: "", commentId: null } : null, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 139387ccb1..6bafd3eee3 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1,3 +1,5 @@ +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { floatingActionsClearance } from "@/components/ui/floating-action-button"; import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -31,6 +33,7 @@ import { import { type ParsedDiffFile } from "@/git/use-diff-query"; import type { ChangesState } from "@/panels/changes/state"; import { defaultChangesState } from "@/panels/changes/state"; +import { ReviewCommentSheet, ReviewFeedbackAction } from "@/review"; import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document"; import { ChangedFilesTree } from "@/git/changed-files-tree"; import { JUMP_TO_FILE_CLEARANCE, JumpToFile } from "@/git/jump-to-file"; @@ -1548,7 +1551,9 @@ export function ChangesSurface({ isDiffLoading, reviewActions, reviewAttachment, + reviewFeedback, } = useWorkingDiff({ + presentation, serverId, workspaceId: workspaceId ?? undefined, cwd, @@ -1729,6 +1734,7 @@ export function ChangesSurface({ ], ); + const { bottom: bottomInset } = useSafeAreaInsets(); const hasChanges = files.length > 0; const jumpToFileInset = jumpToFileClearance({ isCompact: isMobile, @@ -1778,27 +1784,41 @@ export function ChangesSurface({ }); const diffContent: ReactElement = ( - - + + + + - + ); const bodyContent = ( + + summarizeChecks(checks), [checks]); + const { t } = useTranslation(); + const summary = useMemo(() => summarizeChecks(checks, t), [checks, t]); const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState(); return ( @@ -91,13 +93,14 @@ export function ChecksSection({ {summary.parts.length > 0 ? ( + {summary.detailLead} {summary.parts.map((part, index) => ( {index > 0 ? ", " : ""} {part.text} ))} - {` ${summary.countNoun}`} + {summary.detailTrail} ) : null} @@ -186,6 +189,7 @@ function CheckRow({ isAddingLogsToChat: boolean; onAddLogsToChat: (check: PrPaneCheck) => void; }) { + const { t } = useTranslation(); const handlePress = useCallback(() => { void openExternalUrl(check.url); }, [check.url]); @@ -217,7 +221,9 @@ function CheckRow({ onPress={handleAddLogsToChat} style={styles.addButton} > - {isAddingLogsToChat ? "Adding..." : "Add to chat"} + {isAddingLogsToChat + ? t("workspace.git.pr.actions.addingToChat") + : t("workspace.git.pr.actions.addToChat")} ) : null} {check.timing && {check.timing}} diff --git a/packages/app/src/git/pull-request-panel/checks-summary.test.ts b/packages/app/src/git/pull-request-panel/checks-summary.test.ts index f014500753..747947b907 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.test.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import type { CheckStatus } from "./check-status"; import { summarizeChecks } from "./checks-summary"; import type { PrPaneCheck } from "./data"; @@ -9,7 +10,7 @@ function check(name: string, status: CheckStatus): PrPaneCheck { describe("summarizeChecks", () => { it("reports no checks without claiming the run passed", () => { - const summary = summarizeChecks([]); + const summary = summarizeChecks([], i18n.t); expect(summary.outcome).toBe("none"); expect(summary.headline).toBe("No checks"); @@ -18,11 +19,10 @@ describe("summarizeChecks", () => { }); it("leads with failures when anything failed", () => { - const summary = summarizeChecks([ - check("build", "success"), - check("lint", "failure"), - check("e2e", "pending"), - ]); + const summary = summarizeChecks( + [check("build", "success"), check("lint", "failure"), check("e2e", "pending")], + i18n.t, + ); expect(summary.outcome).toBe("failure"); expect(summary.headline).toBe("Some checks were not successful"); @@ -31,7 +31,7 @@ describe("summarizeChecks", () => { }); it("reports an unfinished run as in progress when nothing failed", () => { - const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")]); + const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")], i18n.t); expect(summary.outcome).toBe("pending"); expect(summary.headline).toBe("Some checks haven't completed yet"); @@ -39,7 +39,10 @@ describe("summarizeChecks", () => { }); it("treats a run of only successes and skips as passed", () => { - const summary = summarizeChecks([check("build", "success"), check("deploy", "skipped")]); + const summary = summarizeChecks( + [check("build", "success"), check("deploy", "skipped")], + i18n.t, + ); expect(summary.outcome).toBe("success"); expect(summary.headline).toBe("All checks have passed"); @@ -47,11 +50,10 @@ describe("summarizeChecks", () => { }); it("keeps the checks of each group together under a counted label", () => { - const summary = summarizeChecks([ - check("build", "failure"), - check("lint", "success"), - check("test", "failure"), - ]); + const summary = summarizeChecks( + [check("build", "failure"), check("lint", "success"), check("test", "failure")], + i18n.t, + ); const [failing, successful] = summary.groups; expect(failing?.label).toBe("2 failing checks"); @@ -60,9 +62,34 @@ describe("summarizeChecks", () => { }); it("says check rather than checks for a run of one", () => { - const summary = summarizeChecks([check("build", "failure")]); + const summary = summarizeChecks([check("build", "failure")], i18n.t); - expect(summary.countNoun).toBe("check"); expect(summary.detail).toBe("1 failing check"); }); + + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + const summary = summarizeChecks( + [ + check("build", "success"), + check("deploy", "skipped"), + check("lint", "failure"), + check("test", "failure"), + ], + i18n.t, + ); + + expect(summary.headline).toBe("Certaines vérifications ont échoué"); + expect(summary.detail).toBe("Vérifications : 2 en échec, 1 réussie(s), 1 ignorée(s)"); + expect(summary.groups.map((group) => group.label)).toEqual([ + "2 vérifications en échec", + "1 vérification réussie", + "1 vérification ignorée", + ]); + expect(summarizeChecks([], i18n.t).headline).toBe("Aucune vérification"); + } finally { + await i18n.changeLanguage("en"); + } + }); }); diff --git a/packages/app/src/git/pull-request-panel/checks-summary.ts b/packages/app/src/git/pull-request-panel/checks-summary.ts index f2f9b30a38..977706a9af 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.ts @@ -1,4 +1,5 @@ import { classifyCheck, type CheckPresentation } from "@/git/check-presentation"; +import type { TFunction } from "i18next"; import type { PrPaneCheck } from "./data"; /** @@ -16,27 +17,10 @@ const PRESENTATION_ORDER = [ "ignored", ] as const satisfies readonly CheckPresentation[]; -/** How each status reads inside a count phrase: "2 failing", "4 in progress". */ -const PRESENTATION_NOUN: Record = { - actionRequired: "needs action", - warning: "warning", - failure: "failing", - pending: "in progress", - manual: "manual", - success: "successful", - ignored: "skipped", -}; - /** The worst thing happening in the run, which is what the headline and the ring report. */ export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none"; -const OUTCOME_HEADLINE: Record = { - actionRequired: "Some checks need your attention", - failure: "Some checks were not successful", - pending: "Some checks haven't completed yet", - success: "All checks have passed", - none: "No checks", -}; +const COPY = "workspace.git.pr.checksOverview"; export interface ChecksCountPart { status: CheckPresentation; @@ -58,8 +42,12 @@ export interface ChecksSummary { headline: string; /** The count phrases behind `detail`, kept apart so the header can label each one. */ parts: readonly ChecksCountPart[]; - /** The noun closing the detail line — "checks", or "check" for a run of one. */ - countNoun: string; + /** + * The words around the count phrases in `detail`: "" and " checks" in English, where the noun + * closes the line, while other languages lead with it. + */ + detailLead: string; + detailTrail: string; /** The whole line: "2 failing, 4 in progress, 1 successful checks". Empty with no checks. */ detail: string; total: number; @@ -72,7 +60,7 @@ export interface ChecksSummary { * header, the ring, and the grouped list all read from one derivation instead of each * filtering the array again with its own idea of what counts. */ -export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { +export function summarizeChecks(checks: readonly PrPaneCheck[], t: TFunction): ChecksSummary { const groups: ChecksGroup[] = []; const parts: ChecksCountPart[] = []; @@ -83,24 +71,27 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { } groups.push({ status, - label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`, + label: t(`${COPY}.${matching.length === 1 ? "groupOne" : "groupMany"}.${status}`, { + count: matching.length, + }), checks: matching, }); parts.push({ status, count: matching.length, - text: `${matching.length} ${PRESENTATION_NOUN[status]}`, + text: t(`${COPY}.count.${status}`, { count: matching.length }), }); } const outcome = selectOutcome(checks); - const noun = countNoun(checks.length); + const { lead, trail } = detailFrame(checks.length, t); return { outcome, - headline: OUTCOME_HEADLINE[outcome], + headline: t(`${COPY}.headline.${outcome}`), parts, - countNoun: noun, - detail: parts.length === 0 ? "" : `${parts.map((part) => part.text).join(", ")} ${noun}`, + detailLead: lead, + detailTrail: trail, + detail: parts.length === 0 ? "" : `${lead}${parts.map((part) => part.text).join(", ")}${trail}`, total: checks.length, groups, }; @@ -131,6 +122,13 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome { return "success"; } -function countNoun(count: number): string { - return count === 1 ? "check" : "checks"; +/** + * The header renders each count phrase on its own, so the translated line is split around + * where the phrases go. + */ +function detailFrame(total: number, t: TFunction): { lead: string; trail: string } { + const marker = "\u0000"; + const line = t(`${COPY}.${total === 1 ? "detailOne" : "detailMany"}`, { parts: marker }); + const [lead = "", trail = ""] = line.split(marker); + return { lead, trail }; } diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index 21b145bedc..837433417e 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -9,12 +9,13 @@ import { isPipelineActiveStatus, mapPipelineStatus, } from "@/git/forges/gitlab"; +import { i18n } from "@/i18n/i18next"; import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors"; import { deriveAvatarColor, formatAge, - getActivityVerb, - getStateLabel, + getActivityVerbKey, + getStateLabelKey, mapPrPaneData, } from "./data"; @@ -779,37 +780,34 @@ describe("formatAge", () => { }); }); -describe("getStateLabel", () => { +const english = i18n.getFixedT("en"); +const french = i18n.getFixedT("fr"); + +describe("getStateLabelKey", () => { it.each([ - ["open", "Open"], - ["draft", "Draft"], - ["merged", "Merged"], - ["closed", "Closed"], - ] as const)("maps %s → %s", (state, expected) => { - expect(getStateLabel(state)).toBe(expected); + ["open", "Open", "Ouverte"], + ["draft", "Draft", "Brouillon"], + ["merged", "Merged", "Fusionnée"], + ["closed", "Closed", "Fermée"], + ] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => { + expect(english(getStateLabelKey(state))).toBe(englishLabel); + expect(french(getStateLabelKey(state))).toBe(frenchLabel); }); }); -describe("getActivityVerb", () => { - it("returns Commented for comment kind", () => { - expect(getActivityVerb({ kind: "comment" })).toBe("Commented"); - }); - - it("returns Approved for approved review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved"); - }); - - it("returns Requested changes for changes_requested review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe( +describe("getActivityVerbKey", () => { + it.each([ + [{ kind: "comment" }, "Commented", "A commenté"], + [{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"], + [ + { kind: "review", reviewState: "changes_requested" }, "Requested changes", - ); - }); - - it("returns Reviewed for a commented review with body (generic case)", () => { - expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed"); - }); - - it("returns Reviewed when reviewState is undefined", () => { - expect(getActivityVerb({ kind: "review" })).toBe("Reviewed"); + "A demandé des modifications", + ], + [{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"], + [{ kind: "review" }, "Reviewed", "A relu"], + ] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => { + expect(english(getActivityVerbKey(item))).toBe(englishVerb); + expect(french(getActivityVerbKey(item))).toBe(frenchVerb); }); }); diff --git a/packages/app/src/git/pull-request-panel/data.ts b/packages/app/src/git/pull-request-panel/data.ts index 0f1e2014e9..d1275f2f1e 100644 --- a/packages/app/src/git/pull-request-panel/data.ts +++ b/packages/app/src/git/pull-request-panel/data.ts @@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null { } } -export function getStateLabel(state: PrState): string { - if (state === "draft") return "Draft"; - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return "Open"; +export function getStateLabelKey(state: PrState) { + if (state === "draft") return "workspace.git.pr.states.draft"; + if (state === "merged") return "workspace.git.pr.states.merged"; + if (state === "closed") return "workspace.git.pr.states.closed"; + return "workspace.git.pr.states.open"; } -export function getActivityVerb(item: Pick): string { - if (item.kind === "comment") return "Commented"; - if (item.reviewState === "approved") return "Approved"; - if (item.reviewState === "changes_requested") return "Requested changes"; - return "Reviewed"; +export function getActivityVerbKey(item: Pick) { + if (item.kind === "comment") return "workspace.git.pr.activity.commented"; + if (item.reviewState === "approved") return "workspace.git.pr.activity.approved"; + if (item.reviewState === "changes_requested") { + return "workspace.git.pr.activity.requestedChanges"; + } + return "workspace.git.pr.activity.reviewed"; } diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index b7c0890c3a..429dd2be0e 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -63,7 +63,7 @@ import { canAddPullRequestActivityToChat, } from "./context-attachment"; import { ChecksSection, getCheckIdentity } from "./checks-section"; -import { getActivityVerb, getStateLabel } from "./data"; +import { getActivityVerbKey, getStateLabelKey } from "./data"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { CheckPresentationIcon } from "@/git/check-presentation.view"; @@ -538,7 +538,7 @@ export function PullRequestPane({ - {getStateLabel(data.state)} + {t(getStateLabelKey(data.state))} {nativeHeaderMeta} {repoIdentity ? ( @@ -568,7 +568,7 @@ export function PullRequestPane({
- Add all to chat + {t("workspace.git.pr.actions.addAllToChat")} ) : null} {activityLoading ? : null} {!activityLoading && visibleEntries.length === 0 ? ( - No activity yet + {t("workspace.git.pr.empty.noActivity")} ) : null} {!activityLoading ? visibleEntries.map(({ entry, collapsed }) => ( @@ -702,14 +702,14 @@ function ActivityKebab({ {renderKebabTriggerIcon} {attachEnabled && canAddPullRequestActivityToChat(activity) ? ( - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {activity.body.trim() !== "" ? ( @@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu } function ActivityVerb({ activity }: { activity: PrPaneActivity }) { - const verb = getActivityVerb(activity).toLowerCase(); + const { t } = useTranslation(); + const verb = t(getActivityVerbKey(activity)).toLowerCase(); if (activity.kind === "review" && activity.reviewState === "approved") { return ( @@ -815,6 +816,7 @@ function SingleActivityCard({ entry: Extract; collapsed: boolean; }) { + const { t } = useTranslation(); const { activity } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -884,7 +886,7 @@ function SingleActivityCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -935,6 +937,7 @@ function ReviewCard({ collapsed: boolean; collapsedEntryIds: ReadonlySet; }) { + const { t } = useTranslation(); const { review, threads } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -961,7 +964,7 @@ function ReviewCard({ onPress={handleAddToChat} style={styles.checkAddButton} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {collapsed ? ( @@ -995,7 +998,7 @@ function ReviewCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -1063,8 +1066,12 @@ function ThreadBlock({ ? formatPullRequestThreadPath(thread.location) : t("workspace.git.pr.thread.discussion")} - {thread.isResolved ? : null} - {thread.location?.isOutdated ? : null} + {thread.isResolved ? ( + + ) : null} + {thread.location?.isOutdated ? ( + + ) : null} {collapsed ? ( @@ -1080,7 +1087,7 @@ function ThreadBlock({ {renderKebabTriggerIcon} @@ -1124,7 +1131,7 @@ function ThreadBlock({ leftIcon={MessageSquarePlus} onPress={handleAddThreadToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index 341cc4946d..daeda1479d 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -7,6 +7,7 @@ import { buildReviewDraftKey, useInlineReviewController, useReviewAttachmentSnapshot, + useReviewFeedback, } from "@/review"; import { useCheckoutDiffQuery } from "@/git/use-diff-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; @@ -19,6 +20,7 @@ interface UseWorkingDiffOptions { ignoreWhitespace: boolean; enabled: boolean; queryScope?: string; + presentation?: string; } export function useWorkingDiff({ @@ -28,6 +30,7 @@ export function useWorkingDiff({ ignoreWhitespace, enabled, queryScope, + presentation, }: UseWorkingDiffOptions) { const { status, @@ -81,7 +84,7 @@ export function useWorkingDiff({ }), [baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId], ); - const reviewActions = useInlineReviewController({ reviewDraftKey }); + const reviewActions = useInlineReviewController({ reviewDraftKey, presentation }); const reviewAttachment = useReviewAttachmentSnapshot({ key: reviewDraftKey, diffFiles: files, @@ -90,6 +93,13 @@ export function useWorkingDiff({ baseRef, }); + const reviewFeedback = useReviewFeedback({ + serverId, + workspaceId, + reviewDraftKey, + attachment: reviewAttachment, + }); + return { status, isStatusLoading, @@ -107,6 +117,7 @@ export function useWorkingDiff({ isDiffLoading, reviewActions, reviewAttachment, + reviewFeedback, }; } diff --git a/packages/app/src/hooks/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 63e360aba0..201c17c022 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -44,6 +44,7 @@ describe("ACP provider catalog", () => { expect(findProvider("codewhale").command).toEqual(["codewhale", "serve", "--acp"]); expect(findProvider("devin").command).toEqual(["devin", "acp"]); expect(findProvider("goose").command).toEqual(["goose", "acp"]); + expect(findProvider("hoplite").command).toEqual(["hoplite", "acp"]); expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]); expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]); expect(findProvider("poolside").command).toEqual(["pool", "acp"]); @@ -59,6 +60,32 @@ describe("ACP provider catalog", () => { expect(findProvider("minimax-code").iconSvg).toContain(" { + expect(findProvider("hoplite")).toMatchObject({ + title: "Hoplite", + command: ["hoplite", "acp"], + installLink: "https://hoplite.sh/docs/cli#install", + }); + expect(buildAcpProviderConfigPatch(findProvider("hoplite"), true)).toEqual({ + providers: { + hoplite: { enabled: true }, + }, + }); + const ids = getAcpProviderCatalog().map((entry) => entry.id); + expect(ids.indexOf("hermes")).toBeLessThan(ids.indexOf("hoplite")); + expect(ids.indexOf("hoplite")).toBeLessThan(ids.indexOf("junie")); + expect(findProvider("hoplite").iconSvg).toContain('fill="currentColor"'); + }); + + it.each([undefined, false])( + "rejects unsupported bundled Hoplite before producing a patch (%s)", + (supported) => { + expect(() => buildAcpProviderConfigPatch(findProvider("hoplite"), supported)).toThrow( + "Update the host to add Hoplite.", + ); + }, + ); + it("maps a catalog entry to the daemon provider config patch", () => { expect(buildAcpProviderConfigPatch(findProvider("amp-acp"))).toEqual({ providers: { diff --git a/packages/app/src/hooks/use-acp-provider-catalog.ts b/packages/app/src/hooks/use-acp-provider-catalog.ts index 92f9ca07e2..f20c31caf2 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.ts @@ -10,7 +10,13 @@ export function getAcpProviderCatalog(): AcpProviderCatalogItem[] { export function buildAcpProviderConfigPatch( entry: AcpProviderCatalogItem, + supportsHopliteProvider = false, ): MutableDaemonConfigPatch { + if (entry.bundledProvider) { + // COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter. + if (!supportsHopliteProvider) throw new Error("Update the host to add Hoplite."); + return { providers: { [entry.id]: { enabled: true } } }; + } return { providers: { [entry.id]: { diff --git a/packages/app/src/hooks/use-dictation-audio-source.native.ts b/packages/app/src/hooks/use-dictation-audio-source.native.ts index aa50a8eead..293412fe1a 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.native.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.native.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { Buffer } from "buffer"; import { useState } from "react"; -import { createAudioEngine } from "@/voice/audio-engine"; +import { createAudioEngine } from "@/audio"; import type { DictationAudioSource, diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index afb4b44499..c482bece18 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -1,3 +1,4 @@ +import { resampleToPcm16 } from "@/audio/pcm"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parsePcm16Wav } from "@/utils/pcm16-wav"; import { isElectronRuntime } from "@/desktop/host"; @@ -17,41 +18,6 @@ const getAudioContextCtor = (): typeof AudioContext | null => { return ctor ?? null; }; -const floatToInt16 = (sample: number): number => { - const clamped = Math.max(-1, Math.min(1, sample)); - return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); -}; - -const resampleToPcm16 = ( - input: Float32Array, - inputRate: number, - outputRate: number, -): Int16Array => { - if (input.length === 0) { - return new Int16Array(0); - } - if (inputRate === outputRate) { - const out = new Int16Array(input.length); - for (let i = 0; i < input.length; i++) { - out[i] = floatToInt16(input[i]); - } - return out; - } - - const ratio = inputRate / outputRate; - const outputLength = Math.max(1, Math.round(input.length / ratio)); - const out = new Int16Array(outputLength); - for (let i = 0; i < outputLength; i++) { - const sourceIndex = i * ratio; - const i0 = Math.floor(sourceIndex); - const i1 = Math.min(input.length - 1, i0 + 1); - const frac = sourceIndex - i0; - const sample = input[i0] * (1 - frac) + input[i1] * frac; - out[i] = floatToInt16(sample); - } - return out; -}; - const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => { if (a.length === 0) { return b; 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..7434471ec6 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -30,6 +30,7 @@ export const ar: TranslationResources = { back: "خلف", loading: "تحميل...", actions: { + save: "حفظ", back: "خلف", cancel: "يلغي", close: "يغلق", @@ -123,6 +124,8 @@ export const ar: TranslationResources = { input: { accessibilityLabel: "وكيل الرسائل...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "ملء الشاشة", + exitFullscreen: "الخروج من ملء الشاشة", focusHint: "{{shortcut}}للتركيز", addAttachment: "إضافة مرفق", interruptAgent: "عامل المقاطعة", @@ -382,6 +385,9 @@ export const ar: TranslationResources = { completed: "اكتملت", }, }, + turnFooter: { + workedFor: "عمل لمدة {{duration}}", + }, compaction: { loading: "الضغط...", auto: "يتم ضغط السياق تلقائيًا", @@ -607,6 +613,9 @@ export const ar: TranslationResources = { updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", }, + sheet: { + openAsTab: "فتح كعلامة تبويب", + }, tabs: { loading: "تحميل...", modified: "تغييرات غير محفوظة", @@ -622,7 +631,7 @@ export const ar: TranslationResources = { }, switcher: { trigger: "تبديل علامات التبويب (فتح{{count}})", - title: "علامة التبويب التبديل", + title: "علامات تبويب الـ Workspace", searchPlaceholder: "علامات تبويب البحث", }, menu: { @@ -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: { @@ -1592,14 +1657,19 @@ export const ar: TranslationResources = { title: "إضافة مزود", search: "مقدمي البحث", noProviders: "لم يتم العثور على مقدمي الخدمات", + clearSearch: "مسح البحث", actions: { add: "يضيف", + addProvider: "إضافة {{provider}}", adding: "إضافة", installed: "تم التثبيت", cancel: "يلغي", installInstructions: "تعليمات التثبيت", installInstructionsFor: "تعليمات تثبيت{{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "غير قادر على تثبيت الموفر", }, @@ -1703,6 +1773,12 @@ export const ar: TranslationResources = { helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.", fields: { target: "مضيف SSH", + password: "كلمة مرور الدايمون", + optional: "خياري", + }, + passwordVisibility: { + show: "إظهار كلمة المرور", + hide: "إخفاء كلمة المرور", }, actions: { cancel: "إلغاء", @@ -1931,6 +2007,8 @@ export const ar: TranslationResources = { dismiss: "رفض", }, contextWindow: { + noData: "لا توجد بيانات للسياق", + accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق", title: "نافذة السياق", used: "تم استخدام{{percentage}}%", tokens: "رموز{{used}}/{{max}}", @@ -1938,6 +2016,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..97ced3f535 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -26,6 +26,7 @@ export const en = { back: "Back", loading: "Loading...", actions: { + save: "Save", back: "Back", cancel: "Cancel", close: "Close", @@ -119,6 +120,8 @@ export const en = { input: { accessibilityLabel: "Message agent...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", focusHint: "{{shortcut}} to focus", addAttachment: "Add attachment", interruptAgent: "Interrupt agent", @@ -379,6 +382,9 @@ export const en = { completed: "Completed", }, }, + turnFooter: { + workedFor: "Worked for {{duration}}", + }, compaction: { loading: "Compacting...", auto: "Context automatically compacted", @@ -604,6 +610,9 @@ export const en = { updateHost: "Update the host to use the native terminal renderer.", unableToSubscribe: "Unable to subscribe to terminal", }, + sheet: { + openAsTab: "Open as tab", + }, tabs: { loading: "Loading...", modified: "Unsaved changes", @@ -619,7 +628,7 @@ export const en = { }, switcher: { trigger: "Switch tabs ({{count}} open)", - title: "Switch tab", + title: "Workspace tabs", searchPlaceholder: "Search tabs", }, menu: { @@ -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: { @@ -1617,14 +1682,19 @@ export const en = { title: "Add provider", search: "Search providers", noProviders: "No providers found", + clearSearch: "Clear search", actions: { add: "Add", + addProvider: "Add {{provider}}", adding: "Adding", installed: "Installed", cancel: "Cancel", installInstructions: "Install instructions", installInstructionsFor: "{{provider}} install instructions", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Unable to install provider", }, @@ -1727,6 +1797,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 +2032,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 +2041,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 +2185,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..124e931497 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -31,6 +31,7 @@ export const es: TranslationResources = { back: "Atrás", loading: "Cargando...", actions: { + save: "Guardar", back: "Atrás", cancel: "Cancelar", close: "Cerrar", @@ -124,6 +125,8 @@ export const es: TranslationResources = { input: { accessibilityLabel: "Agente de mensajes...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Pantalla completa", + exitFullscreen: "Salir de pantalla completa", focusHint: "{{shortcut}}para enfocar", addAttachment: "Agregar archivo adjunto", interruptAgent: "agente de interrupción", @@ -387,6 +390,9 @@ export const es: TranslationResources = { completed: "Completada", }, }, + turnFooter: { + workedFor: "Trabajó durante {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automáticamente", @@ -613,6 +619,9 @@ export const es: TranslationResources = { updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", unableToSubscribe: "No se puede suscribir al terminal", }, + sheet: { + openAsTab: "Abrir como pestaña", + }, tabs: { loading: "Cargando...", modified: "Cambios sin guardar", @@ -628,7 +637,7 @@ export const es: TranslationResources = { }, switcher: { trigger: "Cambiar pestañas ({{count}}abierto)", - title: "Cambiar pestaña", + title: "Pestañas del workspace", searchPlaceholder: "Pestañas de búsqueda", }, menu: { @@ -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: { @@ -1638,14 +1703,19 @@ export const es: TranslationResources = { title: "Agregar proveedor", search: "Proveedores de búsqueda", noProviders: "No se encontraron proveedores", + clearSearch: "Borrar búsqueda", actions: { add: "Agregar", + addProvider: "Añadir {{provider}}", adding: "Añadiendo", installed: "Instalado", cancel: "Cancelar", installInstructions: "Instrucciones de instalación", installInstructionsFor: "Instrucciones de instalación de{{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "No se puede instalar el proveedor", }, @@ -1749,6 +1819,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 +2055,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 +2064,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..8a8e01786d 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: "Onglets de l’espace de travail", + 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,48 @@ 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é", + clearSearch: "Effacer la recherche", actions: { add: "Ajouter", - adding: "Ajout", + addProvider: "Ajouter {{provider}}", + 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}}", + }, + types: { + acp: "ACP", }, 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 +1738,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 +1756,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 +1779,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 +1789,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 +1897,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 +1942,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 +1980,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 +1991,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 +2124,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 +2177,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 +2225,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 +2248,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 +2319,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 +2328,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 +2394,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 +2491,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 +2515,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 +2774,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 +2815,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..9f6d4eced3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -31,6 +31,7 @@ export const ja: TranslationResources = { back: "戻る", loading: "読み込み中...", actions: { + save: "保存", back: "戻る", cancel: "キャンセル", close: "閉じる", @@ -124,6 +125,8 @@ export const ja: TranslationResources = { input: { accessibilityLabel: "エージェントにメッセージ...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全画面表示", + exitFullscreen: "全画面表示を終了", focusHint: "{{shortcut}}でフォーカス", addAttachment: "添付ファイルを追加", interruptAgent: "エージェントを中断", @@ -387,6 +390,9 @@ export const ja: TranslationResources = { completed: "完了", }, }, + turnFooter: { + workedFor: "作業時間 {{duration}}", + }, compaction: { loading: "コンテキストを圧縮中...", auto: "コンテキストが自動的に圧縮されました", @@ -613,6 +619,9 @@ export const ja: TranslationResources = { updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", }, + sheet: { + openAsTab: "タブで開く", + }, tabs: { loading: "読み込み中...", modified: "未保存の変更", @@ -628,7 +637,7 @@ export const ja: TranslationResources = { }, switcher: { trigger: "タブを切り替え({{count}}件開いています)", - title: "タブを切り替え", + title: "ワークスペースのタブ", searchPlaceholder: "タブを検索", }, menu: { @@ -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: { @@ -1609,14 +1674,19 @@ export const ja: TranslationResources = { title: "プロバイダーを追加", search: "プロバイダーを検索", noProviders: "プロバイダーが見つかりません", + clearSearch: "検索をクリア", actions: { add: "追加", + addProvider: "{{provider}} を追加", adding: "追加中", installed: "インストール済み", cancel: "キャンセル", installInstructions: "インストール手順", installInstructionsFor: "{{provider}}のインストール手順", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "プロバイダーをインストールできません", }, @@ -1719,6 +1789,12 @@ export const ja: TranslationResources = { helper: "リモートホストで動作する Paseo デーモンに接続します。", fields: { target: "SSH ホスト", + password: "デーモンのパスワード", + optional: "任意", + }, + passwordVisibility: { + show: "パスワードを表示", + hide: "パスワードを非表示", }, actions: { cancel: "キャンセル", @@ -1951,6 +2027,8 @@ export const ja: TranslationResources = { dismiss: "閉じる", }, contextWindow: { + noData: "コンテキストデータがありません", + accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません", title: "コンテキストウィンドウ", used: "{{percentage}}%使用", tokens: "{{used}} / {{max}}トークン", @@ -1958,6 +2036,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..a78d44f74b 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -30,6 +30,7 @@ export const ko: TranslationResources = { back: "뒤로", loading: "불러오는 중...", actions: { + save: "저장", back: "뒤로", cancel: "취소", close: "닫기", @@ -123,6 +124,8 @@ export const ko: TranslationResources = { input: { accessibilityLabel: "에이전트에게 메시지...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "전체 화면", + exitFullscreen: "전체 화면 종료", focusHint: "{{shortcut}}로 포커스", addAttachment: "첨부 추가", interruptAgent: "에이전트 중단", @@ -383,6 +386,9 @@ export const ko: TranslationResources = { completed: "완료됨", }, }, + turnFooter: { + workedFor: "작업 시간 {{duration}}", + }, compaction: { loading: "압축하는 중...", auto: "컨텍스트가 자동으로 압축되었습니다", @@ -608,6 +614,9 @@ export const ko: TranslationResources = { updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", unableToSubscribe: "터미널을 구독할 수 없습니다", }, + sheet: { + openAsTab: "탭으로 열기", + }, tabs: { loading: "불러오는 중...", modified: "저장되지 않은 변경사항", @@ -623,7 +632,7 @@ export const ko: TranslationResources = { }, switcher: { trigger: "탭 전환 ({{count}}개 열림)", - title: "탭 전환", + title: "워크스페이스 탭", searchPlaceholder: "탭 검색", }, menu: { @@ -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: { @@ -1602,14 +1667,19 @@ export const ko: TranslationResources = { title: "프로바이더 추가", search: "프로바이더 검색", noProviders: "프로바이더를 찾을 수 없습니다", + clearSearch: "검색 지우기", actions: { add: "추가", + addProvider: "{{provider}} 추가", adding: "추가 중", installed: "설치됨", cancel: "취소", installInstructions: "설치 안내", installInstructionsFor: "{{provider}} 설치 안내", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "프로바이더를 설치할 수 없습니다", }, @@ -1711,6 +1781,12 @@ export const ko: TranslationResources = { helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.", fields: { target: "SSH 호스트", + password: "데몬 비밀번호", + optional: "선택 사항", + }, + passwordVisibility: { + show: "비밀번호 표시", + hide: "비밀번호 숨기기", }, actions: { cancel: "취소", @@ -1941,6 +2017,8 @@ export const ko: TranslationResources = { dismiss: "닫기", }, contextWindow: { + noData: "컨텍스트 데이터 없음", + accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음", title: "컨텍스트 윈도우", used: "{{percentage}}% 사용됨", tokens: "{{used}} / {{max}} 토큰", @@ -1948,6 +2026,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..e5a2e3bfe4 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -31,6 +31,7 @@ export const ptBR: TranslationResources = { back: "Voltar", loading: "Carregando...", actions: { + save: "Salvar", back: "Voltar", cancel: "Cancelar", close: "Fechar", @@ -124,6 +125,8 @@ export const ptBR: TranslationResources = { input: { accessibilityLabel: "Enviar mensagem ao agente...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Tela cheia", + exitFullscreen: "Sair da tela cheia", focusHint: "{{shortcut}} para focar", addAttachment: "Adicionar anexo", interruptAgent: "Interromper agente", @@ -386,6 +389,9 @@ export const ptBR: TranslationResources = { completed: "Concluída", }, }, + turnFooter: { + workedFor: "Trabalhou por {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automaticamente", @@ -612,6 +618,9 @@ export const ptBR: TranslationResources = { updateHost: "Atualize o host para usar o renderizador de terminal nativo.", unableToSubscribe: "Não foi possível assinar o terminal", }, + sheet: { + openAsTab: "Abrir como aba", + }, tabs: { loading: "Carregando...", modified: "Alterações não salvas", @@ -627,7 +636,7 @@ export const ptBR: TranslationResources = { }, switcher: { trigger: "Alternar abas ({{count}} abertas)", - title: "Alternar aba", + title: "Abas do workspace", searchPlaceholder: "Buscar abas", }, menu: { @@ -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: { @@ -1623,14 +1688,19 @@ export const ptBR: TranslationResources = { title: "Adicionar provedor", search: "Buscar provedores", noProviders: "Nenhum provedor encontrado", + clearSearch: "Limpar pesquisa", actions: { add: "Adicionar", + addProvider: "Adicionar {{provider}}", adding: "Adicionando", installed: "Instalado", cancel: "Cancelar", installInstructions: "Instruções de instalação", installInstructionsFor: "Instruções de instalação de {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Não foi possível instalar provedor", }, @@ -1734,6 +1804,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 +2040,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 +2049,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..ad0bac05c6 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -31,6 +31,7 @@ export const ru: TranslationResources = { back: "Назад", loading: "Загрузка...", actions: { + save: "Сохранить", back: "Назад", cancel: "Отмена", close: "Закрыть", @@ -124,6 +125,8 @@ export const ru: TranslationResources = { input: { accessibilityLabel: "Написать агенту...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Полный экран", + exitFullscreen: "Выйти из полноэкранного режима", focusHint: "{{shortcut}}, для фокуса", addAttachment: "Добавить вложение", interruptAgent: "Прервать агента", @@ -386,6 +389,9 @@ export const ru: TranslationResources = { completed: "Завершена", }, }, + turnFooter: { + workedFor: "Время работы: {{duration}}", + }, compaction: { loading: "Сжатие контекста...", auto: "Контекст сжат автоматически", @@ -614,6 +620,9 @@ export const ru: TranslationResources = { updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", unableToSubscribe: "Не удалось подключиться к терминалу", }, + sheet: { + openAsTab: "Открыть во вкладке", + }, tabs: { loading: "Загрузка...", modified: "Несохранённые изменения", @@ -629,7 +638,7 @@ export const ru: TranslationResources = { }, switcher: { trigger: "Переключить вкладки (открыто: {{count}})", - title: "Переключить вкладку", + title: "Вкладки рабочего пространства", searchPlaceholder: "Поиск вкладок", }, menu: { @@ -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: { @@ -1621,14 +1686,19 @@ export const ru: TranslationResources = { title: "Добавить провайдера", search: "Поиск провайдеров", noProviders: "Провайдеры не найдены", + clearSearch: "Очистить поиск", actions: { add: "Добавить", + addProvider: "Добавить {{provider}}", adding: "Добавление", installed: "Установлено", cancel: "Отмена", installInstructions: "Инструкции по установке", installInstructionsFor: "Инструкции по установке {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Не удалось установить провайдера", }, @@ -1732,6 +1802,12 @@ export const ru: TranslationResources = { helper: "Подключитесь к демону Paseo на удалённом хосте.", fields: { target: "Хост SSH", + password: "Пароль демона", + optional: "Необязательно", + }, + passwordVisibility: { + show: "Показать пароль", + hide: "Скрыть пароль", }, actions: { cancel: "Отмена", @@ -1964,6 +2040,8 @@ export const ru: TranslationResources = { dismiss: "Закрыть", }, contextWindow: { + noData: "Нет данных о контексте", + accessibilityNoData: "Контекстное окно: нет данных о контексте", title: "Контекстное окно", used: "Использовано: {{percentage}}%", tokens: "Токены: {{used}} / {{max}}", @@ -1971,6 +2049,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..7b78bfbea6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -30,6 +30,7 @@ export const zhCN: TranslationResources = { back: "返回", loading: "加载中...", actions: { + save: "保存", back: "返回", cancel: "取消", close: "关闭", @@ -123,6 +124,8 @@ export const zhCN: TranslationResources = { input: { accessibilityLabel: "给 Agent 发消息...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全屏", + exitFullscreen: "退出全屏", focusHint: "{{shortcut}} 聚焦", addAttachment: "添加附件", interruptAgent: "中断 Agent", @@ -382,6 +385,9 @@ export const zhCN: TranslationResources = { completed: "已完成", }, }, + turnFooter: { + workedFor: "工作了 {{duration}}", + }, compaction: { loading: "正在压缩...", auto: "上下文已自动压缩", @@ -607,6 +613,9 @@ export const zhCN: TranslationResources = { updateHost: "请更新主机以使用原生终端渲染器。", unableToSubscribe: "无法订阅 Terminal", }, + sheet: { + openAsTab: "作为标签页打开", + }, tabs: { loading: "正在加载...", modified: "未保存的更改", @@ -622,7 +631,7 @@ export const zhCN: TranslationResources = { }, switcher: { trigger: "切换标签(已打开 {{count}} 个)", - title: "切换标签", + title: "Workspace 标签", searchPlaceholder: "搜索标签", }, menu: { @@ -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: { @@ -1575,14 +1640,19 @@ export const zhCN: TranslationResources = { title: "添加 provider", search: "搜索 providers", noProviders: "未找到 providers", + clearSearch: "清除搜索", actions: { add: "添加", + addProvider: "添加 {{provider}}", adding: "正在添加", installed: "已安装", cancel: "取消", installInstructions: "安装说明", installInstructionsFor: "{{provider}} 安装说明", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "无法安装 provider", }, @@ -1683,6 +1753,12 @@ export const zhCN: TranslationResources = { helper: "连接到远程主机上运行的 Paseo 守护进程。", fields: { target: "SSH 主机", + password: "守护进程密码", + optional: "可选", + }, + passwordVisibility: { + show: "显示密码", + hide: "隐藏密码", }, actions: { cancel: "取消", @@ -1909,6 +1985,8 @@ export const zhCN: TranslationResources = { dismiss: "关闭", }, contextWindow: { + noData: "暂无上下文数据", + accessibilityNoData: "上下文窗口:暂无上下文数据", title: "上下文窗口", used: "已使用 {{percentage}}%", tokens: "{{used}} / {{max}} tokens", @@ -1916,6 +1994,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/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index b1d8a0036e..77059dfc39 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -42,7 +42,15 @@ export function MobilePanelOverlay({ const overlayAnimatedStyle = useAnimatedStyle(() => { const isVisible = isLeft ? position.value < 0 : position.value > 0; - return { display: isVisible ? ("flex" as const) : ("none" as const) }; + if (isWeb) { + return { display: isVisible ? ("flex" as const) : ("none" as const) }; + } + // Preserve native scroll ranges while hidden. Collapsing layout makes Android + // spring retained scroll offsets back to zero on touch cancellation or release. + return { + opacity: isVisible ? 1 : 0, + pointerEvents: isVisible ? ("box-none" as const) : ("none" as const), + }; }, [isLeft]); const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel; @@ -79,10 +87,7 @@ export function MobilePanelOverlay({ pointerEvents={overlayPointerEvents} style={styles.overlay} > - + { + it("joins model, thinking, mode, and context usage as the controls show them", () => { + expect(buildAgentFactsLine(base)).toBe( + "Fable 5.1 · High · Bypass permissions · 56k / 200k tokens (28%)", + ); + }); + + it("shows the raw model id before the catalog has loaded", () => { + expect( + buildAgentFactsLine({ + ...base, + models: null, + runtimeModelId: null, + configuredModelId: "gpt-6.1-sol", + runtimeThinkingOptionId: null, + explicitThinkingOptionId: "medium", + availableModes: [], + contextWindowUsedTokens: null, + contextWindowMaxTokens: null, + }), + ).toBe("gpt-6.1-sol · Medium"); + }); + + it("falls back to the provider's first mode until the agent reports one", () => { + expect( + buildAgentFactsLine({ + ...base, + currentModeId: null, + contextWindowUsedTokens: undefined, + contextWindowMaxTokens: undefined, + }), + ).toBe("Fable 5.1 · High · Default"); + }); + + it("leaves thinking and context out when the agent has none", () => { + expect( + buildAgentFactsLine({ + ...base, + models: [{ provider: "mock", id: "m", label: "Model M" }], + runtimeModelId: "m", + runtimeThinkingOptionId: null, + availableModes: [], + contextWindowUsedTokens: 10, + contextWindowMaxTokens: 0, + }), + ).toBe("Model M"); + }); + + it("is empty until the agent has a model", () => { + expect( + buildAgentFactsLine({ ...base, runtimeModelId: null, configuredModelId: " " }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/panels/agent-facts-line.ts b/packages/app/src/panels/agent-facts-line.ts new file mode 100644 index 0000000000..7d3cd80332 --- /dev/null +++ b/packages/app/src/panels/agent-facts-line.ts @@ -0,0 +1,59 @@ +import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import { formatAgentModeLabel } from "@/agent-controls/labels"; +import { formatTokenCount } from "@/components/context-window-meter.utils"; +import { resolveAgentModelSelection } from "@/composer/agent-controls/utils"; + +export interface AgentFactsInput { + models: AgentModelDefinition[] | null; + runtimeModelId: string | null | undefined; + configuredModelId: string | null | undefined; + runtimeThinkingOptionId: string | null | undefined; + explicitThinkingOptionId: string | null | undefined; + availableModes: AgentMode[]; + currentModeId: string | null | undefined; + contextWindowUsedTokens: number | null | undefined; + contextWindowMaxTokens: number | null | undefined; +} + +/** + * What the composer's controls and context meter show about a Paseo agent, as one line: model, + * thinking, mode, and context usage, each resolved exactly as its control resolves it. Null until + * the agent has a model at all. + */ +export function buildAgentFactsLine(input: AgentFactsInput): string | null { + const hasModel = Boolean(input.runtimeModelId?.trim() || input.configuredModelId?.trim()); + if (!hasModel) return null; + const selection = resolveAgentModelSelection(input); + const parts = [selection.displayModel]; + if (selection.selectedThinkingId) parts.push(selection.displayThinking); + const mode = resolveModeLabel(input.availableModes, input.currentModeId); + if (mode) parts.push(mode); + const context = formatContextUsage(input.contextWindowUsedTokens, input.contextWindowMaxTokens); + if (context) parts.push(context); + return parts.join(" · "); +} + +/** The mode control shows the current mode, or the provider's first one until the agent reports. */ +function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) { + const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0]; + return mode ? formatAgentModeLabel(mode) : null; +} + +/** The context meter's numbers: used of max, with the share it draws as the ring. */ +function formatContextUsage( + usedTokens: number | null | undefined, + maxTokens: number | null | undefined, +): string | null { + if ( + typeof usedTokens !== "number" || + typeof maxTokens !== "number" || + !Number.isFinite(usedTokens) || + !Number.isFinite(maxTokens) || + usedTokens < 0 || + maxTokens <= 0 + ) { + return null; + } + const percentage = Math.round(Math.min(100, (usedTokens / maxTokens) * 100)); + return `${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens (${percentage}%)`; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2d8a605c3f..a9e11df570 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -27,6 +27,11 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { RetainedChatContent } from "./retained-chat-content"; import { Composer } from "@/composer"; +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "@/composer/editing-session"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { resolveComposerTrackControlClearance, @@ -55,6 +60,9 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; import { TimelineSyncStatus } from "@/timeline/sync-status"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; +import { buildAgentFactsLine } from "@/panels/agent-facts-line"; +import { PaneSubtitle } from "@/panels/pane-subtitle"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; import { useHasPluginComposerPills } from "@/plugins"; @@ -1153,6 +1161,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); + // The workspace sheet is for looking; Open as tab brings the agent back with its composer. + const showsComposer = usePaneContext().host !== "sheet"; + // The dock lays out fullscreen for the same session the composer edits in. + const composerEditingSession = useComposerEditingSession("chat"); + const isComposerFullscreen = useComposerFullscreen(composerEditingSession); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const tasks = useSessionStore((state): TodoEntry[] | undefined => state.sessions[serverId]?.agentTasks.get(agentId), @@ -1216,7 +1229,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ composerState, ], ); - const composerSection = ( + const composerSection = showsComposer ? ( - ); + ) : null; const streamContent = ( + {showsComposer ? null : } + {dockContent} {composerSection} {dockOverlay} @@ -1321,13 +1336,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ function ChatSurface({ children, disabled, + fullscreen, }: { children: [ReactNode, ReactNode, ReactNode]; disabled: boolean; + fullscreen: boolean; }) { return ( - {children} + {children} ); } @@ -1362,6 +1379,36 @@ function TimelineSyncErrorCallout({ ); } +/** + * What the composer's controls and context meter would show, as one line, for an agent shown + * without its composer. + */ +function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) { + const agent = useSessionStore( + useShallow((state) => { + const current = state.sessions[serverId]?.agents.get(agentId); + return current + ? { + provider: current.provider, + cwd: current.cwd, + runtimeModelId: current.runtimeInfo?.model ?? null, + configuredModelId: current.model ?? null, + runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null, + explicitThinkingOptionId: current.thinkingOptionId ?? null, + availableModes: current.availableModes, + currentModeId: current.currentModeId, + contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null, + contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null, + } + : null; + }), + ); + const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); + const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null; + const text = agent ? buildAgentFactsLine({ ...agent, models }) : null; + return text ? : null; +} + const AgentStreamSection = memo(function AgentStreamSection({ streamViewRef, serverId, @@ -1471,6 +1518,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd, isSubmitLoading, agentInputDraft, + editingSession, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, @@ -1484,6 +1532,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; + editingSession: ComposerEditingSession; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; @@ -1507,6 +1556,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd={cwd} isSubmitLoading={isSubmitLoading} agentInputDraft={agentInputDraft} + editingSession={editingSession} onAttentionInputFocus={onAttentionInputFocus} onAttentionPromptSend={onAttentionPromptSend} onComposerHeightChange={onComposerHeightChange} @@ -1522,6 +1572,7 @@ function ActiveAgentComposer({ cwd, isSubmitLoading, agentInputDraft, + editingSession, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, @@ -1533,6 +1584,7 @@ function ActiveAgentComposer({ cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; + editingSession: ComposerEditingSession; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; @@ -1629,6 +1681,7 @@ function ActiveAgentComposer({ onChangeAttachments={agentInputDraft.setAttachments} cwd={cwd} clearDraft={agentInputDraft.clear} + editingSession={editingSession} autoFocus autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)} isSubmitLoading={isSubmitLoading} @@ -1721,6 +1774,8 @@ const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", flexShrink: 1, + // Content-sized inline; fills the dock's fullscreen slot. + flexGrow: 1, }, }); diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 962e095b89..86faad329c 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -3,7 +3,7 @@ import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { AgentTaskList } from "@/composer/task-list"; import { ComposerTrackBar } from "@/composer/tracks"; -import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePaneContext } from "@/panels/pane-context"; import { useSettings } from "@/hooks/use-settings"; import { PluginComposerPills } from "@/plugins"; @@ -12,12 +12,13 @@ import { type ArchiveFinishedStatus, useArchiveSubagent, useDetachSubagent, + useSubagentOpensInSheet, type SubagentRow, } from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; import type { TodoEntry } from "@/types/stream"; import { navigateToAgent } from "@/utils/navigate-to-agent"; -import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model"; import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside"; import { openComposerChanges } from "@/workspace-tabs/open-supporting-view"; @@ -49,10 +50,9 @@ export const AgentTracks = memo(function AgentTracks({ onArchiveFinished: () => void; hasPluginComposerPills: boolean; }): ReactElement | null { - const { tabId, openTab } = usePaneContext(); + const { tabId } = usePaneContext(); const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId); const isCompact = useIsCompactFormFactor(); - const canSplit = supportsDesktopPaneSplits() && !isCompact; const openInSidePane = useSettings((settings) => settings.openInSidePane); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const canDetachSubagents = useSessionStore( @@ -60,46 +60,38 @@ export const AgentTracks = memo(function AgentTracks({ ); const archiveSubagent = useArchiveSubagent({ serverId }); const detachSubagent = useDetachSubagent({ serverId }); + const openSubagentTarget = useCallback( + (target: WorkspaceTabTarget) => { + openPreferredWorkspaceTarget({ + workspaceKey, + target, + source: "subagents", + preferences: openInSidePane, + parentTabId: tabId, + }); + }, + [openInSidePane, tabId, workspaceKey], + ); const handleOpenSubagent = useCallback( (subagentId: string) => { const session = useSessionStore.getState().sessions[serverId]; const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId); + // A subagent in another workspace lives in that workspace's tabs. if (agent?.workspaceId && agent.workspaceId !== workspaceId) { navigateToAgent({ serverId, agentId: subagentId }); return; } - if (canSplit && workspaceKey) { - openPreferredWorkspaceTarget({ - isCompact, - workspaceKey, - target: { kind: "agent", agentId: subagentId }, - source: "subagents", - preferences: openInSidePane, - parentTabId: tabId, - }); - return; - } - navigateToAgent({ serverId, agentId: subagentId }); + openSubagentTarget({ kind: "agent", agentId: subagentId }); }, - [canSplit, isCompact, openInSidePane, serverId, tabId, workspaceId, workspaceKey], + [openSubagentTarget, serverId, workspaceId], ); const handleOpenProviderSubagent = useCallback( (parentAgentId: string, subagentId: string) => { - if (canSplit && workspaceKey) { - openPreferredWorkspaceTarget({ - isCompact, - workspaceKey, - target: { kind: "provider_subagent", parentAgentId, subagentId }, - source: "subagents", - preferences: openInSidePane, - parentTabId: tabId, - }); - return; - } - openTab({ kind: "provider_subagent", parentAgentId, subagentId }); + openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId }); }, - [canSplit, isCompact, openInSidePane, openTab, tabId, workspaceKey], + [openSubagentTarget], ); + const opensInSheet = useSubagentOpensInSheet(workspaceKey); const handleOpenChanges = useCallback(() => { if (!workspaceKey) { return; @@ -132,6 +124,7 @@ export const AgentTracks = memo(function AgentTracks({ rows={subagentRows} onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} + opensInSheet={opensInSheet} onArchiveSubagent={archiveSubagent} onArchiveFinished={onArchiveFinished} archiveFinishedStatus={archiveFinishedStatus} diff --git a/packages/app/src/panels/new-tab-panel.tsx b/packages/app/src/panels/new-tab-panel.tsx index cdafafabed..da1039a980 100644 --- a/packages/app/src/panels/new-tab-panel.tsx +++ b/packages/app/src/panels/new-tab-panel.tsx @@ -137,6 +137,7 @@ const NewTabPanel = memo(function NewTabPanel(): ReactElement { serverId, purpose: host === "explorer" ? "supporting" : "primary", host, + surface: "panel", }); const itemsById = useMemo( () => new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item])), diff --git a/packages/app/src/panels/pane-subtitle.tsx b/packages/app/src/panels/pane-subtitle.tsx new file mode 100644 index 0000000000..eb5292d67b --- /dev/null +++ b/packages/app/src/panels/pane-subtitle.tsx @@ -0,0 +1,30 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * One muted line of facts about a pane's agent, under the pane's header and above its stream: + * model, thinking, tokens. Provider subagents get it from the daemon; a Paseo agent shown without + * its composer gets it from the same facts the composer's controls show. + */ +export function PaneSubtitle({ text, testID }: { text: string; testID?: string }) { + return ( + + + {text} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + text: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/panels/panel-instance-attributes.ts b/packages/app/src/panels/panel-instance-attributes.ts index 6ef49b0e4e..6fb414943c 100644 --- a/packages/app/src/panels/panel-instance-attributes.ts +++ b/packages/app/src/panels/panel-instance-attributes.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { usePaneContext } from "@/panels/pane-context"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; export interface PanelInstanceIdentity { serverId: string; @@ -18,8 +19,20 @@ const listenersByPanel = new Map void>>(); const allListeners = new Set<() => void>(); let attributesRevision = 0; +function buildWorkspacePanelKey(workspaceKey: string, tabId: string): string { + return `${workspaceKey}:${tabId}`; +} + export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string { - return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`; + return buildWorkspacePanelKey(buildWorkspaceTabPersistenceKey(identity) ?? "", identity.tabId); +} + +/** Looks a panel up by the workspace key the layout store uses. */ +export function getWorkspacePanelAttributes( + workspaceKey: string, + tabId: string, +): PanelInstanceAttributes { + return attributesByPanel.get(buildWorkspacePanelKey(workspaceKey, tabId)) ?? DEFAULT_ATTRIBUTES; } export function getPanelInstanceAttributes( diff --git a/packages/app/src/panels/panel-manifest.test.ts b/packages/app/src/panels/panel-manifest.test.ts index 0a91aad995..0766003f84 100644 --- a/packages/app/src/panels/panel-manifest.test.ts +++ b/packages/app/src/panels/panel-manifest.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { panelResourceKey, panelSupportsHost } from "@/panels/panel-manifest"; +import { + getPanelManifest, + panelCanLaunchInPane, + panelResourceKey, + panelSupportsHost, +} from "@/panels/panel-manifest"; describe("panel manifest", () => { it("answers host support without React panel registration", () => { @@ -13,6 +18,31 @@ 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); + expect(getPanelManifest("agent").showCloseButton).toBe(true); + expect(getPanelManifest("terminal").showCloseButton).toBe(true); + expect(getPanelManifest("file").showCloseButton).toBe(true); + expect(getPanelManifest("working_diff").showCloseButton).toBe(true); + }); + + it("hides a singleton launch only when it already exists in the destination pane", () => { + expect(panelCanLaunchInPane("files", ["files"])).toBe(false); + expect(panelCanLaunchInPane("changes_tree", ["changes_tree"])).toBe(false); + expect(panelCanLaunchInPane("files", ["changes_tree"])).toBe(true); + expect(panelCanLaunchInPane("files", [])).toBe(true); + expect(panelCanLaunchInPane("terminal", ["terminal"])).toBe(true); + }); + it("keeps durable resource identity separate from transient target input", () => { expect( panelResourceKey({ kind: "working_diff", focusPath: "src/a.ts", focusRequestId: 1 }), diff --git a/packages/app/src/panels/panel-manifest.ts b/packages/app/src/panels/panel-manifest.ts index c608340d09..90fdf1931e 100644 --- a/packages/app/src/panels/panel-manifest.ts +++ b/packages/app/src/panels/panel-manifest.ts @@ -1,10 +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; } @@ -16,62 +44,99 @@ const manifests = { new_tab: { kind: "new_tab", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: null, resourceKey: () => "new_tab", }, draft: { kind: "draft", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: null, resourceKey: (target) => target.draftId, }, agent: { kind: "agent", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: (target, facts) => + facts.isWorkspaceRootAgent(target.agentId) ? null : { openAsTab: true }, resourceKey: (target) => target.agentId, }, provider_subagent: { kind: "provider_subagent", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: { openAsTab: false }, resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`, }, terminal: { kind: "terminal", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: null, resourceKey: (target) => target.terminalId, }, browser: { kind: "browser", supportedHosts: ["main"], + showCloseButton: true, + singleton: false, + compactSheet: null, resourceKey: (target) => target.browserId, }, changes_tree: { kind: "changes_tree", supportedHosts: ["explorer"], + showCloseButton: false, + singleton: true, + compactSheet: null, resourceKey: () => "changes_tree", }, files: { kind: "files", supportedHosts: ["explorer"], + showCloseButton: false, + singleton: true, + compactSheet: null, resourceKey: () => "files", }, pull_request: { kind: "pull_request", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: null, resourceKey: () => "pull_request", }, file: { kind: "file", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: { openAsTab: false }, resourceKey: (target) => target.path, }, working_diff: { kind: "working_diff", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: null, resourceKey: () => "working_diff", }, plugin: { kind: "plugin", // Plugin targets are narrowed by the target-aware plugin panel capability resolver. supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: null, resourceKey: (target) => target.context === "agent" ? `${target.pluginId}:${target.panelId}:agent:${target.agentId}` @@ -80,11 +145,17 @@ const manifests = { setup: { kind: "setup", supportedHosts: ["main"], + showCloseButton: true, + singleton: false, + compactSheet: null, resourceKey: (target) => target.workspaceId, }, commit_diff: { kind: "commit_diff", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, + compactSheet: null, resourceKey: (target) => target.sha, }, } satisfies PanelManifestByKind; @@ -94,7 +165,24 @@ 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( + kind: WorkspaceTabTarget["kind"], + panePanelKinds: readonly WorkspaceTabTarget["kind"][], +): boolean { + return !getPanelManifest(kind).singleton || !panePanelKinds.includes(kind); } export function panelResourceKey(target: WorkspaceTabTarget): string { diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 6c2d482403..ecd4a082bf 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -13,9 +13,11 @@ import { ComposerTrackBar } from "@/composer/tracks"; import { useIsCompactFormFactor } from "@/constants/layout"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; import { usePaneContext } from "@/panels/pane-context"; +import { PaneSubtitle } from "@/panels/pane-subtitle"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; -import { useSubagentsForParent } from "@/subagents/select"; +import { type SubagentRow, useSubagentsForParent } from "@/subagents/select"; +import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet"; import { SubagentsTrack } from "@/subagents/track"; import { providerSubagentKey, @@ -30,6 +32,7 @@ import type { StreamItem } from "@/types/stream"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import type { TurnPresentation } from "@/timeline/turn-liveness"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; const EMPTY_PERMISSIONS = new Map(); const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -47,10 +50,12 @@ function ProviderSubagentChildTrack({ serverId, rows, onOpenProviderSubagent, + opensInSheet, }: { serverId: string; rows: ReturnType; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + opensInSheet: (row: SubagentRow) => boolean; }) { if (rows.length === 0) return null; return ( @@ -60,6 +65,7 @@ function ProviderSubagentChildTrack({ rows={rows} onOpenSubagent={NOOP_SUBAGENT} onOpenProviderSubagent={onOpenProviderSubagent} + opensInSheet={opensInSheet} onArchiveSubagent={NOOP_SUBAGENT} /> @@ -109,7 +115,7 @@ function useProviderSubagentDescriptor( function ProviderSubagentPanel() { const { t } = useTranslation(); - const { serverId, target, openFileInWorkspace, openTab } = usePaneContext(); + const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext(); invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId); const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`; @@ -137,6 +143,9 @@ function ProviderSubagentPanel() { providerParentSubagentId: target.subagentId, }); const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact); + const opensInSheet = useSubagentOpensInSheet( + buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + ); const openProviderChild = useCallback( (parentAgentId: string, subagentId: string) => { openTab({ kind: "provider_subagent", parentAgentId, subagentId }); @@ -243,17 +252,7 @@ function ProviderSubagentPanel() { return ( - {subtitle ? ( - - - {subtitle} - - - ) : null} + {subtitle ? : null} ); @@ -280,16 +280,6 @@ function ProviderSubagentPanel() { const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0 }, - subtitleHeader: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - subtitleText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, })); diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index 484124971e..0eedfde3ab 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -1,3 +1,4 @@ +import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { pluginSettingsKey } from "./settings/use-settings"; import { useEffect } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -12,13 +13,14 @@ export function PluginCatalogSync({ serverId: string; client: DaemonClient; }) { + const audio = useVoiceAudioEngineOptional(); const connected = useHostRuntimeIsConnected(serverId); const supported = useHostFeature(serverId, "plugins"); useEffect(() => { let cancelled = false; let refreshQueue = Promise.resolve(); - if (!supported) { + if (!supported || !audio) { pluginRegistry.removeHost(serverId); return; } @@ -35,6 +37,7 @@ export function PluginCatalogSync({ pluginRegistry.installCatalog(serverId, catalog, { replacePluginId, client, + audio, }); } return undefined; @@ -79,7 +82,7 @@ export function PluginCatalogSync({ .release() .catch((error) => console.warn("[Plugins] Failed to release catalog", error)); }; - }, [client, connected, serverId, supported]); + }, [audio, client, connected, serverId, supported]); useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); return null; diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index 68534a0579..f9074383bd 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,3 +1,4 @@ +import { createPlayAudio, type AudioEngine } from "@/audio"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { createPluginHosts } from "./hosts"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; @@ -12,7 +13,10 @@ import { createPluginNavigation } from "./navigation"; import { pluginButtonStore } from "./buttons"; import type { InstalledPlugin } from "./types"; -export function createPluginClientRuntime(installation: InstalledPlugin): PluginClientRuntime { +export function createPluginClientRuntime( + installation: InstalledPlugin, + audio: Pick, +): PluginClientRuntime { const state = createPluginClientStateSource(installation.serverId); const capabilities = createPluginCapabilities( installation, @@ -20,6 +24,7 @@ export function createPluginClientRuntime(installation: InstalledPlugin): Plugin ); return { ...capabilities, + playAudio: createPlayAudio(audio, installation.lifetime.signal), hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), addComposerPill(contribution) { return pluginButtonStore.addComposerPill(installation, contribution); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index ecb8f89084..a2703b6ad2 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -91,6 +91,7 @@ function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle export type PluginClientRuntime = Pick< PluginClientContext, + | "playAudio" | "paseo" | "rpc" | "openSettings" diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index 445269c87e..ecec420e3c 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -4,6 +4,8 @@ import type { PaseoApi } from "@getpaseo/client"; import { expect, test } from "vitest"; import { PluginRegistry } from "./registry"; +const audio = { play: async () => 0 }; + /** A host client that records open event observations by their first event name. */ function observingClient() { const open = new Set(); @@ -47,6 +49,7 @@ function registry() { openScreen: () => {}, openSurface: () => {}, openSettings: () => {}, + playAudio: async () => {}, openPanel: () => {}, addComposerPill: () => ({ update() {}, remove() {} }), addHeaderButton: () => ({ update() {}, remove() {} }), @@ -67,7 +70,7 @@ function catalog(id: string, body = "return function() {};") { test("setup and every surface share the installation's one Paseo client", () => { const h = registry(); - h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client }); + h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client, audio }); const [installation] = h.plugins.getSnapshot(); expect(h.setupClients.get("deploys")).toBe(installation.paseo); @@ -76,11 +79,14 @@ test("setup and every surface share the installation's one Paseo client", () => test("teardown ends every subscription the plugin still holds", async () => { const h = registry(); - h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], { client: h.client }); + h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], { + client: h.client, + audio, + }); expect(h.open).toEqual(new Set(["disabled", "kept"])); // Disabling one plugin removes it from the catalog. - h.plugins.installCatalog("host", [catalog("kept")], { client: h.client }); + h.plugins.installCatalog("host", [catalog("kept")], { client: h.client, audio }); await expect.poll(() => [...h.open]).toEqual(["kept"]); h.plugins.removeHost("host"); @@ -89,11 +95,12 @@ test("teardown ends every subscription the plugin still holds", async () => { test("reloading a plugin ends the old installation's subscriptions", async () => { const h = registry(); - h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client }); + h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client, audio }); const [first] = h.plugins.getSnapshot(); h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client, + audio, replacePluginId: "reloaded", }); @@ -109,6 +116,7 @@ test("failed plugin initialization ends the subscriptions it opened", async () = const h = registry(); h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], { client: h.client, + audio, }); await expect.poll(() => h.open.size).toBe(0); expect(h.plugins.getSnapshot()).toEqual([]); @@ -118,8 +126,8 @@ test("unloading ends the subscriptions even when plugin cleanup throws, preservi const h = registry(); const failing = catalog("failing", 'return function() { throw new Error("cleanup failed"); };'); const surviving = catalog("surviving", "return function() {};"); - h.plugins.installCatalog("host", [failing, surviving], { client: h.client }); - h.plugins.installCatalog("host", [surviving], { client: h.client }); + h.plugins.installCatalog("host", [failing, surviving], { client: h.client, audio }); + h.plugins.installCatalog("host", [surviving], { client: h.client, audio }); await expect.poll(() => [...h.open]).toEqual(["surviving"]); expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]); h.plugins.removeHost("host"); @@ -131,7 +139,7 @@ test("an invalid async client entry is disposed and its rejected continuation is h.plugins.installCatalog( "host", [catalog("async-entry", 'return Promise.reject(new Error("asynchronous setup failed"));')], - { client: h.client }, + { client: h.client, audio }, ); await expect.poll(() => h.open.size).toBe(0); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 055fe207cd..eb141e94c2 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -3,6 +3,8 @@ import { afterEach, expect, it } from "vitest"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { PluginRegistry } from "./registry"; +const audio = { play: async () => 0 }; + const client = new DaemonClient({ url: "ws://unused.test", clientId: "plugin-requirements-test" }); const registries: PluginRegistry[] = []; function registry(version: string) { @@ -31,6 +33,7 @@ function registry(version: string) { }, openScreen() {}, openSurface() {}, + async playAudio() {}, openPanel() {}, addComposerPill: () => ({ update() {}, remove() {} }), addHeaderButton: () => ({ update() {}, remove() {} }), @@ -57,7 +60,7 @@ it("checks the app version before creating a runtime or evaluating plugin code", clientBundle: "throw new Error('executed')", }, ], - { client }, + { client, audio }, ); expect(starts()).toBe(0); expect(result.getSnapshot()).toEqual([]); @@ -68,7 +71,7 @@ it("checks the app version before creating a runtime or evaluating plugin code", it("rejects catalogs without requirements from pre-0.8 daemons", () => { const { result, starts } = registry("0.8.0"); - result.installCatalog("host", [{ id: "example", clientBundle }], { client }); + result.installCatalog("host", [{ id: "example", clientBundle }], { client, audio }); expect(starts()).toBe(0); expect(result.getEvaluationError("host", "example")).toContain( "https://paseo.sh/docs/plugins/migration", @@ -80,6 +83,7 @@ it("unloads on a requirement-only edit and recovers after correction", () => { const install = (paseo: string) => result.installCatalog("host", [{ id: "example", clientBundle, requirements: { paseo } }], { client, + audio, }); install("^0.8.0"); expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]); diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index 45e1ba546f..9e8d246a95 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { pluginRegistry as registry } from "./registry"; +const audio = { play: async () => 0 }; + vi.mock("./navigation", () => ({ createPluginNavigation: () => ({}), })); @@ -30,7 +32,7 @@ const pluginRegistry = { return registry.installCatalog( serverId, catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })), - { ...options, client: daemonClient }, + { ...options, client: daemonClient, audio }, ); }, }; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index eed76a530f..e349446bfe 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -1,3 +1,4 @@ +import type { AudioEngine } from "@/audio"; import { useMemo, useSyncExternalStore } from "react"; import { QueryClient } from "@tanstack/react-query"; import { createPaseoApi } from "@getpaseo/client"; @@ -41,6 +42,7 @@ export class PluginRegistry { options: { replacePluginId?: string; client: DaemonClient; + audio: Pick; }, ): boolean { const previous = this.byHost.get(serverId) ?? []; @@ -99,7 +101,7 @@ export class PluginRegistry { timelineTransformers: [], timelineRenderers: [], }; - const runtime = this.dependencies.createRuntime(installation); + const runtime = this.dependencies.createRuntime(installation, options.audio); const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () => this.publish(), ); 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 ? ( ({ import { pluginRegistry } from "../registry"; import { PluginTimelineItemView } from "./view"; +const audio = { play: async () => 0 }; + const bundle = `(function(require) { const React = require("react"); return { default: function(plugin) { @@ -122,6 +124,7 @@ describe("PluginTimelineItemView", () => { [{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], { client: daemonClient, + audio, }, ); @@ -151,6 +154,7 @@ describe("PluginTimelineItemView", () => { ], { client: daemonClient, + audio, }, ); const container = document.createElement("div"); @@ -188,6 +192,7 @@ describe("PluginTimelineItemView", () => { ], { client: daemonClient, + audio, }, ); const container = document.createElement("div"); @@ -310,7 +315,7 @@ it("a renderer releases its observation on the plugin's client when it crashes, clientBundle: liveBundle, }, ], - { client }, + { client, audio }, ); const container = document.createElement("div"); containers.push(container); diff --git a/packages/app/src/plugins/workspace-panels/locations.test.ts b/packages/app/src/plugins/workspace-panels/locations.test.ts index b475dd501d..af5cade055 100644 --- a/packages/app/src/plugins/workspace-panels/locations.test.ts +++ b/packages/app/src/plugins/workspace-panels/locations.test.ts @@ -4,6 +4,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { pluginRegistry } from "../registry"; import { panelTargetSupportsHost, resolvePluginPanelOpenLocation } from "./locations"; +const audio = { play: async () => 0 }; + vi.mock("../navigation", () => ({ createPluginNavigation: () => ({}), })); @@ -37,6 +39,7 @@ function install(locations: readonly ("workspace" | "explorer")[]) { [{ id: "review", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], { client: {} as DaemonClient, + audio, }, ); return pluginRegistry.getSnapshot()[0]!; diff --git a/packages/app/src/plugins/workspace-panels/locations.ts b/packages/app/src/plugins/workspace-panels/locations.ts index 1c8cfbfb90..5800aecf72 100644 --- a/packages/app/src/plugins/workspace-panels/locations.ts +++ b/packages/app/src/plugins/workspace-panels/locations.ts @@ -50,7 +50,7 @@ export function panelTargetSupportsHost( target: WorkspaceTabTarget, host: PaneHost, ): boolean { - if (target.kind !== "plugin") return panelSupportsHost(target.kind, host); + if (target.kind !== "plugin" || host === "sheet") return panelSupportsHost(target.kind, host); const panel = findPluginPanel(serverId, target); // Preserve a persisted unavailable panel where the user left it until its plugin loads again. if (!panel) return true; diff --git a/packages/app/src/polyfills/install-early.ts b/packages/app/src/polyfills/install-early.ts new file mode 100644 index 0000000000..0ee6ca02fa --- /dev/null +++ b/packages/app/src/polyfills/install-early.ts @@ -0,0 +1,12 @@ +// Side-effect module imported first by index.ts. Babel hoists imports above +// statements, so polyfill calls written in index.ts would run only after every +// imported module had evaluated. Modules that read these globals at load time, +// such as @xterm/headless reading navigator.userAgent, need them installed here. +import { polyfillCrypto } from "./crypto"; +import { polyfillNavigator } from "./navigator"; +import { polyfillScreenOrientation } from "./screen-orientation"; + +polyfillCrypto(); +polyfillNavigator(); +// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API. +polyfillScreenOrientation(); diff --git a/packages/app/src/polyfills/navigator-startup.test.ts b/packages/app/src/polyfills/navigator-startup.test.ts new file mode 100644 index 0000000000..49a0fc69a2 --- /dev/null +++ b/packages/app/src/polyfills/navigator-startup.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createContext, runInContext } from "node:vm"; +import ts from "typescript"; +import { expect, it } from "vitest"; + +it("installs navigator before the app entry loads its other dependencies", () => { + 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.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 77aaa4d8ec..0a702ae641 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; +import { ToastApiProvider } from "@/contexts/toast-api-context"; const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted( () => ({ @@ -281,6 +282,12 @@ vi.mock("@/utils/confirm-dialog", () => ({ import { ProvidersSection } from "./providers-section"; +function unexpectedToast(): never { + throw new Error("Provider row interactions should not display a toast"); +} + +const toastApi = { show: unexpectedToast, copied: unexpectedToast, error: unexpectedToast }; + const claudeEntry: ProviderSnapshotEntry = { provider: "claude", status: "ready", @@ -366,7 +373,11 @@ describe("ProvidersSection", () => { function render(): void { act(() => { - root?.render(); + root?.render( + + + , + ); }); } diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 4495acba83..72234b7602 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -11,6 +11,7 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-api-context"; import { settingsStyles } from "@/styles/settings"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostFeature } from "@/runtime/host-features"; @@ -19,6 +20,7 @@ import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildAcpProviderConfigPatch, + getAcpProviderCatalog, type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; import { ProviderCatalogList } from "@/components/provider-catalog-list"; @@ -325,10 +327,12 @@ export interface ProvidersSectionProps { export function ProvidersSection({ serverId }: ProvidersSectionProps) { const { t } = useTranslation(); + const toast = useToast(); const isConnected = useHostRuntimeIsConnected(serverId); + const supportsHopliteProvider = useHostFeature(serverId, "hopliteProvider"); const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval"); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); - const { patchConfig } = useDaemonConfig(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); const openProviderSettings = useProviderSettingsStore((state) => state.open); const [pendingProviderId, setPendingProviderId] = useState(null); const [removingProviderId, setRemovingProviderId] = useState(null); @@ -338,6 +342,19 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; + /** Config owns derived providers and opt-in bundled catalog providers. */ + const isConfigOwnedProvider = useCallback( + (providerId: string) => + Boolean( + config?.providers?.[providerId] && + (config.providers[providerId].extends || + getAcpProviderCatalog().some( + (entry) => entry.id === providerId && entry.bundledProvider, + )), + ), + [config], + ); + const handleOpenProviderSettings = useCallback( (providerId: string) => { openProviderSettings({ serverId, provider: providerId }); @@ -399,18 +416,15 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { if (installingProviderId) return; setInstallingProviderId(entry.id); try { - await patchConfig(buildAcpProviderConfigPatch(entry)); + await patchConfig(buildAcpProviderConfigPatch(entry, supportsHopliteProvider)); await refresh([entry.id]); } catch (error) { - Alert.alert( - t("settings.providers.addErrorTitle"), - error instanceof Error ? error.message : String(error), - ); + toast.error(error instanceof Error ? error.message : String(error)); } finally { setInstallingProviderId((current) => (current === entry.id ? null : current)); } }, - [installingProviderId, patchConfig, refresh, t], + [installingProviderId, patchConfig, refresh, supportsHopliteProvider, toast], ); return ( @@ -444,7 +458,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { enabled={entry.enabled ?? true} isToggling={pendingProviderId === def.id} isRemoving={removingProviderId === def.id} - canRemove={supportsProviderRemoval && entry.source === "custom"} + canRemove={ + supportsProviderRemoval && + entry.source === "custom" && + isConfigOwnedProvider(def.id) + } isFirst={index === 0} onPress={handleOpenProviderSettings} onToggleEnabled={handleToggleEnabled} diff --git a/packages/app/src/screens/workspace/compact-workspace-header/index.tsx b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx new file mode 100644 index 0000000000..2a7c4a6a57 --- /dev/null +++ b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx @@ -0,0 +1,72 @@ +import { useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { CompactHeader } from "@/components/headers/compact-header"; +import { + EXPLORER_TOGGLE_KEYS, + WorkspaceExplorerToggle, +} from "@/screens/workspace/workspace-explorer-toggle"; +import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row"; + +export { MobileWorkspaceTabSwitcher } from "./tab-switcher"; + +interface CompactWorkspaceHeaderProps { + serverId: string; + isLoading: boolean; + title: string; + subtitle: string; + isSubtitleDistinct: boolean; + onToggleExplorer: () => void; + explorerToggleLabel: string; + explorerToggleAccessibilityState: { expanded: boolean }; + /** Tab switcher and workspace menu, before the explorer toggle. */ + children: ReactNode; +} + +/** The compact header with the workspace's project row and controls. */ +export function CompactWorkspaceHeader({ + isLoading, + title, + subtitle, + isSubtitleDistinct, + onToggleExplorer, + explorerToggleLabel, + explorerToggleAccessibilityState, + children, + serverId, +}: CompactWorkspaceHeaderProps) { + const { t } = useTranslation(); + const projectRow = useMemo( + () => ( + + ), + [subtitle, isSubtitleDistinct, serverId], + ); + const actions = ( + <> + {children} + + + ); + + return ( + + ); +} diff --git a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx new file mode 100644 index 0000000000..970e77aaa9 --- /dev/null +++ b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx @@ -0,0 +1,501 @@ +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { Keyboard, Pressable, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { SquarePen, SquareTerminal } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { useIconButtonChromeStyle } from "@/components/ui/icon-button-chrome"; +import { + WorkspaceTabPresentationResolver, + WorkspaceTabOptionRow, + type WorkspaceTabPresentation, +} from "@/screens/workspace/workspace-tab-presentation"; +import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; +import { + buildWorkspaceTabMenuEntries, + type WorkspaceTabMenuLabels, +} from "@/screens/workspace/workspace-tab-menu"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +// The trigger draws at 32pt beside its island neighbours, so only vertical slop is free. +const TAB_SWITCHER_TRIGGER_HIT_SLOP = { top: 8, bottom: 8 } as const; + +function getFallbackTabOptionLabel( + tab: WorkspaceTabDescriptor, + labels: { + newTab: string; + newAgent: string; + setup: string; + terminal: string; + browser: string; + agent: string; + changes: string; + files: string; + pullRequest: string; + }, +): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } + if (tab.target.kind === "draft") { + return labels.newAgent; + } + if (tab.target.kind === "setup") { + return labels.setup; + } + if (tab.target.kind === "terminal") { + return labels.terminal; + } + if (tab.target.kind === "browser") { + return labels.browser; + } + if (tab.target.kind === "file") { + return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; + } + if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { + return labels.changes; + } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } + return labels.agent; +} + +function getFallbackTabOptionDescription( + tab: WorkspaceTabDescriptor, + labels: { + newTab: string; + newAgent: string; + workspaceSetup: string; + agent: string; + terminal: string; + browser: string; + changes: string; + files: string; + pullRequest: string; + }, +): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } + if (tab.target.kind === "draft") { + return labels.newAgent; + } + if (tab.target.kind === "setup") { + return labels.workspaceSetup; + } + if (tab.target.kind === "agent") { + return labels.agent; + } + if (tab.target.kind === "terminal") { + return labels.terminal; + } + if (tab.target.kind === "browser") { + return labels.browser; + } + if (tab.target.kind === "provider_subagent") { + return labels.agent; + } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } + if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { + return labels.changes; + } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } + if (tab.target.kind === "plugin") { + return tab.target.panelId; + } + return tab.target.path; +} + +interface MobileWorkspaceTabSwitcherProps { + tabs: WorkspaceTabDescriptor[]; + activeTabKey: string; + normalizedServerId: string; + normalizedWorkspaceId: string; + onSelectSwitcherTab: (key: string) => void; + createTerminalDisabled: boolean; + onCreateDraftTab: () => void; + onCreateTerminal: () => void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsAbove: (tabId: string) => Promise | void; + onCloseTabsBelow: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +} + +function MobileWorkspaceTabOption({ + tab, + tabIndex, + tabCount, + normalizedServerId, + normalizedWorkspaceId, + selected, + active, + onPress, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, +}: { + tab: WorkspaceTabDescriptor; + tabIndex: number; + tabCount: number; + normalizedServerId: string; + normalizedWorkspaceId: string; + selected: boolean; + active: boolean; + onPress: () => void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsAbove: (tabId: string) => Promise | void; + onCloseTabsBelow: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +}) { + const { t } = useTranslation(); + const tabMenuLabels = useMemo( + () => ({ + copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), + copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), + copyFilePath: t("workspace.tabs.menu.copyFilePath"), + rename: t("workspace.tabs.menu.rename"), + closeAbove: t("workspace.tabs.menu.closeAbove"), + closeBelow: t("workspace.tabs.menu.closeBelow"), + closeLeft: t("workspace.tabs.menu.closeLeft"), + closeRight: t("workspace.tabs.menu.closeRight"), + closeOthers: t("workspace.tabs.menu.closeOthers"), + reloadAgent: t("workspace.tabs.menu.reloadAgent"), + reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), + close: t("workspace.tabs.menu.close"), + }), + [t], + ); + const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; + const menuEntries = buildWorkspaceTabMenuEntries({ + surface: "mobile", + tab, + index: tabIndex, + tabCount, + menuTestIDBase, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsBefore: onCloseTabsAbove, + onCloseTabsAfter: onCloseTabsBelow, + onCloseOtherTabs, + labels: tabMenuLabels, + }); + + const fallbackLabels = useMemo( + () => ({ + newTab: t("workspace.tabs.actions.newTab"), + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), + }), + [t], + ); + const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels); + const trailingAccessory = useMemo( + () => ( + + ), + [menuTestIDBase, fallbackLabel, menuEntries], + ); + + const renderPresentation = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + ), + [selected, active, onPress, trailingAccessory], + ); + + return ( + + {renderPresentation} + + ); +} + +export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ + tabs, + activeTabKey, + normalizedServerId, + normalizedWorkspaceId, + onSelectSwitcherTab, + createTerminalDisabled, + onCreateDraftTab, + onCreateTerminal, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, +}: MobileWorkspaceTabSwitcherProps) { + const { t } = useTranslation(); + const tabFallbackLabels = useMemo( + () => ({ + newTab: t("workspace.tabs.actions.newTab"), + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), + }), + [t], + ); + + const tabSwitcherOptions = useMemo( + () => + tabs.map((tab) => ({ + id: tab.key, + label: getFallbackTabOptionLabel(tab, tabFallbackLabels), + description: getFallbackTabOptionDescription(tab, tabFallbackLabels), + })), + [tabFallbackLabels, tabs], + ); + + const tabByKey = useMemo(() => new Map(tabs.map((tab) => [tab.key, tab])), [tabs]); + const triggerStyle = useIconButtonChromeStyle({ size: "large" }); + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + const tabIndexByKey = useMemo(() => { + const map = new Map(); + tabs.forEach((tab, index) => { + map.set(tab.key, index); + }); + return map; + }, [tabs]); + + const handleOpenSwitcher = useCallback(() => { + Keyboard.dismiss(); + setIsOpen(true); + }, []); + + const handleNewAgent = useCallback(() => { + setIsOpen(false); + onCreateDraftTab(); + }, [onCreateDraftTab]); + + const handleNewTerminal = useCallback(() => { + setIsOpen(false); + onCreateTerminal(); + }, [onCreateTerminal]); + + const footer = useMemo( + () => ( + + + + + ), + [createTerminalDisabled, handleNewAgent, handleNewTerminal, t], + ); + + const renderTabOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => { + const tab = tabByKey.get(option.id); + if (!tab) { + return ; + } + const tabIndex = tabIndexByKey.get(tab.key) ?? -1; + if (tabIndex < 0) { + return ; + } + return ( + + ); + }, + [ + tabByKey, + tabIndexByKey, + tabs.length, + normalizedServerId, + normalizedWorkspaceId, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, + ], + ); + + return ( + <> + + + {tabs.length} + + + + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + tabSwitcherFooter: { + flexDirection: "row", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[3], + }, + tabSwitcherFooterButton: { + flex: 1, + }, + // Sized and stroked like the 16px header glyphs beside it (1.5 stroke on a 24 viewbox = 1px). + tabCountBadge: { + minWidth: 16, + height: 16, + paddingHorizontal: 2, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.sm, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.foregroundMuted, + }, + // Android pads text above the ascender by default, which pushes the digit below center. + tabCountText: { + fontSize: 10, + lineHeight: 12, + includeFontPadding: false, + textAlign: "center", + textAlignVertical: "center", + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + fontVariant: ["tabular-nums"], + }, +})); diff --git a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx deleted file mode 100644 index a9b7656963..0000000000 --- a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx +++ /dev/null @@ -1,468 +0,0 @@ -import { useCallback, useMemo, useState, type ComponentType, type ReactNode } from "react"; -import { Text, View } from "react-native"; -import { ArrowLeftToLine, Plus, X } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import Animated from "react-native-reanimated"; -import { SortableInlineList } from "@/components/sortable-inline-list"; -import { EXPLORER_TAB_RAIL_INSET } from "@/components/explorer-sidebar-layout"; -import type { - DraggableListDragHandleProps, - DraggableRenderItemInfo, -} from "@/components/draggable-list.types"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { titlebarDragSurfaceStyle } from "@/components/desktop/titlebar-drag-region"; -import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome"; -import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; -import { - WorkspaceTabIcon, - WorkspaceTabPresentationResolver, - type WorkspaceTabPresentation, -} from "@/screens/workspace/workspace-tab-presentation"; -import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row"; -import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import { - useWorkspaceTabLaunchCatalog, - type WorkspaceTabLaunchItem, -} from "@/workspace-tabs/launcher"; -import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; -import type { PanelIconProps } from "@/panels/panel-registry"; -import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; -import type { Theme } from "@/styles/theme"; -import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; -import { - HorizontalScrollBoundaryShades, - useHorizontalScrollBoundary, -} from "@/components/ui/horizontal-scroll-boundary"; - -const TAB_GAP = 4; -const TAB_DROP_INDICATOR_WIDTH = 4; - -const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -interface ExplorerSidebarTabRailProps { - paneId: string; - tabs: WorkspaceDesktopTabRowItem[]; - normalizedServerId: string; - normalizedWorkspaceId: string; - activeDragTabId: string | null; - tabDropPreviewIndex: number | null; - onNavigateTab: (tabId: string) => void; - onCloseTab: (tabId: string) => Promise | void; - onCreateNewTab: () => void; - onMoveTabToMain: (tabId: string) => void; - onReorderTabs: (tabs: WorkspaceTabDescriptor[]) => void; - trailingAccessory?: ReactNode; -} - -function tabKey(item: WorkspaceDesktopTabRowItem): string { - return `${item.tab.key}:${item.tab.kind}`; -} - -function resolveExplorerSidebarTabBackdrop(): SurfaceBackdrop { - return "surfaceSidebar"; -} - -function ExplorerSidebarTab({ - item, - isDragging, - dragHandleProps, - onNavigateTab, - onCloseTab, - onMoveTabToMain, - normalizedServerId, - normalizedWorkspaceId, -}: { - item: WorkspaceDesktopTabRowItem; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; - onNavigateTab: (tabId: string) => void; - onCloseTab: (tabId: string) => Promise | void; - onMoveTabToMain: (tabId: string) => void; - normalizedServerId: string; - normalizedWorkspaceId: string; -}) { - const { t } = useTranslation(); - const [hovered, setHovered] = useState(false); - const handlePress = useCallback( - () => onNavigateTab(item.tab.tabId), - [item.tab.tabId, onNavigateTab], - ); - const handleHoverIn = useCallback(() => setHovered(true), []); - const handleHoverOut = useCallback(() => setHovered(false), []); - const handleClose = useCallback(() => { - void onCloseTab(item.tab.tabId); - }, [item.tab.tabId, onCloseTab]); - const handleMoveToMain = useCallback( - () => onMoveTabToMain(item.tab.tabId), - [item.tab.tabId, onMoveTabToMain], - ); - const canMoveToMain = panelTargetSupportsHost(normalizedServerId, item.tab.target, "main"); - const moveToMainLeading = useMemo( - () => , - [], - ); - const closeLeading = useMemo(() => , []); - const accessibilityState = useMemo(() => ({ selected: item.isActive }), [item.isActive]); - const renderPresentation = useCallback( - (presentation: WorkspaceTabPresentation) => ( - - - - - - - {presentation.label} - - - - - {presentation.tooltip} - - - - {canMoveToMain ? ( - - {t("workspace.tabs.menu.moveToMain")} - - ) : null} - {canMoveToMain ? : null} - - {t("workspace.tabs.menu.close")} - - - - ), - [ - accessibilityState, - dragHandleProps, - handleHoverIn, - handleHoverOut, - handleClose, - handleMoveToMain, - handlePress, - hovered, - isDragging, - item, - canMoveToMain, - closeLeading, - moveToMainLeading, - t, - ], - ); - - return ( - - {renderPresentation} - - ); -} - -function CatalogIcon({ - Icon, - color = "", -}: { - Icon: ComponentType; - color?: string; -}) { - return ; -} - -const ThemedCatalogIcon = withUnistyles(CatalogIcon); -const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); -const ThemedPlus = withUnistyles(Plus); -const ThemedX = withUnistyles(X); - -function ExplorerSidebarConfigurationItem({ - item, - paneId, - tab, - onCloseTab, -}: { - item: WorkspaceTabLaunchItem; - paneId: string; - tab: WorkspaceTabDescriptor | null; - onCloseTab: (tabId: string) => Promise | void; -}) { - const leading = useMemo(() => { - return item.Icon ? : null; - }, [item.Icon]); - const handleSelect = useCallback(() => { - if (tab) { - void onCloseTab(tab.tabId); - return; - } - item.launch({ kind: "open", paneId }); - }, [item, onCloseTab, paneId, tab]); - - return ( - - {item.label} - - ); -} - -function catalogItemMatchesTab(item: WorkspaceTabLaunchItem, tab: WorkspaceTabDescriptor): boolean { - return item.toggleTarget !== null && workspaceTabTargetsEqual(item.toggleTarget, tab.target); -} - -export function ExplorerSidebarTabRail({ - paneId, - tabs, - normalizedServerId, - normalizedWorkspaceId, - activeDragTabId, - tabDropPreviewIndex, - onNavigateTab, - onCloseTab, - onCreateNewTab, - onMoveTabToMain, - onReorderTabs, - trailingAccessory, -}: ExplorerSidebarTabRailProps) { - const scrollBoundary = useHorizontalScrollBoundary(); - const { t } = useTranslation(); - const groups = useWorkspaceTabLaunchCatalog({ - serverId: normalizedServerId, - purpose: "supporting", - host: "explorer", - }); - const singletonConfigurationItems = useMemo( - () => groups.flatMap((group) => group.items).filter((item) => item.toggleTarget !== null), - [groups], - ); - const newTabLeading = useMemo(() => , []); - const handleDragEnd = useCallback( - (nextTabs: WorkspaceDesktopTabRowItem[]) => onReorderTabs(nextTabs.map((item) => item.tab)), - [onReorderTabs], - ); - const getTabDragData = useCallback( - (item: WorkspaceDesktopTabRowItem) => ({ - kind: "workspace-tab" as const, - paneId, - tabId: item.tab.tabId, - }), - [paneId], - ); - const renderTab = useCallback( - ({ - item, - index, - dragHandleProps, - isActive, - }: DraggableRenderItemInfo) => { - const showBefore = activeDragTabId !== null && tabDropPreviewIndex === index; - const showAfter = - activeDragTabId !== null && - tabDropPreviewIndex === tabs.length && - index === tabs.length - 1; - return ( - - {showBefore ? : null} - - {showAfter ? : null} - - ); - }, - [ - activeDragTabId, - normalizedServerId, - normalizedWorkspaceId, - onNavigateTab, - onCloseTab, - onMoveTabToMain, - tabDropPreviewIndex, - tabs.length, - ], - ); - - return ( - - - - - - - - - {trailingAccessory ? ( - {trailingAccessory} - ) : null} - - - - {t("workspace.tabs.actions.newTab")} - - - {singletonConfigurationItems.map((item) => ( - catalogItemMatchesTab(item, tab))?.tab ?? null} - onCloseTab={onCloseTab} - /> - ))} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - track: { - minWidth: 0, - height: WORKSPACE_SECONDARY_HEADER_HEIGHT, - backgroundColor: theme.colors.surfaceSidebar, - flexDirection: "row", - alignItems: "center", - }, - scrollContainer: { - flex: 1, - minWidth: 0, - alignSelf: "stretch", - }, - scrollContent: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: EXPLORER_TAB_RAIL_INSET, - }, - trailingAccessory: { - marginRight: 4, - }, - tabSlot: { - position: "relative", - marginHorizontal: TAB_GAP / 2, - }, - tab: { - height: HEADER_CONTROL_HEIGHT, - maxWidth: 180, - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - userSelect: "none", - }, - tabHovered: { - backgroundColor: theme.colors.interactionHighlight, - }, - tabActive: { - backgroundColor: theme.colors.interactionHighlight, - }, - tabLabel: { - minWidth: 0, - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - userSelect: "none", - }, - tabLabelActive: { - color: theme.colors.foreground, - }, - tabDragging: { - opacity: 0.3, - }, - dropIndicator: { - position: "absolute", - top: theme.spacing[0.5], - bottom: theme.spacing[0.5], - width: TAB_DROP_INDICATOR_WIDTH, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.accent, - zIndex: 10, - pointerEvents: "none", - }, - dropIndicatorBefore: { - left: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, - }, - dropIndicatorAfter: { - right: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, - }, - tooltipText: { - color: theme.colors.popoverForeground, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/screens/workspace/explorer-sidebar.tsx b/packages/app/src/screens/workspace/explorer-sidebar.tsx index 389d37c821..175be3695b 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar.tsx @@ -4,17 +4,35 @@ import { StyleSheet } from "react-native-unistyles"; import { RetainedPanel } from "@/components/retained-panel"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import type { TabDropPreview } from "@/components/split-container-tab-drop-preview"; -import { ExplorerSidebarTabRail } from "@/screens/workspace/explorer-sidebar-tab-rail"; import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; -import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row"; +import { + WorkspaceDesktopTabsRow, + type WorkspaceDesktopTabRowItem, + type WorkspaceDesktopTabsRowProps, +} from "@/screens/workspace/workspace-desktop-tabs-row"; import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SplitPane } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; -interface ExplorerSidebarDockProps { +interface ExplorerSidebarDockProps extends Pick< + WorkspaceDesktopTabsRowProps, + | "setHoveredCloseTabKey" + | "onCopyResumeCommand" + | "onCopyAgentId" + | "onCopyTerminalId" + | "onCopyFilePath" + | "onReloadAgent" + | "onRenameTab" + | "onCreateNewTab" + | "onExitFocusMode" +> { + hoveredCloseTabKey: string | null; + onCloseTabsToLeft: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseTabsToRight: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseOtherTabs: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; pane: SplitPane; uiTabs: WorkspaceTab[]; normalizedServerId: string; @@ -25,8 +43,6 @@ interface ExplorerSidebarDockProps { tabDropPreview: TabDropPreview | null; onSelectTab: (paneId: string, tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; - onCreateNewTab: () => void; - onMoveTabToMain: (tabId: string) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; buildPaneContentModel: (input: { paneId: string; @@ -48,7 +64,18 @@ export function ExplorerSidebarDock({ onSelectTab, onCloseTab, onCreateNewTab, - onMoveTabToMain, + hoveredCloseTabKey, + setHoveredCloseTabKey, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + onExitFocusMode, onReorderTabsInPane, buildPaneContentModel, headerAction, @@ -61,10 +88,10 @@ export function ExplorerSidebarDock({ tabs.map((tab) => ({ tab, isActive: tab.tabId === activeTabId, - isCloseHovered: false, + isCloseHovered: hoveredCloseTabKey === tab.key, isClosingTab: closingTabIds.has(tab.tabId), })), - [activeTabId, closingTabIds, tabs], + [activeTabId, closingTabIds, hoveredCloseTabKey, tabs], ); const handleSelectTab = useCallback( (tabId: string) => onSelectTab(pane.id, tabId), @@ -80,28 +107,59 @@ export function ExplorerSidebarDock({ [onReorderTabsInPane, pane.id], ); + const handleCloseTabsToLeft = useCallback( + (tabId: string) => onCloseTabsToLeft(tabId, tabs), + [onCloseTabsToLeft, tabs], + ); + const handleCloseTabsToRight = useCallback( + (tabId: string) => onCloseTabsToRight(tabId, tabs), + [onCloseTabsToRight, tabs], + ); + const handleCloseOtherTabs = useCallback( + (tabId: string) => onCloseOtherTabs(tabId, tabs), + [onCloseOtherTabs, tabs], + ); + return ( - + + + + {headerAction ? {headerAction} : null} @@ -131,9 +189,18 @@ const styles = StyleSheet.create((theme) => ({ }, tabRail: { position: "relative", + flexDirection: "row", + alignItems: "center", flexShrink: 0, backgroundColor: theme.colors.surfaceSidebar, }, + tabRow: { + flex: 1, + minWidth: 0, + }, + headerAction: { + marginRight: theme.spacing[1], + }, tabRailDivider: { position: "absolute", right: 0, diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx new file mode 100644 index 0000000000..2b4e927a13 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -0,0 +1,311 @@ +import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet"; +import { useIsFocused } from "@react-navigation/native"; +import { + type ComponentType, + type FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useStore } from "zustand"; +import { createStore } from "zustand/vanilla"; +import { Maximize2 } from "lucide-react-native"; +import { + SheetBackground, + SheetHandle, + SheetHeaderIconButton, + SheetHeaderView, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { + IsolatedBottomSheetModal, + type IsolatedBottomSheetModalRef, + systemBackPress, +} from "@/components/ui/isolated-bottom-sheet-modal"; +import type { PanelIconProps } from "@/panels/panel-registry"; +import { getPanelRegistration } from "@/panels/panel-registry"; +import { ensurePanelsRegistered } from "@/panels/register-panels"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { + WorkspacePaneContent, + type WorkspacePaneContentModel, +} from "@/screens/workspace/workspace-pane-content"; +import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; + +const SNAP_POINTS = ["92%"]; +const NO_SHEETS: readonly SheetTab[] = []; + +function PanelIcon({ + icon: Icon, + size, + color, +}: { icon: ComponentType } & PanelIconProps) { + return ; +} + +const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +interface SheetTabHandleValue { + header: SheetHeader; + onClose: () => void; +} + +/** + * The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and + * renders it on the far side of the portal: a new function per render would remount the header on + * every change, and no context from here reaches it. So each sheet gets one handle component for + * its lifetime, which reads the header through a store the sheet keeps current. + */ +function useSheetTabHandle(value: SheetTabHandleValue): FC { + const [store] = useState(() => createStore(() => value)); + useEffect(() => store.setState(value, true), [store, value]); + const [Handle] = useState( + () => + function SheetTabHandle(props: BottomSheetHandleProps) { + const { header, onClose } = useStore(store); + return ( + + + + ); + }, + ); + return Handle; +} + +export interface SheetTabsProps { + workspaceKey: string | null; + serverId: string; + workspaceId: string; + isWorkspaceFocused: boolean; + buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; + /** The workspace's ordinary tab close, including its unsaved-changes guard. */ + onCloseTab: (tabId: string) => Promise; +} + +interface SheetTabSheetProps extends Omit { + sheet: SheetTab; + /** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */ + isTop: boolean; + /** Runs the guarded close and resolves to whether the tab is still open. */ + onClose: (tabId: string) => Promise; + onOpenAsTab: (tabId: string) => void; +} + +/** + * The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. They present from inside + * the workspace's `SheetHostScope`, so each panel keeps every context an ordinary pane has, and a + * sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of + * it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`, + * `SheetHeaderView`) keep them looking and dragging like every other sheet. + */ +export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) { + const sheets = useWorkspaceLayoutStore((state) => + workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, + ); + const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); + const handleOpenAsTab = useCallback( + (tabId: string) => { + if (workspaceKey) openSheetAsTab(workspaceKey, tabId); + }, + [openSheetAsTab, workspaceKey], + ); + const handleClose = useCallback( + async (tabId: string) => { + await onCloseTab(tabId); + // A declined unsaved-changes prompt leaves the tab in its sheet. + const open = workspaceKey + ? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey] + : undefined; + return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false; + }, + [onCloseTab, workspaceKey], + ); + return ( + <> + {sheets.map((sheet, index) => ( + + ))} + + ); +} + +function SheetTabSheet({ + sheet, + isTop, + serverId, + workspaceId, + isWorkspaceFocused, + buildContent, + onClose, + onOpenAsTab, +}: SheetTabSheetProps) { + const { t } = useTranslation(); + const { tab } = sheet; + const sheetRef = useRef(null); + const isScreenFocused = useIsFocused(); + ensurePanelsRegistered(); + const registration = getPanelRegistration(tab.target.kind); + if (!registration) { + throw new Error(`No panel registration for kind: ${tab.target.kind}`); + } + const descriptor = registration.useDescriptor(tab.target as never, { + serverId, + workspaceId, + tabId: tab.tabId, + }); + + // The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off + // the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still + // reports that as `onChange(-1)`, which must not read as the user closing it: the store already + // moved on, and the same tab id may now be a pane tab (Open as tab). + const unmounted = useRef(false); + useEffect(() => { + sheetRef.current?.present(); + return () => { + unmounted.current = true; + }; + }, []); + + // Every close path (drag, backdrop, Close, Back) slides the sheet down first; `onChange(-1)` then + // runs the one guarded close. The sheet keeps its content through the close, so a declined + // unsaved-changes prompt only has to bring it back up. + const close = useCallback(() => sheetRef.current?.close(), []); + const handleChange = useCallback( + (index: number) => { + if (index !== -1 || unmounted.current) return; + void onClose(tab.tabId).then((stillOpen) => { + if (stillOpen) sheetRef.current?.snapToIndex(0); + return stillOpen; + }); + }, + [onClose, tab.tabId], + ); + // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns Back. + const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused; + useEffect(() => { + if (!ownsBackPress) return; + return systemBackPress.subscribe(() => { + close(); + return true; + }); + }, [close, ownsBackPress]); + + const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); + const header = useMemo( + () => ({ + title: descriptor.label, + leading: , + // The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into + // the switcher, where it gets its composer back. + actions: sheet.policy.openAsTab ? ( + + ) : undefined, + }), + [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], + ); + const handleValue = useMemo( + () => ({ header, onClose: close }), + [close, header], + ); + // The header is the sheet's handle: the panel scrolls on its own, so dragging the content would + // fight it, and the header is where people reach to pull the sheet down. + const SheetTabHandle = useSheetTabHandle(handleValue); + + return ( + + {/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */} + + + ); +} + +function SheetTabBody({ + tab, + label, + isPaneFocused, + isWorkspaceFocused, + buildContent, +}: { + tab: SheetTab["tab"]; + label: string; + /** Only the top sheet's pane is focused; the ones below are covered. */ + isPaneFocused: boolean; + isWorkspaceFocused: boolean; + buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; +}) { + const tabDescriptor = useMemo( + () => ({ + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + state: tab.state, + }), + [tab], + ); + const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]); + // The sheet reaches the bottom of the screen, so it owns the system navigation inset. + const { bottom: bottomInset } = useSafeAreaInsets(); + const contentStyle = useMemo( + () => [styles.content, { paddingBottom: bottomInset }], + [bottomInset], + ); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + content: { + flex: 1, + minHeight: 0, + }, +}); diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts index 6620889475..d7b6f33a72 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.test.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts @@ -110,3 +110,25 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to }), }).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] }); }); + +test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => { + const layout: WorkspaceLayout = { + focusedPaneId: "main", + root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } }, + }; + const tabs: WorkspaceTab[] = [ + { tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + ]; + const sheetTab: WorkspaceTab = { + tabId: "sheet", + target: { kind: "agent", agentId: "agent-child" }, + createdAt: 2, + }; + + expect( + selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }), + ).toEqual(["agent-a", "agent-child"]); + expect( + selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }), + ).toEqual([]); +}); diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts index 6fdd246a9b..324d4121c3 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -6,6 +6,8 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state"; export function selectVisibleAgentIds(input: { layout: WorkspaceLayout | null; tabs: WorkspaceTab[]; + /** The workspace sheet's tab, shown over the panes. */ + sheetTab?: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; }): string[] { @@ -16,20 +18,21 @@ export function selectVisibleAgentIds(input: { ? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId) : collectAllPanes(input.layout.root); + const targets = panes.flatMap((pane) => { + const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor + .target; + return target ? [target] : []; + }); + if (input.sheetTab) targets.push(input.sheetTab.target); return [ - ...new Set( - panes.flatMap((pane) => { - const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor - .target; - return target?.kind === "agent" ? [target.agentId] : []; - }), - ), + ...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))), ].sort(); } export function useVisibleAgentIds(input: { layout: WorkspaceLayout | null; tabs: WorkspaceTab[]; + sheetTab: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; }): string[] { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index bbb0561130..f1806ede34 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -63,6 +63,8 @@ import { type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import type { PaneHost } from "@/panels/panel-manifest"; +import type { WorkspaceTabLaunchPurpose } from "@/workspace-tabs/launcher"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; @@ -107,7 +109,7 @@ const TAB_ICON_WIDTH = 14; const TAB_CONTENT_GAP = 4; const TAB_DROP_INDICATOR_WIDTH = 4; const TAB_MODIFIED_DOT_SIZE = 8; -const TAB_MIN_WIDTH = 96; +const TAB_MIN_WIDTH = 64; const TAB_MAX_WIDTH = 160; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const TAB_LABEL_LAYOUT_ALLOWANCE = 4; @@ -209,7 +211,12 @@ function TabLabelMeasurement({ ); } +type PanePanelKinds = readonly WorkspaceTabDescriptor["kind"][]; + interface WorkspaceNewTabButtonProps { + panePanelKinds: PanePanelKinds; + host: PaneHost; + launchPurpose: WorkspaceTabLaunchPurpose; serverId: string; paneId?: string; shortcutKeys: ShortcutKey[][] | null; @@ -217,6 +224,9 @@ interface WorkspaceNewTabButtonProps { } function WorkspaceNewTabButton({ + panePanelKinds, + host, + launchPurpose, serverId, paneId, shortcutKeys, @@ -237,8 +247,9 @@ function WorkspaceNewTabButton({ @@ -248,6 +259,9 @@ function WorkspaceNewTabButton({ } function WorkspacePaneToolbarActions({ + panePanelKinds, + host, + launchPurpose, showNewTabButton, showSplitActions, showMaximizeAction, @@ -259,6 +273,9 @@ function WorkspacePaneToolbarActions({ onSplitDown, onTogglePaneMaximized, }: { + panePanelKinds: PanePanelKinds; + host: PaneHost; + launchPurpose: WorkspaceTabLaunchPurpose; showNewTabButton: boolean; showSplitActions: boolean; showMaximizeAction: boolean; @@ -297,6 +314,9 @@ function WorkspacePaneToolbarActions({ {showNewTabButton ? ( width === right[index]); } -interface WorkspaceDesktopTabsRowProps { +export interface WorkspaceDesktopTabsRowProps { + host?: PaneHost; + launchPurpose?: WorkspaceTabLaunchPurpose; paneId?: string; isFocused?: boolean; + ownsKeyboardShortcuts?: boolean; tabs: WorkspaceDesktopTabRowItem[]; normalizedServerId: string; normalizedWorkspaceId: string; @@ -988,8 +1011,11 @@ export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) { } function ResolvedWorkspaceDesktopTabsRow({ + host = "main", + launchPurpose = "primary", paneId, isFocused = false, + ownsKeyboardShortcuts = isFocused, tabs, normalizedServerId, normalizedWorkspaceId, @@ -1059,6 +1085,8 @@ function ResolvedWorkspaceDesktopTabsRow({ [exitFocusModeWidth, focusModeEnabled, showPaneMaximizeAction, showPaneSplitActions], ); + const panePanelKinds = useMemo(() => tabs.map(({ tab }) => tab.kind), [tabs]); + const fallbackTabLabels = useMemo( () => ({ newTab: t("workspace.tabs.actions.newTab"), @@ -1203,14 +1231,14 @@ function ResolvedWorkspaceDesktopTabsRow({ const handleNewTabKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { - if (!isFocused) return false; + if (!ownsKeyboardShortcuts) return false; if (action.id === "workspace.tab.menu.open") { createNewTab(); return true; } return false; }, - [createNewTab, isFocused], + [createNewTab, ownsKeyboardShortcuts], ); useKeyboardActionHandler({ @@ -1221,7 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({ paneId, }), actions: ["workspace.tab.menu.open"], - enabled: isFocused, + enabled: ownsKeyboardShortcuts, priority: 200, handle: handleNewTabKeyboardAction, }); @@ -1233,7 +1261,8 @@ function ResolvedWorkspaceDesktopTabsRow({ dragHandleProps, isActive, }: DraggableRenderItemInfo) => { - const shouldShowCloseButton = layout.closeButtonPolicy === "all"; + const shouldShowCloseButton = + layout.closeButtonPolicy === "all" && item.presentation.showCloseButton; const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; const showLabel = layoutItem?.showLabel ?? true; @@ -1360,6 +1389,9 @@ function ResolvedWorkspaceDesktopTabsRow({ /> {!layout.requiresHorizontalScrollFallback ? ( 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-new-tab-menu.tsx b/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx index a8d7c3a43a..16ca639c09 100644 --- a/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx @@ -16,6 +16,7 @@ import { type WorkspaceTabLaunchItem, type WorkspaceTabLaunchPurpose, } from "@/workspace-tabs/launcher"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { PaneHost } from "@/panels/panel-manifest"; import type { PanelIconProps } from "@/panels/panel-registry"; @@ -84,14 +85,22 @@ export function WorkspaceNewTabMenuContent({ serverId, purpose, host, + panePanelKinds, paneId, }: { serverId: string; purpose: WorkspaceTabLaunchPurpose; host: PaneHost; + panePanelKinds: readonly WorkspaceTabTarget["kind"][]; paneId?: string; }) { - const groups = useWorkspaceTabLaunchCatalog({ serverId, purpose, host }); + const groups = useWorkspaceTabLaunchCatalog({ + serverId, + purpose, + host, + surface: "menu", + panePanelKinds, + }); return ( - + + + diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d94b7e5e82..bfe131d008 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,4 +1,9 @@ +import { + WorkspaceComposerEditingSessionsProvider, + useComposerTabFullscreen, +} from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; import { @@ -14,23 +19,26 @@ import { } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; -import { BackHandler, Keyboard, Pressable, Text, View } from "react-native"; +import { BackHandler, Text, View } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; -import { ChevronDown } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import invariant from "tiny-invariant"; -import { SidebarMenuToggle } from "@/components/headers/menu-header"; +import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle"; import { ScreenHeader } from "@/components/headers/screen-header"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { FloatingHeaderLayout } from "@/components/floating-header"; +import { StreamTopOverlayProvider, supportsStreamTopOverlay } from "@/agent-stream/top-overlay"; +import { + CompactWorkspaceHeader, + MobileWorkspaceTabSwitcher, +} from "@/screens/workspace/compact-workspace-header"; +import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row"; import { ScreenTitle } from "@/components/headers/screen-title"; -import { HostBadge } from "@/hosts/host-badge"; -import { useHostBadges } from "@/hosts/use-host-badges"; -import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; -import type { ShortcutKey } from "@/utils/format-shortcut"; import { FloatingPanelPortalHost, FloatingPanelPortalHostNameProvider, @@ -69,7 +77,9 @@ import { getFocusedBrowserId, FOCUSED_PANE_PLACEMENT, selectExplorerSidebarPaneId, + selectPaneFocusSuspended, type WorkspaceLayout, + type SheetTab, type WorkspaceTabPlacement, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, @@ -115,30 +125,20 @@ import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; import { resolveWorkspaceRouteId } from "@/utils/workspace-identity"; import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels"; -import { - WorkspaceTabPresentationResolver, - WorkspaceTabIcon, - WorkspaceTabOptionRow, - type WorkspaceTabPresentation, -} from "@/screens/workspace/workspace-tab-presentation"; +import { WorkspaceTabPresentationResolver } from "@/screens/workspace/workspace-tab-presentation"; import { useWorkspaceTabRename, WorkspaceTabRenameModal, } from "@/screens/workspace/use-workspace-tab-rename"; -import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; -import { - buildWorkspaceTabMenuEntries, - type WorkspaceTabMenuLabels, -} from "@/screens/workspace/workspace-tab-menu"; import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { resolveWorkspaceExplorerToggleOwner, - WorkspaceExplorerToggle, + EXPLORER_TOGGLE_KEYS, WorkspaceExplorerSidebarToggle, WorkspaceHeaderExplorerToggle, } from "@/screens/workspace/workspace-explorer-toggle"; @@ -166,6 +166,8 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { SheetTabs } from "@/screens/workspace/sheet-tabs"; +import type { PaneHost } from "@/panels/panel-manifest"; import { WorkspaceFocusProvider } from "@/workspace/focus"; import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache"; import type { NewTabSelection } from "@/workspace-tabs/new-tab"; @@ -189,7 +191,6 @@ import { import { findAdjacentPane } from "@/utils/split-navigation"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; -import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; @@ -208,6 +209,7 @@ import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; +const NO_SHEETS: readonly SheetTab[] = []; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; @@ -246,12 +248,9 @@ function buildWorkspaceFileLocation( } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const GATED_WORKSPACE_HEADER_LEFT = ; - interface WorkspaceScreenProps { serverId: string; workspaceId: string; @@ -301,187 +300,6 @@ function useSyncWorkspaceActiveBrowser(input: { }, [focusedBrowserId, input.workspaceId]); } -function getFallbackTabOptionLabel( - tab: WorkspaceTabDescriptor, - labels: { - newTab: string; - newAgent: string; - setup: string; - terminal: string; - browser: string; - agent: string; - changes: string; - files: string; - pullRequest: string; - }, -): string { - if (tab.target.kind === "new_tab") { - return labels.newTab; - } - if (tab.target.kind === "draft") { - return labels.newAgent; - } - if (tab.target.kind === "setup") { - return labels.setup; - } - if (tab.target.kind === "terminal") { - return labels.terminal; - } - if (tab.target.kind === "browser") { - return labels.browser; - } - if (tab.target.kind === "file") { - return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; - } - if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { - return labels.changes; - } - if (tab.target.kind === "files") { - return labels.files; - } - if (tab.target.kind === "pull_request") { - return labels.pullRequest; - } - if (tab.target.kind === "commit_diff") { - return tab.target.sha.slice(0, 7); - } - return labels.agent; -} - -function getFallbackTabOptionDescription( - tab: WorkspaceTabDescriptor, - labels: { - newTab: string; - newAgent: string; - workspaceSetup: string; - agent: string; - terminal: string; - browser: string; - changes: string; - files: string; - pullRequest: string; - }, -): string { - if (tab.target.kind === "new_tab") { - return labels.newTab; - } - if (tab.target.kind === "draft") { - return labels.newAgent; - } - if (tab.target.kind === "setup") { - return labels.workspaceSetup; - } - if (tab.target.kind === "agent") { - return labels.agent; - } - if (tab.target.kind === "terminal") { - return labels.terminal; - } - if (tab.target.kind === "browser") { - return labels.browser; - } - if (tab.target.kind === "provider_subagent") { - return labels.agent; - } - if (tab.target.kind === "commit_diff") { - return tab.target.sha.slice(0, 7); - } - if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { - return labels.changes; - } - if (tab.target.kind === "files") { - return labels.files; - } - if (tab.target.kind === "pull_request") { - return labels.pullRequest; - } - if (tab.target.kind === "plugin") { - return tab.target.panelId; - } - return tab.target.path; -} - -interface MobileWorkspaceTabSwitcherProps { - tabs: WorkspaceTabDescriptor[]; - activeTabKey: string; - activeTab: WorkspaceTabDescriptor | null; - tabSwitcherOptions: ComboboxOption[]; - tabByKey: Map; - normalizedServerId: string; - normalizedWorkspaceId: string; - onSelectSwitcherTab: (key: string) => void; - onCopyResumeCommand: (agentId: string) => Promise | void; - onCopyAgentId: (agentId: string) => Promise | void; - onCopyTerminalId: (terminalId: string) => Promise | void; - onCopyFilePath: (path: string) => Promise | void; - onReloadAgent: (agentId: string) => Promise | void; - onRenameTab: (tab: WorkspaceTabDescriptor) => void; - onCloseTab: (tabId: string) => Promise | void; - onCloseTabsAbove: (tabId: string) => Promise | void; - onCloseTabsBelow: (tabId: string) => Promise | void; - onCloseOtherTabs: (tabId: string) => Promise | void; -} - -function MobileActiveTabTrigger({ - activeTab, - normalizedServerId, - normalizedWorkspaceId, - backdrop, -}: { - activeTab: WorkspaceTabDescriptor | null; - normalizedServerId: string; - normalizedWorkspaceId: string; - backdrop: SurfaceBackdrop; -}) { - if (!activeTab) { - return null; - } - - return ( - - ); -} - -function ResolvedMobileActiveTabTrigger({ - activeTab, - normalizedServerId, - normalizedWorkspaceId, - backdrop, -}: { - activeTab: WorkspaceTabDescriptor; - normalizedServerId: string; - normalizedWorkspaceId: string; - backdrop: SurfaceBackdrop; -}) { - const { t } = useTranslation(); - return ( - - {(presentation) => ( - <> - - - - - - {presentation.titleState === "loading" - ? t("workspace.tabs.loading") - : presentation.label} - - - )} - - ); -} - function WorkspaceDocumentTitleEffect({ label, titleState, @@ -504,277 +322,6 @@ function WorkspaceDocumentTitleEffect({ return null; } -function switcherTriggerStyle({ pressed }: { pressed?: boolean }) { - return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed]; -} - -function MobileWorkspaceTabOption({ - tab, - tabIndex, - tabCount, - normalizedServerId, - normalizedWorkspaceId, - selected, - active, - onPress, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, -}: { - tab: WorkspaceTabDescriptor; - tabIndex: number; - tabCount: number; - normalizedServerId: string; - normalizedWorkspaceId: string; - selected: boolean; - active: boolean; - onPress: () => void; - onCopyResumeCommand: (agentId: string) => Promise | void; - onCopyAgentId: (agentId: string) => Promise | void; - onCopyTerminalId: (terminalId: string) => Promise | void; - onCopyFilePath: (path: string) => Promise | void; - onReloadAgent: (agentId: string) => Promise | void; - onRenameTab: (tab: WorkspaceTabDescriptor) => void; - onCloseTab: (tabId: string) => Promise | void; - onCloseTabsAbove: (tabId: string) => Promise | void; - onCloseTabsBelow: (tabId: string) => Promise | void; - onCloseOtherTabs: (tabId: string) => Promise | void; -}) { - const { t } = useTranslation(); - const tabMenuLabels = useMemo( - () => ({ - copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), - copyAgentId: t("workspace.tabs.menu.copyAgentId"), - copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), - copyFilePath: t("workspace.tabs.menu.copyFilePath"), - rename: t("workspace.tabs.menu.rename"), - closeAbove: t("workspace.tabs.menu.closeAbove"), - closeBelow: t("workspace.tabs.menu.closeBelow"), - closeLeft: t("workspace.tabs.menu.closeLeft"), - closeRight: t("workspace.tabs.menu.closeRight"), - closeOthers: t("workspace.tabs.menu.closeOthers"), - reloadAgent: t("workspace.tabs.menu.reloadAgent"), - reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), - close: t("workspace.tabs.menu.close"), - }), - [t], - ); - const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; - const menuEntries = buildWorkspaceTabMenuEntries({ - surface: "mobile", - tab, - index: tabIndex, - tabCount, - menuTestIDBase, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsBefore: onCloseTabsAbove, - onCloseTabsAfter: onCloseTabsBelow, - onCloseOtherTabs, - labels: tabMenuLabels, - }); - - const fallbackLabels = useMemo( - () => ({ - newTab: t("workspace.tabs.actions.newTab"), - newAgent: t("workspace.tabs.fallback.newAgent"), - setup: t("workspace.tabs.fallback.setup"), - terminal: t("workspace.tabs.fallback.terminal"), - browser: t("workspace.tabs.fallback.browser"), - agent: t("workspace.tabs.fallback.agent"), - changes: t("panels.diff.changesLabel"), - files: t("panels.files.label"), - pullRequest: t("panels.pullRequest.label"), - }), - [t], - ); - const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels); - const trailingAccessory = useMemo( - () => ( - - ), - [menuTestIDBase, fallbackLabel, menuEntries], - ); - - const renderPresentation = useCallback( - (presentation: WorkspaceTabPresentation) => ( - - ), - [selected, active, onPress, trailingAccessory], - ); - - return ( - - {renderPresentation} - - ); -} - -const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ - tabs, - activeTabKey, - activeTab, - tabSwitcherOptions, - tabByKey, - normalizedServerId, - normalizedWorkspaceId, - onSelectSwitcherTab, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, -}: MobileWorkspaceTabSwitcherProps) { - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); - const anchorRef = useRef(null); - const tabIndexByKey = useMemo(() => { - const map = new Map(); - tabs.forEach((tab, index) => { - map.set(tab.key, index); - }); - return map; - }, [tabs]); - - const handleOpenSwitcher = useCallback(() => { - Keyboard.dismiss(); - setIsOpen(true); - }, []); - - const renderTabOption = useCallback( - ({ - option, - selected, - active, - onPress, - }: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => { - const tab = tabByKey.get(option.id); - if (!tab) { - return ; - } - const tabIndex = tabIndexByKey.get(tab.key) ?? -1; - if (tabIndex < 0) { - return ; - } - return ( - - ); - }, - [ - tabByKey, - tabIndexByKey, - tabs.length, - normalizedServerId, - normalizedWorkspaceId, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, - ], - ); - - return ( - - - {({ pressed }) => ( - <> - - - - - - )} - - - - - ); -}); - interface MobileMountedTabSlotProps { tabDescriptor: WorkspaceTabDescriptor; isVisible: boolean; @@ -902,47 +449,6 @@ function useCloseTabs(): UseCloseTabsResult { return { closingTabIds, closeTab }; } -/** - * Which project the workspace belongs to, and which machine it runs on. - * - * Compact gets both, on their own line under the workspace name: this header is the only thing on - * screen that says where the workspace lives, because the sidebar that normally carries the host - * badge is closed. It still follows the host's own badge setting, so a purely local setup stays - * quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit - * side by side, and kept on compact, where the line exists for the host anyway. - */ -function WorkspaceHeaderProjectRow({ - subtitle, - isSubtitleDistinct, - serverId, -}: { - subtitle: string; - isSubtitleDistinct: boolean; - serverId: string; -}) { - const isCompact = useIsCompactFormFactor(); - const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null; - const showProject = isSubtitleDistinct || isCompact; - if (!showProject && !hostBadge) { - return null; - } - return ( - - {showProject ? ( - - {subtitle} - - ) : null} - {showProject && hostBadge ? · : null} - {hostBadge ? : null} - - ); -} - interface WorkspaceHeaderTitleBarProps { isLoading: boolean; title: string; @@ -950,26 +456,13 @@ interface WorkspaceHeaderTitleBarProps { isSubtitleDistinct: boolean; currentBranchName: string | null; normalizedServerId: string; - normalizedWorkspaceId: string; - workspaceScripts: WorkspaceDescriptor["scripts"]; - liveTerminalIds: string[]; showWorkspaceSetup: boolean; - showCreateBrowserTab: boolean; - isMobile: boolean; - createTerminalDisabled: boolean; importAgentDisabled: boolean; copyPathDisabled: boolean; - onCreateDraftTab: () => void; - onCreateTerminal: () => void; - onCreateTerminalWithProfile: (profile: TerminalProfile) => void; - onCreateBrowser: () => void; onOpenImportSheet: () => void; onCopyWorkspacePath: () => void; onCopyBranchName: () => void; onOpenSetupTab: () => void; - onScriptTerminalStarted: (terminalId: string) => void; - onViewScriptTerminal: (terminalId: string) => void; - onOpenUrlInBrowserTab: (url: string) => void; } function WorkspaceHeaderTitleBar({ @@ -979,26 +472,13 @@ function WorkspaceHeaderTitleBar({ isSubtitleDistinct, currentBranchName, normalizedServerId, - normalizedWorkspaceId, - workspaceScripts, - liveTerminalIds, showWorkspaceSetup, - showCreateBrowserTab, - isMobile, - createTerminalDisabled, importAgentDisabled, copyPathDisabled, - onCreateDraftTab, - onCreateTerminal, - onCreateTerminalWithProfile, - onCreateBrowser, onOpenImportSheet, onCopyWorkspacePath, onCopyBranchName, onOpenSetupTab, - onScriptTerminalStarted, - onViewScriptTerminal, - onOpenUrlInBrowserTab, }: WorkspaceHeaderTitleBarProps) { return ( @@ -1017,49 +497,16 @@ function WorkspaceHeaderTitleBar({ )} - {isMobile ? ( - - ) : ( - - )} - {isMobile && workspaceScripts.length > 0 ? ( - - ) : null} + ); @@ -1293,7 +740,7 @@ function shouldInspectWorkspaceRecovery( function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { return ( <> - + {children} ); @@ -1313,17 +760,21 @@ function WorkspaceContentProviders({ ); } -function WorkspacePanelContent({ +function WorkspacePanelProviders({ launcher, - content, + children, + tabIds, + workspaceKey, }: { + workspaceKey: string; launcher: NewTabLauncher; - content: ReactNode; + children: ReactNode; + tabIds: readonly string[]; }) { return ( - - {content} - + + {children} + ); } @@ -1375,6 +826,20 @@ function WorkspaceDocumentTitleEffectSlot({ ); } +function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean { + return supportsStreamTopOverlay && activeTab?.kind === "agent"; +} + +/** A fullscreen composer covers the compact header; the column lays out without it. */ +function resolveCompactHeaderLayout(input: { + header: ReactNode; + activeTab: WorkspaceTabDescriptor | null; + composerFullscreen: boolean; +}): { header: ReactNode; floating: boolean } { + if (input.composerFullscreen) return { header: null, floating: false }; + return { header: input.header, floating: floatsHeaderOverContent(input.activeTab) }; +} + function shouldShowWorkspaceScreenHeader(input: { isFocusModeEnabled: boolean; isMobile: boolean; @@ -1819,6 +1284,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 +1325,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 +1404,7 @@ function WorkspaceScreenContent({ const visibleAgentIds = useVisibleAgentIds({ layout: workspaceLayout, tabs: uiTabs, + sheetTab, routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, }); @@ -2230,7 +1706,6 @@ function WorkspaceScreenContent({ } const tabId = openPreferredWorkspaceTarget({ - isCompact: isMobile, workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), source: "chatFiles", @@ -2300,14 +1775,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 +1820,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 +2089,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 +2114,13 @@ function WorkspaceScreenContent({ }, [ allTabDescriptorsById, + closeWorkspaceTab, confirmDiscardModifiedTab, handleCloseAgentTab, handleClosePassiveTab, handleCloseTerminalTab, + persistenceKey, + sheets, ], ); @@ -3504,17 +2954,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 +2985,6 @@ function WorkspaceScreenContent({ onOpenPreferredTarget: (target, source) => { if (!persistenceKey) return; const tabId = openPreferredWorkspacePreview({ - isCompact: isMobile, workspaceKey: persistenceKey, serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -3595,7 +3046,6 @@ function WorkspaceScreenContent({ canRenderDesktopPaneSplits, openImportSheet, openInSidePane, - isMobile, requestFileNavigation, revealWorkspaceChildTab, persistenceKey, @@ -3639,6 +3089,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 +3115,7 @@ function WorkspaceScreenContent({ focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, - paneFocusSuspended: Boolean(unfocusedPaneId), + paneFocusSuspended, onFocusPane: handleFocusPane, buildMobilePaneContentModel, }); @@ -3759,7 +3220,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 +3342,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 +3357,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 +3542,50 @@ function WorkspaceScreenContent({ const desktopContent = desktopSplitContent ?? content; const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null; - const workspacePanelContent = ( - + const workspacePanelTabIds = useMemo( + () => [...uiTabs.map((tab) => tab.tabId), ...sheets.map((sheet) => sheet.tab.tabId)], + [uiTabs, sheets], ); + const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`; + const compactHeaderLayout = resolveCompactHeaderLayout({ + header: compactWorkspaceHeader, + activeTab: activeTabDescriptor, + composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId), + }); - const workspaceCenterColumn = ( + // Sheets opened from the compact column present inside it, so their panels read the workspace + // providers, and they cover the compact header as a modal. + const workspaceCenterColumn = isMobile ? ( + + + + {(topOverlayInset) => ( + + {content} + + )} + + + + + ) : ( {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} - {isMobile ? ( - - ) : null} - {shouldRenderDesktopPaneFallback ? ( ) : null} - {workspacePanelContent} + + + {desktopContent} + + ); @@ -4190,30 +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-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 3ccf39da35..b2b226e645 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -9,7 +9,7 @@ const metrics = { actionsReservedWidth: 120, rowPaddingHorizontal: 8, tabGap: 4, - minTabWidth: 96, + minTabWidth: 64, maxTabWidth: 160, tabIconWidth: 14, tabContentGap: 4, @@ -29,7 +29,20 @@ describe("computeWorkspaceTabLayout", () => { expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.items).toHaveLength(3); expect(result.items.every((item) => item.showLabel)).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([96, 104, 96]); + expect(result.items.map((item) => item.width)).toEqual([90, 104, 83]); + }); + + it("lets short labels use their content width below the old 96px floor", () => { + const result = computeWorkspaceTabLayout({ + viewportWidth: 300, + tabLabelWidths: [29, 52], + metrics: { ...metrics, actionsReservedWidth: 36 }, + }); + expect(result.items).toEqual([ + { width: 64, showLabel: true }, + { width: 86, showLabel: true }, + ]); + expect(result.requiresHorizontalScrollFallback).toBe(false); }); it("sizes a single tab between the minimum and maximum from its content", () => { @@ -52,7 +65,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([117, 103, 96]); + expect(result.items.map((item) => item.width)).toEqual([132, 102, 82]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); @@ -75,27 +88,27 @@ describe("computeWorkspaceTabLayout", () => { it("keeps every tab at the clickable minimum at the exact fit boundary", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 532, + viewportWidth: 404, tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); it("uses horizontal scroll rather than shrinking below the clickable minimum", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 531, + viewportWidth: 403, tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 07cd56e4fb..8b499429f0 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -26,6 +26,7 @@ export interface WorkspaceTabPresentation { subtitle: string; tooltip: string; modified: boolean; + showCloseButton: boolean; titleState: "ready" | "loading"; icon: React.ComponentType; statusBucket: SidebarStateBucket | null; @@ -90,6 +91,7 @@ function WorkspaceTabPresentationResolverInner({ subtitle: descriptor.subtitle, tooltip: descriptor.tooltip, modified: attributes.modified, + showCloseButton: registration.showCloseButton, titleState: descriptor.titleState, icon: descriptor.icon, statusBucket: descriptor.statusBucket, @@ -104,6 +106,7 @@ function WorkspaceTabPresentationResolverInner({ tab.key, tab.kind, attributes.modified, + registration.showCloseButton, ], ); diff --git a/packages/app/src/sidebar-nav/model.test.ts b/packages/app/src/sidebar-nav/model.test.ts index f58acba007..f7e7ba0933 100644 --- a/packages/app/src/sidebar-nav/model.test.ts +++ b/packages/app/src/sidebar-nav/model.test.ts @@ -34,7 +34,6 @@ describe("resolveSidebarNavItems", () => { it("yields builtins then plugins, all visible, when nothing is stored", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [kanban, notes], preferences: [], }); @@ -59,7 +58,6 @@ describe("resolveSidebarNavItems", () => { it("keeps the stored order and appends newly available items as visible", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [notes, kanban], preferences: [ { key: kanbanKey, visible: false }, @@ -81,7 +79,6 @@ describe("resolveSidebarNavItems", () => { it("skips keys that are unknown or not currently available", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: [ { key: notesKey, visible: false }, @@ -101,7 +98,6 @@ describe("resolveSidebarNavItems", () => { it("lets the first of duplicate keys win", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: [ { key: "history", visible: false }, @@ -118,18 +114,13 @@ describe("resolveSidebarNavItems", () => { }); }); -describe("resolveSidebarNavItems on compact layouts", () => { +describe("resolveSidebarNavItems for the Usage item", () => { it("hides the Usage item until it is turned on", () => { - const resolve = (compact: boolean, preferences: SidebarNavPreference[]) => - summarize( - resolveSidebarNavItems({ section: "footer", compact, pluginGroups: [], preferences }), - ); + const resolve = (preferences: SidebarNavPreference[]) => + summarize(resolveSidebarNavItems({ section: "footer", pluginGroups: [], preferences })); - expect(resolve(true, [])).toEqual([{ key: "usage", visible: false }]); - expect(resolve(false, [])).toEqual([{ key: "usage", visible: true }]); - expect(resolve(true, [{ key: "usage", visible: true }])).toEqual([ - { key: "usage", visible: true }, - ]); + expect(resolve([])).toEqual([{ key: "usage", visible: false }]); + expect(resolve([{ key: "usage", visible: true }])).toEqual([{ key: "usage", visible: true }]); }); }); @@ -137,7 +128,6 @@ describe("setSidebarNavItemVisible", () => { it("toggles one item and writes the full resolved order", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [kanban], preferences: [], }); @@ -160,7 +150,6 @@ describe("setSidebarNavItemVisible", () => { ]; const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: previous, }); @@ -186,7 +175,6 @@ describe("setSidebarNavItemVisible", () => { ]; const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: previous, }); @@ -204,7 +192,6 @@ describe("setSidebarNavItemVisible", () => { summarize( resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [notes], preferences: next, }), @@ -215,7 +202,6 @@ describe("setSidebarNavItemVisible", () => { it("returns the normalized list unchanged for an unknown key", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [], preferences: [], }); @@ -229,7 +215,6 @@ describe("setSidebarNavItemVisible", () => { describe("moveSidebarNavItem", () => { const items = resolveSidebarNavItems({ section: "header", - compact: false, pluginGroups: [kanban], preferences: [], }); @@ -307,13 +292,12 @@ describe("footer section", () => { it("resolves the Usage item first, then plugin rows", () => { const items = resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [sync], preferences: [], }); expect(summarize(items)).toEqual([ - { key: "usage", visible: true }, + { key: "usage", visible: false }, { key: syncKey, visible: true }, ]); }); @@ -321,18 +305,16 @@ describe("footer section", () => { it("ignores header built-ins stored in footer preferences", () => { const items = resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [], preferences: [{ key: "history", visible: false }], }); - expect(summarize(items)).toEqual([{ key: "usage", visible: true }]); + expect(summarize(items)).toEqual([{ key: "usage", visible: false }]); }); it("ignores the footer icon buttons, which are fixed and not items", () => { const items = resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [sync], preferences: [ { key: "add-project", visible: false }, @@ -350,33 +332,31 @@ describe("footer section", () => { ]); }); - it("moves and hides footer rows while keeping an unavailable plugin's entry", () => { + it("moves and shows footer rows while keeping an unavailable plugin's entry", () => { const notesPreference = { key: notesKey, visible: false }; const previous: SidebarNavPreference[] = [notesPreference]; const items = resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [sync], preferences: previous, }); const moved = moveSidebarNavItem({ items, key: syncKey, direction: "up", previous }); - const hidden = setSidebarNavItemVisible({ + const shown = setSidebarNavItemVisible({ items: resolveSidebarNavItems({ section: "footer", - compact: false, pluginGroups: [sync], preferences: moved, }), key: "usage", - visible: false, + visible: true, previous: moved, }); - expect(hidden).toEqual([ + expect(shown).toEqual([ notesPreference, { key: syncKey, visible: true }, - { key: "usage", visible: false }, + { key: "usage", visible: true }, ]); }); }); diff --git a/packages/app/src/sidebar-nav/model.ts b/packages/app/src/sidebar-nav/model.ts index b5da97c034..6b98821e93 100644 --- a/packages/app/src/sidebar-nav/model.ts +++ b/packages/app/src/sidebar-nav/model.ts @@ -69,14 +69,11 @@ export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): strin return BUILTIN_SHORTCUT_ACTIONS[id]; } -/** - * Builtins that start hidden on compact layouts, until the user turns them on. A phone's footer - * has no room to spare for the Usage summary. - */ -const HIDDEN_BY_DEFAULT_ON_COMPACT: ReadonlySet = new Set(["usage"]); +/** Builtins that start hidden until the user turns them on: the Usage summary is opt-in. */ +const HIDDEN_BY_DEFAULT: ReadonlySet = new Set(["usage"]); -function builtinVisibleByDefault(id: BuiltinSidebarItemId, compact: boolean): boolean { - return !(compact && HIDDEN_BY_DEFAULT_ON_COMPACT.has(id)); +function builtinVisibleByDefault(id: BuiltinSidebarItemId): boolean { + return !HIDDEN_BY_DEFAULT.has(id); } export function pluginSidebarNavKey( @@ -95,8 +92,6 @@ function isBuiltinSidebarItemId
( export function resolveSidebarNavItems
(input: { section: Section; - /** Compact layouts start some builtins hidden; a stored preference always wins. */ - compact: boolean; pluginGroups: readonly PluginSidebarGroup[]; preferences: readonly SidebarNavPreference[]; }): SidebarNavItem
[] { @@ -131,7 +126,7 @@ export function resolveSidebarNavItems
(input: { kind: "builtin", key: id, id, - visible: builtinVisibleByDefault(id, input.compact), + visible: builtinVisibleByDefault(id), }); } for (const [key, group] of groupsByKey) { diff --git a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts index 96b99a6f1b..dcebf670df 100644 --- a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts +++ b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts @@ -1,5 +1,4 @@ import { useCallback, useMemo } from "react"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { useAppSettings } from "@/hooks/use-settings"; import type { AppSettings } from "@/hooks/use-settings/storage"; import { useInstalledPlugins } from "@/plugins/registry"; @@ -28,15 +27,14 @@ export function useSidebarNavItems
( section: Section, ): UseSidebarNavItemsReturn
{ const plugins = useInstalledPlugins(); - const compact = useIsCompactFormFactor(); const { settings, updateSettings } = useAppSettings(); const field = PREFERENCE_FIELDS[section]; const preferences = settings[field]; const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]); const items = useMemo( - () => resolveSidebarNavItems({ section, compact, pluginGroups, preferences }), - [compact, pluginGroups, preferences, section], + () => resolveSidebarNavItems({ section, pluginGroups, preferences }), + [pluginGroups, preferences, section], ); const setVisible = useCallback( @@ -45,7 +43,6 @@ export function useSidebarNavItems
( const previous = current[field]; const currentItems = resolveSidebarNavItems({ section, - compact, pluginGroups, preferences: previous, }); @@ -54,7 +51,7 @@ export function useSidebarNavItems
( }; }); }, - [compact, field, pluginGroups, section, updateSettings], + [field, pluginGroups, section, updateSettings], ); const move = useCallback( @@ -63,7 +60,6 @@ export function useSidebarNavItems
( const previous = current[field]; const currentItems = resolveSidebarNavItems({ section, - compact, pluginGroups, preferences: previous, }); @@ -72,7 +68,7 @@ export function useSidebarNavItems
( }; }); }, - [compact, field, pluginGroups, section, updateSettings], + [field, pluginGroups, section, updateSettings], ); return { items, setVisible, move }; 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..5f170bed7e 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@react-native-async-storage/async-storage", () => { const storage = new Map(); @@ -16,6 +16,9 @@ vi.mock("@react-native-async-storage/async-storage", () => { }); import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { defaultChangesState, type ChangesState } from "@/panels/changes/state"; @@ -24,6 +27,7 @@ import { canDismissPaneInLayout, collectAllPanes, collectAllTabs, + selectPaneFocusSuspended, createWorkspaceLayoutStore, observeOpenWorkspaceAgentIds, createDefaultLayout, @@ -4438,3 +4442,677 @@ it("persists the once-only PR add after closing, and clears it when purging the expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBeUndefined(); expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBe("pull_request"); }); + +describe("compact workspace sheet", () => { + const workspaceKey = "server-1:sheet-ws"; + const fileTarget = { kind: "file" as const, path: "src/index.ts" }; + + function createCompactStore() { + const store = createWorkspaceLayoutStore(workspaceLayoutIds); + store.setState({ layoutByWorkspace: {}, sheetsByWorkspace: {} }); + store.getState().setLayoutMode("compact"); + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + return store; + } + + function topSheet(store: ReturnType) { + return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1); + } + + function sheetTargets(store: ReturnType) { + return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map( + (sheet) => sheet.tab.target, + ); + } + + function tabKinds(store: ReturnType): string[] { + const layout = store.getState().layoutByWorkspace[workspaceKey]; + const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab])); + return (findPaneById(layout.root, "main")?.tabIds ?? []).map( + (tabId) => tabsById.get(tabId)?.target.kind ?? "missing", + ); + } + + function seedAgents(agents: Array>): void { + const timestamp = new Date("2026-04-21T10:00:00.000Z"); + useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient); + useSessionStore.getState().setAgents( + "server-1", + new Map( + agents.map((agent) => [ + agent.id, + { + serverId: "server-1", + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: timestamp, + updatedAt: timestamp, + lastUserMessageAt: null, + lastActivityAt: timestamp, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: undefined, + lastUsage: undefined, + lastError: null, + title: agent.id, + cwd: "/repo", + model: null, + features: undefined, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + labels: {}, + projectPlacement: null, + ...agent, + } satisfies Agent, + ]), + ), + ); + } + + it("reveals a file in the sheet and leaves the tab strip unchanged", () => { + const store = createCompactStore(); + const before = tabKinds(store); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(topSheet(store)?.tab).toEqual({ + tabId, + target: fileTarget, + createdAt: expect.any(Number), + }); + expect(tabKinds(store)).toEqual(before); + }); + + it("focuses a target that is already open as a tab instead of using the sheet", () => { + const store = createCompactStore(); + store.getState().setLayoutMode("split-panes"); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + store.getState().setLayoutMode("compact"); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(tabId).toBe(fileTabId); + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId); + }); + + it("stacks the next sheet target over the open sheet", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(tabKinds(store)).toEqual(["terminal"]); + + store.getState().closeTab(workspaceKey, nextTabId ?? ""); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("closes a lower sheet and keeps the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + store.getState().closeTab(workspaceKey, fileTabId ?? ""); + + expect(sheetTargets(store)).toEqual([nextTarget]); + expect(tabKinds(store)).toEqual(["terminal"]); + }); + + it("uncovers a covered sheet by dropping the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(tabId).toBe(fileTabId); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("opens the top sheet as a tab and dismisses the sheets that would cover it", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + store.getState().openSheetAsTab(workspaceKey, nextTabId ?? ""); + + expect(sheetTargets(store)).toEqual([]); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(nextTabId); + }); + + it("closes the sheet when a tab target is revealed, and keeps it for background opens", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + store.getState().openTab({ + workspaceKey, + target: { kind: "file", path: "src/background.ts" }, + intent: "background", + }); + expect(topSheet(store)?.tab.target).toEqual(fileTarget); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + + store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + expect(topSheet(store)).toBeUndefined(); + }); + + it("moves the sheet tab into the focused pane with the same tab id and state", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + if (!sheetTabId) throw new Error("Expected a sheet tab"); + store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 }); + + const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId); + + expect(tabId).toBe(sheetTabId); + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + const pane = findPaneContainingTab(layout.root, sheetTabId); + expect(pane?.id).toBe(layout.focusedPaneId); + expect(pane?.focusedTabId).toBe(sheetTabId); + expect(collectAllTabs(layout.root).find((tab) => tab.tabId === sheetTabId)?.state).toEqual({ + scrollTop: 120, + }); + }); + + it("closes the sheet through the ordinary close and ignores focus requests for it", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + if (!sheetTabId) throw new Error("Expected a sheet tab"); + const layoutBefore = store.getState().layoutByWorkspace[workspaceKey]; + + store.getState().focusTab(workspaceKey, sheetTabId); + expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore); + + store.getState().closeTab(workspaceKey, sheetTabId); + expect(topSheet(store)).toBeUndefined(); + expect(tabKinds(store)).toEqual(["terminal"]); + }); + + it("suspends pane focus while the sheet covers the panes", () => { + const store = createCompactStore(); + expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false); + + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(true); + + store.getState().closeTab(workspaceKey, sheetTabId ?? ""); + expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false); + }); + + it("never uses the sheet on wide layouts", () => { + const store = createCompactStore(); + store.getState().setLayoutMode("split-panes"); + + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(store.getState().sheetsByWorkspace).toEqual({}); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + }); + + it("keeps every stacked sheet as a tab when the layout widens, focusing the top one", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + store.getState().setLayoutMode("split-panes"); + + expect(store.getState().sheetsByWorkspace).toEqual({}); + expect(tabKinds(store)).toEqual(["terminal", "file", "file"]); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId); + }); + + it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + { id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" }, + ]); + const store = createCompactStore(); + + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + expect(topSheet(store)).toMatchObject({ + tab: { target: { kind: "agent", agentId: "child" } }, + policy: { openAsTab: true }, + }); + + store.getState().openTab({ + workspaceKey, + target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" }, + intent: "reveal", + }); + expect(topSheet(store)).toMatchObject({ + tab: { target: { kind: "provider_subagent", subagentId: "task-1" } }, + policy: { openAsTab: false }, + }); + + for (const agentId of ["root", "other-workspace-child"]) { + store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" }); + } + expect(topSheet(store)).toBeUndefined(); + expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); + }); + + it("gives a sheet target opened from inside the sheet the sheet's own parent", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const rootTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" }); + const childSheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + parentTabId: rootTabId ?? undefined, + }); + + const fileSheetTabId = store.getState().openTab({ + workspaceKey, + target: fileTarget, + intent: "reveal", + parentTabId: childSheetTabId ?? undefined, + }); + expect(topSheet(store)?.parentTabId).toBe(rootTabId); + + store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? ""); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId); + }); + + it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + { id: "second-root", parentAgentId: null, workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const rootTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" }); + const openChildSheet = () => + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + parentTabId: rootTabId ?? undefined, + }) ?? undefined; + + const terminalTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + parentTabId: openChildSheet(), + }); + expect(topSheet(store)).toBeUndefined(); + let layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId); + + const secondRootTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "second-root" }, + intent: "reveal", + parentTabId: openChildSheet(), + }); + expect(topSheet(store)).toBeUndefined(); + layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId); + expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId); + expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); + }); + + describe("unsaved sheet work", () => { + const sheetPanel = (tabId: string) => ({ + serverId: "server-1", + workspaceId: "sheet-ws", + tabId, + }); + let modifiedTabId: string | null = null; + + function openModifiedFileSheet(store: ReturnType): string { + const tabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + if (!tabId) throw new Error("Expected a sheet tab"); + store.getState().setTabState(workspaceKey, tabId, { scrollTop: 40 }); + setPanelInstanceAttributes(sheetPanel(tabId), { modified: true }); + modifiedTabId = tabId; + return tabId; + } + + function stripTab(store: ReturnType, tabId: string) { + const layout = store.getState().layoutByWorkspace[workspaceKey]; + return collectAllTabs(layout.root).find((tab) => tab.tabId === tabId) ?? null; + } + + function focusedTabId(store: ReturnType): string | null { + const layout = store.getState().layoutByWorkspace[workspaceKey]; + return findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null; + } + + afterEach(() => { + if (modifiedTabId) setPanelInstanceAttributes(sheetPanel(modifiedTabId), { modified: false }); + modifiedTabId = null; + }); + + function sheetTabId(store: ReturnType): string | null { + return topSheet(store)?.tab.tabId ?? null; + } + + it("stacks the next sheet target over a modified sheet", () => { + const store = createCompactStore(); + openModifiedFileSheet(store); + + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + expect(sheetTabId(store)).toBe(nextTabId); + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.state).toEqual({ + scrollTop: 40, + }); + }); + + it("keeps a covered sheet in place under a modified sheet instead of reordering", () => { + const store = createCompactStore(); + const otherTarget = { kind: "file" as const, path: "src/other.ts" }; + const otherTabId = store + .getState() + .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); + const modifiedSheetTabId = openModifiedFileSheet(store); + const unmodifiedTopTarget = { kind: "file" as const, path: "src/third.ts" }; + store.getState().openTab({ workspaceKey, target: unmodifiedTopTarget, intent: "reveal" }); + const focusedBefore = focusedTabId(store); + + const tabId = store + .getState() + .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); + + expect(tabId).toBe(otherTabId); + // Moving a sheet would remount its panel, so the stack keeps its order: the modified sheet + // stays on top of the one revealed, and only the unmodified sheet above them closes. + expect(sheetTargets(store)).toEqual([otherTarget, fileTarget]); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(stripTab(store, tabId ?? "")).toBeNull(); + expect(focusedTabId(store)).toBe(focusedBefore); + }); + + it("opens a tab target under a modified sheet without closing it", () => { + const store = createCompactStore(); + const modifiedSheetTabId = openModifiedFileSheet(store); + + const terminalTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(focusedTabId(store)).toBe(terminalTabId); + }); + + it("keeps a modified sheet open when another tab is chosen", () => { + const store = createCompactStore(); + const secondTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? ""; + const modifiedSheetTabId = openModifiedFileSheet(store); + + store.getState().focusTab(workspaceKey, firstTabId); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(focusedTabId(store)).toBe(firstTabId); + + store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? ""); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); + expect(focusedTabId(store)).toBe(secondTabId); + + store.getState().closeTab(workspaceKey, modifiedSheetTabId); + expect(sheetTabId(store)).toBeNull(); + expect(tabKinds(store)).toEqual(["terminal", "terminal"]); + }); + + it("closes an unmodified sheet when another tab is chosen", () => { + const store = createCompactStore(); + const secondTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "terminal", terminalId: "term-2" }, + intent: "reveal", + }); + const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? ""; + + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().focusTab(workspaceKey, firstTabId); + expect(sheetTabId(store)).toBeNull(); + expect(focusedTabId(store)).toBe(firstTabId); + + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? ""); + expect(sheetTabId(store)).toBeNull(); + expect(focusedTabId(store)).toBe(secondTabId); + expect(tabKinds(store)).toEqual(["terminal", "terminal"]); + }); + }); + + it("closes the sheet when its agent leaves the workspace", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + const snapshot = { + terminalsHydrated: false, + activeAgentIds: ["root"], + autoOpenAgentIds: [], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }; + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false }); + expect(topSheet(store)).toBeDefined(); + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true }); + expect(topSheet(store)).toBeUndefined(); + }); + + it("drops a departed agent's sheet from the middle of the stack", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + store.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: false, + activeAgentIds: ["root"], + autoOpenAgentIds: [], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + }); + + it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const sheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + const snapshot = { + agentsHydrated: true, + terminalsHydrated: false, + activeAgentIds: ["root", "child"], + autoOpenAgentIds: ["child"], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }; + + store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] }); + expect(topSheet(store)?.tab.tabId).toBe(sheetTabId); + + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: null, workspaceId: "sheet-ws" }, + ]); + store.getState().reconcileTabs(workspaceKey, snapshot); + + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect( + collectAllTabs(layout.root).filter( + (tab) => tab.target.kind === "agent" && tab.target.agentId === "child", + ), + ).toHaveLength(1); + }); + + it("re-resolves the sheet policy when the sheet tab gets another target", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const sheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" }, + intent: "reveal", + }); + + const replacedTabId = store + .getState() + .replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" }); + + expect(replacedTabId).toBe(sheetTabId); + expect(topSheet(store)).toMatchObject({ + tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } }, + policy: { openAsTab: true }, + }); + }); + + it("moves the sheet tab into the tab strip when it gets a target the sheet does not host", () => { + const store = createCompactStore(); + const sheetTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + const replacedTabId = store.getState().replaceTab(workspaceKey, sheetTabId ?? "", { + kind: "terminal", + terminalId: "term-2", + }); + + expect(topSheet(store)).toBeUndefined(); + expect(replacedTabId).toBe(sheetTabId); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId); + expect(tabKinds(store)).toEqual(["terminal", "terminal"]); + }); + + it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => { + const store = createCompactStore(); + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const received: string[][] = []; + const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store); + + const sheetTabId = store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + store.getState().closeTab(workspaceKey, sheetTabId ?? ""); + stop(); + + expect(received).toEqual([[], ["child"], []]); + }); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 5de128b631..2aea369df6 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -59,7 +59,12 @@ import { type WorkspaceTabSnapshot, type WorkspaceLayout, } from "@/stores/workspace-layout-actions"; -import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; +import { + buildDeterministicWorkspaceTabId, + normalizeWorkspaceTabTarget, +} from "@/workspace-tabs/identity"; +import { isSheetTabModified, resolveCompactSheetPolicy } from "@/workspace-tabs/compact-sheet"; +import { panelResourceKey, type CompactSheetPolicy } from "@/panels/panel-manifest"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations"; @@ -105,7 +110,29 @@ export interface OpenWorkspaceTabInput { state?: JsonValue; } +/** + * How the app shell lays out a workspace: compact (one tab at a time, plus sheets), a single + * wide pane (touch tablets, which cannot render splits), or split panes. + */ +export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes"; + +/** A tab shown in a sheet over the compact workspace instead of in a pane. */ +export interface SheetTab { + tab: WorkspaceTab; + policy: CompactSheetPolicy; + parentTabId: string | null; +} + interface WorkspaceLayoutStore { + /** Published once by the app shell; callers never pass a form factor to open a tab. */ + layoutMode: WorkspaceLayoutMode; + /** + * Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The + * list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount + * its panel. A workspace with no sheet has no entry. Not persisted: a reload returns to the tab + * strip. + */ + sheetsByWorkspace: Record; layoutByWorkspace: Record; splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; @@ -116,7 +143,18 @@ interface WorkspaceLayoutStore { sidePaneIdByWorkspace: Record; /** Workspaces where PR detection already added its tab once; a closed tab never returns. */ pullRequestTabAutoOpenedByWorkspace: Record; + /** + * The one entry point for showing a target. On compact layouts a target whose panel declares + * `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id. + */ openTab: (input: OpenWorkspaceTabInput) => string | null; + /** Whether `openTab` with this input would show the target in a sheet. */ + opensInSheet: ( + input: Pick, + ) => boolean; + setLayoutMode: (mode: WorkspaceLayoutMode) => void; + /** Moves a sheet's tab, keeping its tab id, into the focused pane. */ + openSheetAsTab: (workspaceKey: string, tabId: string) => string | null; /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ autoOpenPullRequestTab: ( workspaceKey: string, @@ -472,6 +510,17 @@ export function selectExplorerSidebarPaneId( return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]); } +/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */ +export function selectPaneFocusSuspended( + state: Pick, + workspaceKey: string, +): boolean { + return ( + Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) || + workspaceKey in state.sheetsByWorkspace + ); +} + /** Whether the Explorer sidebar pane is currently on screen. */ export function selectIsExplorerSidebarVisible( state: ExplorerSidebarState, @@ -637,6 +686,245 @@ function attachParentTab(input: { }); } +const NO_SHEETS: readonly SheetTab[] = []; + +function getSheets( + state: Pick, + workspaceKey: string, +): readonly SheetTab[] { + return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS; +} + +function findSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): SheetTab | undefined { + return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId); +} + +function withSheets( + state: Pick, + workspaceKey: string, + sheets: readonly SheetTab[], +): Pick { + if (sheets.length > 0) { + return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } }; + } + if (!(workspaceKey in state.sheetsByWorkspace)) { + return { sheetsByWorkspace: state.sheetsByWorkspace }; + } + const { [workspaceKey]: _removed, ...sheetsByWorkspace } = state.sheetsByWorkspace; + return { sheetsByWorkspace }; +} + +function withoutSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheet.tab.tabId !== tabId), + ); +} + +function withSheetTab( + state: Pick, + workspaceKey: string, + tabId: string, + update: (sheet: SheetTab) => SheetTab, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).map((sheet) => + sheet.tab.tabId === tabId ? update(sheet) : sheet, + ), + ); +} + +function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) { + if (!layout) return false; + const resourceKey = panelResourceKey(target); + return collectAllTabs(layout.root).some((tab) => panelResourceKey(tab.target) === resourceKey); +} + +function resolveSheetPolicy( + state: Pick, + workspaceKey: string, + target: WorkspaceTabTarget, + intent: WorkspaceTabOpenIntent, +): CompactSheetPolicy | null { + if (state.layoutMode !== "compact" || intent === "background") return null; + if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) { + return null; + } + return resolveCompactSheetPolicy(workspaceKey, target); +} + +/** + * The parent a newly opened tab links to. A sheet tab is not in the layout, so a target opened + * from inside a sheet inherits that sheet's own parent instead. + */ +function resolveOpenParentTabId( + state: Pick, + workspaceKey: string, + parentTabId: string | undefined, +): string | null { + if (!parentTabId) return null; + return findSheet(state, workspaceKey, parentTabId)?.parentTabId ?? parentTabId; +} + +function placeTargetInSheet(input: { + /** The sheet whose tab the target keeps, if any. */ + kept: SheetTab | undefined; + target: WorkspaceTabTarget; + intent: WorkspaceTabOpenIntent; + policy: CompactSheetPolicy; + parentTabId: string | null; + state: JsonValue | undefined; +}): SheetTab { + if (input.kept) { + return { ...input.kept, tab: { ...input.kept.tab, target: input.target } }; + } + return { + tab: { + tabId: + input.intent === "reveal" + ? buildDeterministicWorkspaceTabId(input.target) + : createWorkspaceTabInstanceId(), + target: input.target, + createdAt: Date.now(), + ...(input.state !== undefined ? { state: input.state } : {}), + }, + policy: input.policy, + parentTabId: input.parentTabId, + }; +} + +/** + * Moves one sheet's tab into the workspace's focused pane, keeping its tab id and state. The + * other sheets stay; callers that bring the tab to the front dismiss them. + */ +function promoteSheetToTab( + state: WorkspaceLayoutStore, + workspaceKey: string, + tabId: string, +): { tabId: string; patch: Partial } | null { + const sheet = findSheet(state, workspaceKey, tabId); + if (!sheet) return null; + const { tab } = sheet; + const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT); + const input = { + ...placement, + target: tab.target, + now: tab.createdAt, + createTabId: () => tab.tabId, + state: tab.state, + }; + const result = isTargetOpenAsTab(placement.layout, tab.target) + ? revealTargetInLayout(input) + : createTabInLayout(input); + if (!result) return null; + const nextLayout = attachParentTab({ + layout: keepWorkspaceFocusOutOfExplorerSidebar( + result.layout, + placement.explorerSidebarPaneId, + placement.layout.focusedPaneId, + ), + childTabId: result.tabId, + parentTabId: sheet.parentTabId, + }); + return { + tabId: result.tabId, + patch: { + ...withoutSheet(state, workspaceKey, tab.tabId), + ...withoutFocusRestoration(state, workspaceKey), + hiddenAgentIdsByWorkspace: + tab.target.kind === "agent" + ? removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + workspaceKey, + tab.target.agentId, + ) + : state.hiddenAgentIdsByWorkspace, + layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: nextLayout }, + }, + }; +} + +/** + * A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the + * panel and drop the work. Only the explicit, guarded close removes it. + */ +function sheetHoldsUnsavedWork(workspaceKey: string, sheet: SheetTab): boolean { + return isSheetTabModified(workspaceKey, sheet.tab.tabId); +} + +/** + * Where an open lands among the sheet tabs, or null when it opens as a tab. A new target goes on + * top. A reveal of a resource a sheet already shows keeps that sheet's tab and uncovers it by + * closing the sheets above it, except ones holding unsaved work, which stay where they are. + */ +function resolveSheetPlacement( + state: WorkspaceLayoutStore, + workspaceKey: string, + target: WorkspaceTabTarget, + intent: WorkspaceTabOpenIntent, +): { policy: CompactSheetPolicy; keptIndex: number | null } | null { + const policy = resolveSheetPolicy(state, workspaceKey, target, intent); + if (!policy) return null; + if (intent !== "reveal") return { policy, keptIndex: null }; + const sheets = getSheets(state, workspaceKey); + const resourceKey = panelResourceKey(target); + const index = sheets.findIndex((sheet) => panelResourceKey(sheet.tab.target) === resourceKey); + return { policy, keptIndex: index === -1 ? null : index }; +} + +/** Closes the sheets because navigation moved on underneath them, except ones with unsaved work. */ +function dismissSheets( + state: Pick, + workspaceKey: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheetHoldsUnsavedWork(workspaceKey, sheet)), + ); +} + +/** Moves a sheet's tab to the front of the tab strip, dismissing the sheets that would cover it. */ +function openSheetTabInFront( + state: WorkspaceLayoutStore, + workspaceKey: string, + tabId: string, +): { tabId: string; patch: Partial } | null { + const promoted = promoteSheetToTab(state, workspaceKey, tabId); + if (!promoted) return null; + const next = { ...state, ...promoted.patch }; + return { + tabId: promoted.tabId, + patch: { ...promoted.patch, ...dismissSheets(next, workspaceKey) }, + }; +} + +/** + * A sheet agent closes once the workspace no longer has it (archived or gone), like its tab would, + * and once it stops being a subagent: as a root agent, reconcile gives it its own tab, and the sheet + * would hold a second tab with the same id. + */ +function sheetAgentNoLongerFits( + workspaceKey: string, + sheet: SheetTab, + snapshot: WorkspaceTabSnapshot, +): boolean { + if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false; + if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true; + return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null; +} + /** * Splits an Explorer sidebar out of the pane the user is in. Only reached by layouts saved * before the Explorer became part of the default tree; new ones are born with it. @@ -676,6 +964,8 @@ export function createWorkspaceLayoutStore( } return tabId; }, + layoutMode: "split-panes", + sheetsByWorkspace: {}, layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, @@ -691,6 +981,41 @@ export function createWorkspaceLayoutStore( if (!normalizedWorkspaceKey || !normalizedTarget) { return null; } + const parentTabId = resolveOpenParentTabId( + get(), + normalizedWorkspaceKey, + input.parentTabId, + ); + const sheetPlacement = resolveSheetPlacement( + get(), + normalizedWorkspaceKey, + normalizedTarget, + input.intent, + ); + if (sheetPlacement) { + const sheets = getSheets(get(), normalizedWorkspaceKey); + const { keptIndex } = sheetPlacement; + const sheet = placeTargetInSheet({ + kept: keptIndex === null ? undefined : sheets[keptIndex], + target: normalizedTarget, + intent: input.intent, + policy: sheetPlacement.policy, + parentTabId, + state: input.state, + }); + const next = + keptIndex === null + ? [...sheets, sheet] + : sheets.flatMap((existing, index) => { + if (index === keptIndex) return [sheet]; + if (index < keptIndex) return [existing]; + return sheetHoldsUnsavedWork(normalizedWorkspaceKey, existing) + ? [existing] + : []; + }); + set((state) => withSheets(state, normalizedWorkspaceKey, next)); + return sheet.tab.tabId; + } const placement = getOpenTabPlacement( get(), normalizedWorkspaceKey, @@ -734,6 +1059,8 @@ export function createWorkspaceLayoutStore( const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent"; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + // A tab brought to the front dismisses the sheet over it. + ...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace @@ -751,17 +1078,53 @@ export function createWorkspaceLayoutStore( : state.pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: input.parentTabId + [normalizedWorkspaceKey]: parentTabId ? attachParentTab({ layout: nextLayout, childTabId: result.tabId, - parentTabId: input.parentTabId, + parentTabId, }) : nextLayout, }, })); return result.tabId; }, + opensInSheet: (input) => { + const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey); + const normalizedTarget = normalizeWorkspaceTabTarget(input.target); + if (!normalizedWorkspaceKey || !normalizedTarget) return false; + return ( + resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !== + null + ); + }, + setLayoutMode: (mode) => { + set((state) => { + if (state.layoutMode === mode) return state; + if (mode === "compact") return { layoutMode: mode }; + // Widening keeps whatever the user had open in sheets as ordinary tabs. + const next: WorkspaceLayoutStore = { ...state, layoutMode: mode }; + for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) { + for (const sheet of sheets) { + const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId); + Object.assign( + next, + promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId), + ); + } + } + return next; + }); + }, + openSheetAsTab: (workspaceKey, tabId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + if (!normalizedWorkspaceKey || !normalizedTabId) return null; + const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId); + if (!promoted) return null; + set(promoted.patch); + return promoted.tabId; + }, showExplorerSidebar: (workspaceKey) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -881,6 +1244,9 @@ export function createWorkspaceLayoutStore( } set((state) => { + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withoutSheet(state, normalizedWorkspaceKey, normalizedTabId); + } const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); const explorerSidebarPaneId = resolveExplorerSidebarPaneId( layout, @@ -988,6 +1354,8 @@ export function createWorkspaceLayoutStore( return { ...withoutFocusRestoration(state, normalizedWorkspaceKey), + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1011,6 +1379,8 @@ export function createWorkspaceLayoutStore( }); return nextLayout ? { + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1024,6 +1394,29 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null; + if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) { + const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget); + const next: WorkspaceLayoutStore = { + ...get(), + ...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({ + ...sheet, + policy: policy ?? sheet.policy, + tab: { + ...sheet.tab, + target: normalizedTarget, + ...(tabState !== undefined ? { state: tabState } : {}), + }, + })), + }; + if (policy) { + set({ sheetsByWorkspace: next.sheetsByWorkspace }); + return normalizedTabId; + } + // A target sheets do not host becomes the tab it would have opened as. + const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId); + set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId)); + return promoted?.tabId ?? null; + } const result = replaceTabTargetInLayout({ layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), tabId: normalizedTabId, @@ -1054,6 +1447,15 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); if (!normalizedWorkspaceKey || !normalizedTabId) return; set((state) => { + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withSheetTab(state, normalizedWorkspaceKey, normalizedTabId, (sheet) => { + const { state: _previous, ...tab } = sheet.tab; + return { + ...sheet, + tab: tabState === undefined ? tab : { ...tab, state: tabState }, + }; + }); + } const layout = setTabStateInLayout({ layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), tabId: normalizedTabId, @@ -1148,15 +1550,22 @@ export function createWorkspaceLayoutStore( ); } } + const sheets = getSheets(state, normalizedWorkspaceKey); + const fittingSheets = sheets.filter( + (sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot), + ); + const sheetAgentGone = fittingSheets.length !== sheets.length; if ( state.layoutByWorkspace[normalizedWorkspaceKey] && nextLayout === rawLayout && - pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace + pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace && + !sheetAgentGone ) { return state; } return { + ...(sheetAgentGone ? withSheets(state, normalizedWorkspaceKey, fittingSheets) : {}), pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, @@ -1661,7 +2070,8 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.focusRestorationByWorkspace || normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || normalizedWorkspaceKey in state.sidePaneIdByWorkspace || - normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace; + normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace || + normalizedWorkspaceKey in state.sheetsByWorkspace; if (!hasAny) { return state; } @@ -1690,6 +2100,7 @@ export function createWorkspaceLayoutStore( const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } = state.sidePaneIdByWorkspace; return { + ...withSheets(state, normalizedWorkspaceKey, NO_SHEETS), pullRequestTabAutoOpenedByWorkspace, layoutByWorkspace, splitSizesByWorkspace, @@ -1795,10 +2206,10 @@ export function createWorkspaceLayoutStore( export const useWorkspaceLayoutStore = createWorkspaceLayoutStore(); /** - * The agent tabs that exist right now, across every workspace of this host, independently - * of which workspace views are mounted. Timeline sync uses this to release a chat when its - * tab closes. It is not a subscription source: this layout is restored from disk at launch, - * so subscribing to it would resume every agent the user has ever opened. + * The agent tabs that exist right now, including sheet tabs, across every workspace of this host, + * independently of which workspace views are mounted. Timeline sync uses this to release a chat + * when its tab closes. It is not a subscription source: this layout is restored from disk at + * launch, so subscribing to it would resume every agent the user has ever opened. */ export function observeOpenWorkspaceAgentIds( serverId: string, @@ -1806,11 +2217,21 @@ export function observeOpenWorkspaceAgentIds( store = useWorkspaceLayoutStore, ): () => void { let previous: string[] | undefined; - const publish = (state: Pick) => { + const publish = ( + state: Pick, + ) => { const ids = new Set(); - for (const [workspaceKey, layout] of Object.entries(state.layoutByWorkspace)) { + const tabsByWorkspace = [ + ...Object.entries(state.layoutByWorkspace).map( + ([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const, + ), + ...Object.entries(state.sheetsByWorkspace).map( + ([workspaceKey, sheets]) => [workspaceKey, sheets.map((sheet) => sheet.tab)] as const, + ), + ]; + for (const [workspaceKey, tabs] of tabsByWorkspace) { if (!workspaceKey.startsWith(`${serverId}:`)) continue; - for (const tab of collectAllTabs(layout.root)) { + for (const tab of tabs) { if (tab.target.kind === "agent") ids.add(tab.target.agentId); } } @@ -1821,7 +2242,12 @@ export function observeOpenWorkspaceAgentIds( listener(next); }; const unsubscribe = store.subscribe((state, before) => { - if (state.layoutByWorkspace !== before.layoutByWorkspace) publish(state); + if ( + state.layoutByWorkspace !== before.layoutByWorkspace || + state.sheetsByWorkspace !== before.sheetsByWorkspace + ) { + publish(state); + } }); publish(store.getState()); return unsubscribe; diff --git a/packages/app/src/styles/density.ts b/packages/app/src/styles/density.ts new file mode 100644 index 0000000000..2383685d24 --- /dev/null +++ b/packages/app/src/styles/density.ts @@ -0,0 +1,49 @@ +import { isCompactBreakpoint } from "@/constants/layout"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +/** + * Density sizes for surfaces that opt into touch sizing: rows and icons grow on compact + * layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay + * desktop-sized everywhere, so a surface migrates by swapping its sizes for these. + * + * Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes + * are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping. + */ + +export type DensityIconSize = keyof typeof ICON_SIZE; + +const COMPACT_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; + +// Compact layouts end where `md` begins; see `isCompactBreakpoint`. +function byLayout(compact: T, desktop: T) { + return { xs: compact, md: desktop }; +} + +export const density = { + /** A list row such as a sidebar entry. */ + rowHeight: byLayout(44, 36), + /** A row in a tight group, such as the sidebar's navigation rows. */ + tightRowHeight: byLayout(36, 28), + /** A square icon-only button that sits in a row or toolbar. */ + iconButton: byLayout(44, 28), +}; + +/** The width or height of a slot that holds an icon of `size`. */ +export function densityIconBox(size: DensityIconSize) { + return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]); +} + +export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number { + return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size]; +} + +/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */ +export function densityIconProps

( + size: DensityIconSize, + mapping: (theme: Theme) => P, +) { + return (theme: Theme, rt: { breakpoint?: string }) => ({ + ...mapping(theme), + size: densityIconSize(rt, size), + }); +} diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts index 707085e7c9..461d980ecd 100644 --- a/packages/app/src/styles/theme.test.ts +++ b/packages/app/src/styles/theme.test.ts @@ -68,9 +68,9 @@ describe("Pure black theme", () => { }); describe("Sidebar interaction surfaces", () => { - it("keeps Light selection distinct from the sidebar surface", () => { - expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface1); - expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface3); + it("keeps Light hover and selection darker than the sidebar surface", () => { + expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3); + expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4); expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar); }); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index f58075b3a1..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)", @@ -597,6 +597,8 @@ export const ICON_SIZE = { lg: 20, } as const; +export const ICON_STROKE_WIDTH = 1.5; + export const FONT_WEIGHT = { normal: "normal" as const, medium: "500" as const, diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index e93d62eb2d..9c6227a34f 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -9,3 +9,4 @@ export { type ArchiveFinishedStatus, type UseArchiveFinishedSubagentsInput, } from "./use-archive-finished"; +export { useSubagentOpensInSheet } from "./opens-in-sheet"; diff --git a/packages/app/src/subagents/opens-in-sheet.test.tsx b/packages/app/src/subagents/opens-in-sheet.test.tsx new file mode 100644 index 0000000000..0792091135 --- /dev/null +++ b/packages/app/src/subagents/opens-in-sheet.test.tsx @@ -0,0 +1,133 @@ +/** + * @vitest-environment jsdom + */ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { useSubagentOpensInSheet } from "./opens-in-sheet"; +import { selectSubagentsForParent, type SubagentRow } from "./select"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "ws-main"; +const AGENT_TIMESTAMP = new Date("2026-04-21T10:00:00.000Z"); + +const AGENT_DEFAULTS: Agent = { + serverId: SERVER_ID, + id: "agent", + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: AGENT_TIMESTAMP, + updatedAt: AGENT_TIMESTAMP, + lastUserMessageAt: null, + lastActivityAt: AGENT_TIMESTAMP, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: undefined, + lastUsage: undefined, + lastError: null, + title: "Agent", + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, + model: null, + features: undefined, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + parentAgentId: null, + labels: {}, + projectPlacement: null, +}; + +const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, +}); +if (!workspaceKey) throw new Error("workspace key"); + +function seedParentWithChild(): SubagentRow { + const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" }; + const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" }; + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore.getState().setAgents( + SERVER_ID, + new Map([ + [parent.id, parent], + [child.id, child], + ]), + ); + const [row] = selectSubagentsForParent( + useSessionStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent" }, + new Set(), + ); + if (!row) throw new Error("child row"); + return row; +} + +afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); + useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + layoutByWorkspace: {}, + sheetsByWorkspace: {}, + hiddenAgentIdsByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, + }); +}); + +describe("useSubagentOpensInSheet", () => { + it("re-renders the row's answer as the child's tab opens and closes", () => { + const row = seedParentWithChild(); + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("compact"); + const target = { kind: "agent", agentId: "child" } as const; + + // The row renders its answer, as the track does, so a stale render is a stale answer. + const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row)); + expect(result.current).toBe(true); + + let tabId: string | null = null; + act(() => { + const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" }); + tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null; + }); + expect(tabId).not.toBeNull(); + expect(result.current).toBe(false); + + act(() => { + store.closeTab(workspaceKey, tabId ?? ""); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/packages/app/src/subagents/opens-in-sheet.ts b/packages/app/src/subagents/opens-in-sheet.ts new file mode 100644 index 0000000000..26432ea039 --- /dev/null +++ b/packages/app/src/subagents/opens-in-sheet.ts @@ -0,0 +1,29 @@ +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import type { SubagentRow } from "./select"; + +/** The workspace target a subagent row opens. */ +export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget { + return row.kind === "provider" + ? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id } + : { kind: "agent", agentId: row.id }; +} + +/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */ +export function useSubagentOpensInSheet( + workspaceKey: string | null, +): (row: SubagentRow) => boolean { + // The rows render the answer, so this subscribes to what the answer reads: the layout mode and + // the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet, + // and a row rendered while that tab existed must re-render once it closes, or choosing the row + // would still close the list it sits in. + useWorkspaceLayoutStore((state) => state.layoutMode); + useWorkspaceLayoutStore((state) => + workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, + ); + return (row) => + workspaceKey !== null && + useWorkspaceLayoutStore + .getState() + .opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" }); +} diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index 96badfd354..01d6880d5b 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -1,8 +1,11 @@ import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; +import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; import { observeProviderSubagentTimeline, providerSubagentKey, + refreshProviderSubagents, + resyncProviderSubagents, useProviderSubagentStore, } from "./provider-store"; @@ -15,6 +18,7 @@ afterEach(() => { descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), + trackedParents: new Set(), }); }); @@ -727,3 +731,96 @@ describe("projected child history", () => { expect(current().lastSeq).toBe(2000); }); }); + +describe("resyncing after the update feed resubscribes", () => { + function child( + id: string, + parentAgentId: string, + status: ProviderSubagentDescriptorPayload["status"], + ): ProviderSubagentDescriptorPayload { + return { + id, + parentAgentId, + provider: "claude", + title: "Explore", + description: null, + status, + createdAt: "2026-10-07T10:00:00.000Z", + updatedAt: "2026-10-07T10:00:00.000Z", + toolCallId: id, + }; + } + + function fakeDaemon(lists: Record) { + const requested: string[] = []; + return { + requested, + lists, + client: { + listProviderSubagents: async (parentAgentId: string) => { + requested.push(parentAgentId); + return { + requestId: "list", + parentAgentId, + subagents: lists[parentAgentId] ?? [], + error: null, + }; + }, + }, + }; + } + + const status = (parentAgentId: string, subagentId: string) => + useProviderSubagentStore + .getState() + .descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status; + + test("catches up on children that finished or started while updates were not delivered", async () => { + const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] }); + await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID); + expect(status(PARENT_ID, "child-1")).toBe("running"); + + // The connection dropped: the daemon finished child-1 and ran child-2 without telling us. + daemon.lists[PARENT_ID] = [ + child("child-1", PARENT_ID, "completed"), + child("child-2", PARENT_ID, "completed"), + ]; + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(status(PARENT_ID, "child-1")).toBe("completed"); + expect(status(PARENT_ID, "child-2")).toBe("completed"); + }); + + test("catches up on a parent whose first list failed while disconnected", async () => { + const daemon = fakeDaemon({}); + const disconnected = { + listProviderSubagents: async () => { + throw new DaemonConnectionError("Transport not connected"); + }, + }; + await expect( + refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID), + ).rejects.toBeInstanceOf(DaemonConnectionError); + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: child("child-1", PARENT_ID, "running"), + }); + + // The connection dropped again: the daemon finished child-1 without telling us. + daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")]; + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(status(PARENT_ID, "child-1")).toBe("completed"); + }); + + test("lists again only the parents this server was showing", async () => { + const daemon = fakeDaemon({}); + await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID); + await refreshProviderSubagents(daemon.client, "server-2", "other-parent"); + daemon.requested.length = 0; + + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(daemon.requested).toEqual([PARENT_ID]); + }); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 86e857d774..fd44dae8d2 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -28,6 +28,9 @@ interface ProviderSubagentState { descriptors: Map; timelines: Map; hiddenFromTrack: Set; + /** Parents whose list this client asked for, so a resubscribed update feed can catch them up. */ + trackedParents: Set; + trackParent(serverId: string, parentAgentId: string): void; hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void; replaceList( serverId: string, @@ -84,6 +87,7 @@ export function refreshProviderSubagents( const pending = clientRequests.get(requestKey); if (pending) return pending; + useProviderSubagentStore.getState().trackParent(serverId, parentAgentId); const request = client .listProviderSubagents(parentAgentId) .then((payload) => { @@ -101,6 +105,27 @@ function parentPrefix(serverId: string, parentAgentId: string): string { return `${serverId}\0${parentAgentId}\0`; } +/** + * Updates are not replayed across a reconnect, so a child that finished or started while the + * update feed was down stays stale until its parent is listed again. Call this once the feed has + * resubscribed, so every update after the list is delivered. + */ +export async function resyncProviderSubagents( + client: ProviderSubagentListClient, + serverId: string, +): Promise { + const serverPrefix = `${serverId}\0`; + const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents] + .filter((key) => key.startsWith(serverPrefix)) + .map((key) => key.slice(serverPrefix.length, -1)); + await Promise.all( + parentAgentIds.map((parentAgentId) => + // A parent that was archived meanwhile has nothing left to catch up. + refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined), + ), + ); +} + const EMPTY_TIMELINE: ProviderSubagentTimelineState = { tail: [], head: [], @@ -147,6 +172,14 @@ export const useProviderSubagentStore = create((set) => ( descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), + trackedParents: new Set(), + trackParent(serverId, parentAgentId) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + if (state.trackedParents.has(prefix)) return state; + return { trackedParents: new Set(state.trackedParents).add(prefix) }; + }); + }, hideFromTrack(serverId, parentAgentId, subagentIds) { set((state) => { const hiddenFromTrack = new Set(state.hiddenFromTrack); diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 953c13e0c6..c1834587d2 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -13,6 +13,7 @@ import { type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; +import { getPanelManifest } from "@/panels/panel-manifest"; import type { SubagentRow } from "./select"; import type { ArchiveFinishedStatus } from "./use-archive-finished"; import { @@ -34,6 +35,11 @@ export interface SubagentsTrackProps { rows: SubagentRow[]; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + /** + * Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on + * close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list. + */ + opensInSheet: (row: SubagentRow) => boolean; onArchiveSubagent: (id: string) => void; onArchiveFinished?: () => void; archiveFinishedStatus?: ArchiveFinishedStatus; @@ -52,6 +58,7 @@ function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPre ...data, tooltip: data.label, modified: false, + showCloseButton: getPanelManifest(data.kind).showCloseButton, icon, }; } @@ -61,6 +68,7 @@ export function SubagentsTrack({ rows, onOpenSubagent, onOpenProviderSubagent, + opensInSheet, onArchiveSubagent, onArchiveFinished, archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS, @@ -101,6 +109,7 @@ export function SubagentsTrack({ serverId={serverId} onOpenSubagent={onOpenSubagent} onOpenProviderSubagent={onOpenProviderSubagent} + opensInSheet={opensInSheet(row)} onArchiveSubagent={onArchiveSubagent} onDetachSubagent={onDetachSubagent} /> @@ -172,6 +181,7 @@ interface SubagentsTrackRowProps { row: SubagentRow; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + opensInSheet: boolean; onArchiveSubagent: (id: string) => void; onDetachSubagent?: (id: string) => void; } @@ -181,6 +191,7 @@ function SubagentsTrackRow({ row, onOpenSubagent, onOpenProviderSubagent, + opensInSheet, onArchiveSubagent, onDetachSubagent, }: SubagentsTrackRowProps): ReactElement { @@ -243,6 +254,7 @@ function SubagentsTrackRow({ {renderRow} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 10a2b0b632..b173545abc 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -73,11 +73,14 @@ function settleMountRefits(): Promise { return new Promise((resolve) => setTimeout(resolve, 2_600)); } -function createTerminalHost(input: { +interface CreateTerminalHostInput { width: number; height: number; scrollback?: number; -}): MountedTerminal { + isMac?: boolean; +} + +function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { const root = document.createElement("div"); root.style.width = `${input.width}px`; root.style.height = `${input.height}px`; @@ -97,7 +100,9 @@ function createTerminalHost(input: { const terminalKeys: TerminalKeyRecord[] = []; const inputModeChanges: TerminalInputModeState[] = []; const openedUrls: string[] = []; - const runtime = new TerminalEmulatorRuntime(); + const runtime = new TerminalEmulatorRuntime( + input.isMac === undefined ? undefined : { isMac: input.isMac }, + ); runtime.setCallbacks({ callbacks: { onInput: (data) => { @@ -176,30 +181,37 @@ function getBrowserTerminal(): BrowserTerminal { return terminal; } -function dispatchTerminalKey(input: { +interface DispatchTerminalKeyInput { host: HTMLElement; key: string; + keyCode?: number; shiftKey?: boolean; ctrlKey?: boolean; altKey?: boolean; metaKey?: boolean; -}): boolean { +} + +function dispatchTerminalKey(input: DispatchTerminalKeyInput): boolean { const textarea = input.host.querySelector("textarea"); if (!textarea) { throw new Error("Expected xterm textarea to be mounted"); } textarea.focus(); - return textarea.dispatchEvent( - new KeyboardEvent("keydown", { - key: input.key, - shiftKey: input.shiftKey ?? false, - ctrlKey: input.ctrlKey ?? false, - altKey: input.altKey ?? false, - metaKey: input.metaKey ?? false, - bubbles: true, - cancelable: true, - }), - ); + const event = new KeyboardEvent("keydown", { + key: input.key, + shiftKey: input.shiftKey ?? false, + ctrlKey: input.ctrlKey ?? false, + altKey: input.altKey ?? false, + metaKey: input.metaKey ?? false, + bubbles: true, + cancelable: true, + }); + if (input.keyCode !== undefined) { + // Synthetic KeyboardEvents always report keyCode 0; xterm's fallback encoder + // switches on keyCode, so hardware-like events need it filled in. + Object.defineProperty(event, "keyCode", { value: input.keyCode }); + } + return textarea.dispatchEvent(event); } /** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */ @@ -514,6 +526,78 @@ describe("terminal emulator runtime in a real browser", () => { }); }); + it("translates mac editing shortcuts into shell editing sequences", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360, isMac: true }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39 }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "Backspace", keyCode: 8, metaKey: true }); + await nextFrame(); + + expect(mounted.inputs).toEqual([ + "\x1b[C", // plain arrow keeps xterm's default encoding + "\x01", // cmd+left -> line start + "\x05", // cmd+right -> line end + "\x1bb", // option+left -> word back + "\x1bf", // option+right -> word forward + "\x15", // cmd+backspace -> kill line + ]); + expect(mounted.terminalKeys).toEqual([]); + }); + + it.each([true, false])("keeps pending modifier chips authoritative (isMac=%s)", async (isMac) => { + const mounted = createTerminalHost({ width: 720, height: 360, isMac }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + mounted.runtime.setPendingModifiers({ + pendingModifiers: { ctrl: true, alt: false, shift: false }, + }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true }); + await nextFrame(); + expect(mounted.inputs).toEqual([]); + expect(mounted.terminalKeys).toEqual([ + { key: "ArrowLeft", ctrl: true, alt: true, shift: false, meta: false }, + ]); + }); + + it("keeps cmd+shift+arrow free for app-level shortcuts", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360, isMac: true }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + dispatchTerminalKey({ + host: mounted.host, + key: "ArrowRight", + keyCode: 39, + metaKey: true, + shiftKey: true, + }); + await nextFrame(); + + expect(mounted.inputs).toEqual([]); + expect(mounted.terminalKeys).toEqual([]); + }); + + it("does not remap modified arrows on non-mac platforms", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360, isMac: false }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); + await nextFrame(); + + // Meta+arrow is ignored by xterm; alt+arrow takes xterm's generic encoding. + expect(mounted.inputs).toEqual(["\x1b[1;3C"]); + }); + it.each([ { name: "DA1", bytes: "\x1b[c" }, { name: "DA1-zero", bytes: "\x1b[0c" }, diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 0108815add..a11cedbfe2 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -241,6 +241,8 @@ describe("terminal-emulator-runtime", () => { hasSelection: () => false, getSelection: () => "", paste: () => {}, + input: () => {}, + scrollToBottom: () => {}, }); expect(passedToXterm).toBe(!opensFind); expect(findRequests).toBe(opensFind ? 1 : 0); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 4b7afb74c2..cc4462ff7b 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -15,11 +15,13 @@ import { } from "@getpaseo/protocol/terminal-input-mode"; import { type PendingTerminalModifiers, + hasPendingTerminalModifiers, isAppleHandheldPlatform, isTerminalModifierDomKey, mergeTerminalModifiers, normalizeDomTerminalKey, normalizeTerminalTransportKey, + resolveMacTerminalEditingShortcut, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; @@ -299,7 +301,12 @@ export class TerminalEmulatorRuntime { attachKeyEventHandler( terminal: Pick< Terminal, - "attachCustomKeyEventHandler" | "hasSelection" | "getSelection" | "paste" + | "attachCustomKeyEventHandler" + | "hasSelection" + | "getSelection" + | "paste" + | "input" + | "scrollToBottom" >, ): void { terminal.attachCustomKeyEventHandler((event) => { @@ -339,6 +346,10 @@ export class TerminalEmulatorRuntime { return true; } + if (this.maybeSendMacEditingShortcut(terminal, event)) { + return false; + } + const normalizedKey = normalizeDomTerminalKey(event.key); if (!normalizedKey || isTerminalModifierDomKey(event.key)) { return true; @@ -371,7 +382,7 @@ export class TerminalEmulatorRuntime { ...modifiers, }); - if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) { + if (hasPendingTerminalModifiers(this.pendingModifiers)) { this.callbacks.onPendingModifiersConsumed?.(); } @@ -426,6 +437,26 @@ export class TerminalEmulatorRuntime { return this.inputModeTracker.getState(); } + private maybeSendMacEditingShortcut( + terminal: Pick, + event: KeyboardEvent, + ): boolean { + if (!this.options.isMac || hasPendingTerminalModifiers(this.pendingModifiers)) { + return false; + } + const editingShortcutData = resolveMacTerminalEditingShortcut(event); + if (editingShortcutData === null) { + return false; + } + // Routed through terminal.input() so the data takes the same path as typed + // keys: onData -> callbacks.onInput, selection clearing included. + terminal.input(editingShortcutData, true); + terminal.scrollToBottom(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + mount(input: TerminalEmulatorRuntimeMountInput): void { this.unmount(); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index efa77dda73..c7fc27652e 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; 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 new file mode 100644 index 0000000000..0199c08ff2 --- /dev/null +++ b/packages/app/src/usage/agent-usage.tsx @@ -0,0 +1,74 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { UsageCard } from "./card"; +import { usageCopy } from "./copy"; +import { useUsageDisplay } from "./display"; +import { useAgentUsage } from "./queries"; +import type { UsageReportEntry } from "./types"; + +/** + * The usage of the account an agent runs under, for its context window details: one card per + * report as it streams in, without pins. Nothing on hosts that cannot report usage, or for an + * agent with no account to report. + */ +export function AgentUsage({ + serverId, + agentId, + refreshable, +}: { + serverId: string; + agentId: string; + /** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */ + refreshable: boolean; +}) { + const view = useAgentUsage(serverId, agentId); + if (view.kind === "none") return null; + return ( + <> + + {view.kind === "ready" ? ( + + ) : ( + + {view.kind === "loading" ? usageCopy.loading : view.message} + + )} + + ); +} + +function AgentUsageCards({ + serverId, + agentId, + reports, + refreshable, +}: { + serverId: string; + agentId: string; + reports: readonly UsageReportEntry[]; + refreshable: boolean; +}) { + const { display } = useUsageDisplay(reports); + return reports.map((entry) => ( + + )); +} + +const styles = StyleSheet.create((theme) => ({ + divider: { height: 1, backgroundColor: theme.colors.borderAccent }, + message: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, +})); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index 8260273e06..a1936e0f97 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -40,28 +40,49 @@ function reportContent(report: UsageReport) { return { windows: [], balances: [], details: [], message }; } +function reportMessages(entry: UsageReportEntry): string[] { + if (entry.report.status === "available") return []; + if (entry.loginErrors) + return entry.loginErrors.map( + (login) => `${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, + refreshable, compact = false, }: { serverId: string; + agentId?: string; entry: UsageReportEntry; display: UsageDisplay; + /** Whether each window row pins the window to the sidebar. */ + pinnable: boolean; + /** Whether the header has a Refresh button. Without one the freshness shows on the card. */ + refreshable: boolean; 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; + 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], @@ -93,25 +114,33 @@ export function UsageCard({ {status} ) : null} - + {refreshable ? ( + + ) : null} - {message ? ( - - {message} + {messages.length > 0 ? ( + + {messages.join("\n")} ) : null} {windows.length > 0 || balances.length > 0 ? ( {windows.map((window) => ( - + ))} {balances.map((balance) => ( @@ -158,14 +187,16 @@ export function UsageCard({ ); } -function PinnableWindowBar({ +function CardWindowBar({ entry, window, display, + pinnable, }: { entry: UsageReportEntry; window: UsageWindow; display: UsageDisplay; + pinnable: boolean; }) { const pin = useMemo( () => ({ sourceId: entry.sourceId, windowId: window.id }), @@ -177,6 +208,7 @@ function PinnableWindowBar({ - {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/copy.ts b/packages/app/src/usage/copy.ts index cda74b464c..639272283b 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -25,6 +25,7 @@ export const usageCopy = { empty: "No usage data", noHosts: "No connected hosts", errorTitle: "Unable to load usage", + agentError: (reason: string) => `Unable to load usage: ${reason}`, hostUnavailable: (host: string) => `Connect to ${host} to see usage`, hostUpgradeRequired: (host: string) => `Update ${host} to see usage`, clientUnavailable: "Host connection is not ready", diff --git a/packages/app/src/usage/display.ts b/packages/app/src/usage/display.ts index cfacf8d738..5b4e534693 100644 --- a/packages/app/src/usage/display.ts +++ b/packages/app/src/usage/display.ts @@ -10,6 +10,7 @@ import { type UsagePin, type UsagePreferences, } from "./preferences"; +import type { UsageReportEntry } from "./types"; /** What usage views need from the preferences: how to read percents, and the pins. */ export interface UsageDisplay { @@ -19,15 +20,21 @@ export interface UsageDisplay { togglePin: (pin: UsagePin) => void; } -/** The device's usage preferences, and the commands that change them. */ -export function useUsagePreferences(serverId?: string): { +interface UsagePreferencesState { preferences: UsagePreferences; display: UsageDisplay; -} { +} + +/** The device's usage preferences, with pins resolved against a host's reports. */ +export function useUsagePreferences(serverId?: string): UsagePreferencesState { + const defaultServerId = useUsageHostId(); + return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId)); +} + +/** The device's usage preferences, with pins resolved against the given reports. */ +export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState { const { settings, updateSettings } = useAppSettings(); const preferences = settings.usage; - const defaultServerId = useUsageHostId(); - const reports = useUsageHostReports(serverId ?? defaultServerId); const setDisplayAs = useCallback( (displayAs: UsageDisplayAs) => { void updateSettings((current) => ({ usage: setUsageDisplayAs(current.usage, displayAs) })); 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/in-sidebar.ts b/packages/app/src/usage/in-sidebar.ts new file mode 100644 index 0000000000..96883bb80f --- /dev/null +++ b/packages/app/src/usage/in-sidebar.ts @@ -0,0 +1,19 @@ +import { useCallback } from "react"; +import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; + +/** + * Whether the sidebar footer shows the Usage summary: the same switch as Settings > Sidebar. + * Window rows pin to the summary only while it is on. + */ +export function useUsageInSidebar(): { + inSidebar: boolean; + setInSidebar: (visible: boolean) => void; +} { + const { items, setVisible } = useSidebarNavItems("footer"); + const inSidebar = items.some((item) => item.key === "usage" && item.visible); + const setInSidebar = useCallback( + (visible: boolean) => setVisible("usage", visible), + [setVisible], + ); + return { inSidebar, setInSidebar }; +} diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index 021c627e31..45599aba2d 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -1,3 +1,9 @@ +export { AgentUsage } from "./agent-usage"; export { HostUsageSection } from "./host-usage-section"; -export { UsageScreen } from "./usage-screen"; -export { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "./sidebar-item"; +export { useHostReportsUsage } from "./queries"; +export { + UsageSidebarItem, + UsageSidebarRoot, + useHasUsageSummary, + useOpenSidebarUsage, +} from "./sidebar-item"; diff --git a/packages/app/src/usage/list.tsx b/packages/app/src/usage/list.tsx index f0c23dd0f7..10e76ba57f 100644 --- a/packages/app/src/usage/list.tsx +++ b/packages/app/src/usage/list.tsx @@ -3,9 +3,10 @@ import { StyleSheet } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; import { UsageCard } from "./card"; import type { UsageDisplay } from "./display"; +import { useUsageInSidebar } from "./in-sidebar"; import type { UsageReportEntry } from "./types"; -/** One card per report (source + account). */ +/** One card per report (source + account). Window rows pin while the sidebar summary is on. */ export function UsageList({ serverId, reports, @@ -15,11 +16,18 @@ export function UsageList({ reports: UsageReportEntry[]; display: UsageDisplay; }) { + const { inSidebar } = useUsageInSidebar(); return ( {reports.map((entry) => ( - + ))} diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 834746e1a7..a2c493619d 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest"; import { displayPercent, formatUsageFreshness, + resolveAgentUsageView, replaceReport, resolveUsageRefresh, resolveUsageHostId, - resolveUsageScreenHostId, + resolveUsageModalHostId, resolveUsageView, + settleReports, + upsertReport, usageWindowRowLabel, type UsageHost, type UsageQueryState, @@ -114,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(); }); }); @@ -216,3 +219,109 @@ describe("replaceReport", () => { expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]); }); }); + +describe("upsertReport", () => { + const alpha = entry({ sourceId: "alpha", planLabel: "Old" }); + const beta = entry({ sourceId: "beta" }); + + it("starts a list from the first streamed report", () => { + expect(upsertReport(undefined, alpha)).toEqual([alpha]); + }); + + it("appends a report the list does not have yet", () => { + expect(upsertReport([alpha], beta)).toEqual([alpha, beta]); + }); + + it("replaces a report the list already has, in place", () => { + const streamed = entry({ sourceId: "alpha", planLabel: "New" }); + expect(upsertReport([alpha, beta], streamed)).toEqual([streamed, beta]); + }); + + it("keeps a copy fetched after the streamed one, such as a Refresh that landed first", () => { + const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" }; + expect(upsertReport([refreshed, beta], alpha)).toEqual([refreshed, beta]); + }); +}); + +describe("settleReports", () => { + const alpha = entry({ sourceId: "alpha", planLabel: "Old" }); + const beta = entry({ sourceId: "beta" }); + + it("takes the finished list, dropping reports the host no longer has", () => { + const streamed = entry({ sourceId: "alpha", planLabel: "New" }); + expect(settleReports([alpha, beta], [streamed])).toEqual([streamed]); + }); + + it("keeps a report refreshed while the list streamed over its older copy in the list", () => { + const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" }; + expect(settleReports([refreshed, beta], [alpha, beta])).toEqual([refreshed, beta]); + }); + + it("keeps the displayed order when providers finish a refresh in reverse order", () => { + const refreshed = entry({ sourceId: "alpha", planLabel: "New" }); + const streamed = upsertReport(upsertReport([alpha, beta], beta), refreshed); + expect(settleReports(streamed, [beta, refreshed])).toEqual([refreshed, beta]); + }); + + it("drops missing reports and appends new ones without moving surviving reports", () => { + const gamma = entry({ sourceId: "gamma" }); + const delta = entry({ sourceId: "delta" }); + expect(settleReports([alpha, beta, gamma], [delta, gamma, alpha])).toEqual([ + alpha, + gamma, + delta, + ]); + }); + + it("takes the finished list as is when nothing was shown yet", () => { + expect(settleReports(undefined, [alpha, beta])).toEqual([alpha, beta]); + }); +}); + +describe("resolveAgentUsageView", () => { + const report = entry({ sourceId: "claude" }); + const idle: UsageQueryState = { data: undefined, error: null, isFetching: false }; + + it("shows nothing on a host that cannot report usage", () => { + expect(resolveAgentUsageView({ canReport: false, query: idle })).toEqual({ kind: "none" }); + }); + + it("loads until the first report streams in", () => { + expect( + resolveAgentUsageView({ canReport: true, query: { ...idle, isFetching: true } }), + ).toEqual({ kind: "loading" }); + }); + + it("shows each report as it streams in, before the request finishes", () => { + expect( + resolveAgentUsageView({ + canReport: true, + query: { data: [report], error: null, isFetching: true }, + }), + ).toEqual({ kind: "ready", reports: [report] }); + }); + + it("shows nothing for an agent with no account to report", () => { + expect(resolveAgentUsageView({ canReport: true, query: { ...idle, data: [] } })).toEqual({ + kind: "none", + }); + }); + + it("says why a failed request has nothing to show", () => { + expect( + resolveAgentUsageView({ + canReport: true, + query: { ...idle, error: new Error("Unknown agent") }, + }), + ).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" }); + }); + + it("says the request failed instead of showing the reports from before it", () => { + expect( + resolveAgentUsageView({ + canReport: true, + query: { data: [report], error: new Error("Unknown agent"), isFetching: false }, + }), + ).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" }); + }); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 0e44ddae18..ed8a39163a 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -61,6 +61,47 @@ export function replaceReport( return reports.map((report) => (report.id === reportId ? refreshed : report)); } +/** + * The later-fetched of two copies of one report. A list request's copies were fetched when it + * started, so a Refresh that lands while it streams is newer than what the request still delivers. + */ +function laterFetched(shown: UsageReportEntry, arriving: UsageReportEntry): UsageReportEntry { + return Date.parse(shown.fetchedAt) > Date.parse(arriving.fetchedAt) ? shown : arriving; +} + +/** + * A report list with one streamed report in place of its previous copy, or appended if new. A + * copy fetched after the streamed one stays. + */ +export function upsertReport( + reports: readonly UsageReportEntry[] | undefined, + report: UsageReportEntry, +): UsageReportEntry[] { + if (!reports?.some((entry) => entry.id === report.id)) return [...(reports ?? []), report]; + return reports.map((entry) => (entry.id === report.id ? laterFetched(entry, report) : entry)); +} + +/** + * Keep displayed reports in place: the finished list is in fetch-completion order, which can + * change on every refresh. Drop missing reports, append new ones, and keep newer fetched copies. + */ +export function settleReports( + shown: readonly UsageReportEntry[] | undefined, + finished: readonly UsageReportEntry[], +): UsageReportEntry[] { + const remaining = new Map(finished.map((report) => [report.id, report])); + const settled: UsageReportEntry[] = []; + for (const report of shown ?? []) { + const arriving = remaining.get(report.id); + if (!arriving) { + continue; + } + settled.push(laterFetched(report, arriving)); + remaining.delete(report.id); + } + return [...settled, ...remaining.values()]; +} + export interface UsageQueryState { data: UsageReportEntry[] | undefined; error: unknown; @@ -90,6 +131,31 @@ export function resolveUsageView(input: { return { kind: "loading" }; } +/** What a meter popover shows of its agent's usage: nothing while the host cannot say. */ +export type AgentUsageView = + | { kind: "none" } + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; reports: UsageReportEntry[] }; + +export function resolveAgentUsageView(input: { + canReport: boolean; + query: UsageQueryState; +}): AgentUsageView { + const { canReport, query } = input; + if (!canReport) return { kind: "none" }; + // A failed request keeps the reports from before it, or those that streamed in before it failed; + // shown alone they would pass for the agent's complete, current usage. + if (query.error) { + const reason = query.error instanceof Error ? query.error.message : String(query.error); + return { kind: "error", message: usageCopy.agentError(reason) }; + } + if (query.data) { + return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data }; + } + return { kind: "loading" }; +} + export interface UsageHost { serverId: string; label: string; @@ -117,11 +183,11 @@ export function resolveUsageHostId(choice: UsageHostChoice): string | null { } /** - * The host the Usage screen shows: the picked host while it is connected, even one that cannot - * report usage so the screen says to update it; else the sidebar row's host; else the first + * The host the Usage modal shows: the picked host while it is connected, even one that cannot + * report usage so the modal says to update it; else the sidebar row's host; else the first * connected host. */ -export function resolveUsageScreenHostId(choice: UsageHostChoice): string | null { +export function resolveUsageModalHostId(choice: UsageHostChoice): string | null { const connected = choice.hosts.filter((host) => host.isConnected); const picked = connected.find((host) => host.serverId === choice.pickedServerId); return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index 18ec7c2f1a..886c21e190 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -1,16 +1,16 @@ -import { useCallback } from "react"; 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 { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; 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"; const DISPLAY_AS_OPTIONS: SegmentedControlOption[] = [ @@ -18,22 +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 sidebarItems = useSidebarNavItems("footer"); - const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible); - const { setVisible } = sidebarItems; - const setInSidebar = useCallback( - (visible: boolean) => setVisible("usage", visible), - [setVisible], - ); +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 f5a3fe1ed6..7b6f210543 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -1,6 +1,12 @@ import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client"; import { useCallback, useMemo } from "react"; -import { skipToken, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { + skipToken, + useMutation, + useQueryClient, + type QueryClient, + type QueryKey, +} from "@tanstack/react-query"; import { useShallow } from "zustand/shallow"; import { useFetchQuery } from "@/data/query"; import { @@ -13,11 +19,15 @@ import { useSessionStore, type SessionState } from "@/stores/session-store"; import { usageCopy } from "./copy"; import { replaceReport, + resolveAgentUsageView, resolveUsageRefresh, resolveUsageView, + settleReports, type UsageHost, type UsageQueryState, + type AgentUsageView, type UsageRefresh, + upsertReport, } from "./model"; import type { UsageReportEntry, UsageView } from "./types"; @@ -25,8 +35,17 @@ import type { UsageReportEntry, UsageView } from "./types"; // an explicit refresh passes `forceRefresh` and reaches the source's API. const REPORTS_STALE_TIME_MS = 60_000; +/** Every report list of a host: its own and each agent's. */ +function hostUsageQueryKey(serverId: string) { + return ["usage", serverId] as const; +} + function usageReportsQueryKey(serverId: string) { - return ["usage", "reports", serverId] as const; + return [...hostUsageQueryKey(serverId), "reports"] as const; +} + +function agentUsageQueryKey(serverId: string, agentId: string) { + return [...hostUsageQueryKey(serverId), "agent", agentId] as const; } function requireClient(serverId: string) { @@ -35,18 +54,58 @@ function requireClient(serverId: string) { return client; } -async function listReports(serverId: string, forceRefresh = false): Promise { - return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports; +/** + * Lists a host's reports, or one agent's, writing each into `queryKey` as it streams in so a slow + * source never holds back the others. The finished list then replaces the streamed one, dropping + * any report the host no longer has, but never a report refreshed while the list streamed. + */ +async function streamReports(input: { + queryClient: QueryClient; + queryKey: QueryKey; + serverId: string; + agentId?: string; + forceRefresh?: boolean; + /** Once aborted, reports still on their way are dropped instead of written. */ + signal?: AbortSignal; +}): Promise { + const { queryClient, queryKey, serverId, agentId, forceRefresh = false, signal } = input; + const { reports } = await requireClient(serverId).listUsageReports( + { agentId, forceRefresh }, + (report) => { + if (signal?.aborted) return; + queryClient.setQueryData(queryKey, (current) => + upsertReport(current, report), + ); + }, + ); + return settleReports(queryClient.getQueryData(queryKey), reports); +} + +function listReports( + queryClient: QueryClient, + serverId: string, + forceRefresh = false, +): Promise { + return streamReports({ + queryClient, + queryKey: usageReportsQueryKey(serverId), + serverId, + forceRefresh, + }); } 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 ); } @@ -57,7 +116,7 @@ function supportsUsage(session: SessionState | undefined): boolean { async function refreshReports(queryClient: QueryClient, serverId: string): Promise { await queryClient.fetchQuery({ queryKey: usageReportsQueryKey(serverId), - queryFn: () => listReports(serverId, true), + queryFn: () => listReports(queryClient, serverId, true), staleTime: 0, }); } @@ -77,7 +136,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); const query = useFetchQuery({ queryKey: usageReportsQueryKey(serverId), - queryFn: () => listReports(serverId), + queryFn: () => listReports(queryClient, serverId), enabled: isConnected && isSupported, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, @@ -102,15 +161,51 @@ const NO_REPORTS: UsageReportEntry[] = []; * load, or without a host. */ export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { + const queryClient = useQueryClient(); const query = useFetchQuery({ queryKey: usageReportsQueryKey(serverId ?? ""), - queryFn: serverId ? () => listReports(serverId) : skipToken, + queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, }); return query.data ?? NO_REPORTS; } +/** + * The reports of the account an agent runs under, as its meter's tooltip or sheet shows them. + * Fetched while either is mounted, so only while it is open. + */ +export function useAgentUsage(serverId: string, agentId: string): AgentUsageView { + const queryClient = useQueryClient(); + const canReport = useHostReportsUsage(serverId); + const queryKey = agentUsageQueryKey(serverId, agentId); + const query = useFetchQuery({ + queryKey, + // Consuming the signal cancels the request when the details close, so a reopen sends a new + // request instead of joining one scoped to the login the agent ran under before. + queryFn: ({ signal }) => streamReports({ queryClient, queryKey, serverId, agentId, signal }), + enabled: canReport, + // Another agent's reports never stand in while this one's load. + dataShape: "value", + // Dropped as soon as the details close: an agent resumed under another login keeps its ID, so + // reports kept from an earlier open could show the old login. The daemon's cache keeps each + // open's request cheap. + gcTime: 0, + // The daemon's errors (an unknown agent) do not heal on retry, and reopening the details + // fetches again; retrying would hold the loading sentence for seconds instead. + retry: false, + staleTimeMs: REPORTS_STALE_TIME_MS, + }); + return resolveAgentUsageView({ canReport, query: toQueryState(query) }); +} + +/** Whether a host is connected and reports usage, read without fetching anything. */ +export function useHostReportsUsage(serverId: string): boolean { + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); + return isConnected && isSupported; +} + /** Every host with whether it is connected and reports usage, in host order. */ export function useUsageHosts(): UsageHost[] { const hosts = useHosts(); @@ -132,20 +227,23 @@ export function useUsageHosts(): UsageHost[] { } /** - * Forces the source to fetch one report, and only that report. The result replaces the report - * in its host's list, 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.setQueryData(usageReportsQueryKey(serverId), (reports) => - reports ? replaceReport(reports, reportId, report) : reports, + 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/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 8b668eab00..5260a1c44b 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -21,6 +21,7 @@ function highlightStyle(pinned: boolean, hovered: boolean) { export function UsageWindowBar({ window, displayAs, + pinnable, pinned, onTogglePin, pinLabel, @@ -28,6 +29,8 @@ export function UsageWindowBar({ }: { window: UsageWindow; displayAs: UsageDisplayAs; + /** Whether the row pins the window to the sidebar. When false the row is a plain bar. */ + pinnable: boolean; pinned: boolean; onTogglePin: () => void; /** What the row pins, naming the source and window: "Pin Claude Session". */ @@ -44,6 +47,24 @@ export function UsageWindowBar({ const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); + const content = { + label: window.label, + value, + trailing, + isAtRisk, + percent: shownPct ?? 0, + tone, + pinned, + }; + + // Same padding as the pinnable row, so bars line up in both modes. + if (!pinnable) { + return ( + + + + ); + } // The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same // padding so toggling only changes the background. @@ -59,14 +80,9 @@ export function UsageWindowBar({ > {({ hovered }: { hovered?: boolean }) => ( )} @@ -81,6 +97,7 @@ function WindowRowContent({ isAtRisk, percent, tone, + pinnable, pinned, }: { highlight: StyleProp; @@ -90,6 +107,7 @@ function WindowRowContent({ isAtRisk: boolean; percent: number; tone: UsageTone; + pinnable: boolean; pinned: boolean; }) { return ( @@ -110,7 +128,7 @@ function WindowRowContent({ - + {pinnable ? : null} ); diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts index 8f8cca2d3f..3ebee656dd 100644 --- a/packages/app/src/utils/assistant-image-metadata.test.ts +++ b/packages/app/src/utils/assistant-image-metadata.test.ts @@ -21,25 +21,53 @@ describe("assistant image metadata", () => { ).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]); }); - it("reuses cached metadata across canonical and raw source keys", () => { - setAssistantImageMetadata( - { - source: "/tmp/paseo-codex-screenshot.png", - workspaceRoot: "/workspaces/paseo", - serverId: "server-1", - }, - { width: 1200, height: 800 }, - ); + it("keeps local image metadata scoped to its server and workspace", () => { + const image = { + source: "screenshot.png", + workspaceRoot: "/workspace/one", + serverId: "server-1", + }; + setAssistantImageMetadata(image, { width: 1200, height: 800 }); - expect( - getAssistantImageMetadata({ - source: "/tmp/paseo-codex-screenshot.png", - }), - ).toEqual({ + expect(getAssistantImageMetadata({ ...image, workspaceRoot: "/workspace/two" })).toBeNull(); + expect(getAssistantImageMetadata({ ...image, serverId: "server-2" })).toBeNull(); + expect(getAssistantImageMetadata({ source: image.source })).toBeNull(); + expect(getAssistantImageMetadata(image)).toEqual({ width: 1200, height: 800, aspectRatio: 1.5, }); + + setAssistantImageMetadata( + { ...image, workspaceRoot: "/workspace/two" }, + { width: 800, height: 1200 }, + ); + expect(getAssistantImageMetadata(image)?.aspectRatio).toBe(1.5); + expect( + getAssistantImageMetadata({ ...image, workspaceRoot: "/workspace/two" })?.aspectRatio, + ).toBe(2 / 3); + }); + + it("reuses direct image metadata without workspace context", () => { + const source = "https://example.com/shared.png"; + setAssistantImageMetadata( + { source, workspaceRoot: "/workspace/one", serverId: "server-1" }, + { width: 1200, height: 800 }, + ); + expect(getAssistantImageMetadata({ source })).toEqual({ + width: 1200, + height: 800, + aspectRatio: 1.5, + }); + }); + + it("reserves the default image aspect ratio before metadata arrives", () => { + expect( + estimateAssistantMessageHeightFromCache({ + markdown: "![Pending](https://example.com/pending.png)", + contentMaxWidth: 608, + }), + ).toBe(464); }); it("estimates assistant message height from cached image metadata", () => { @@ -51,10 +79,10 @@ describe("assistant image metadata", () => { ); expect( - estimateAssistantMessageHeightFromCache( - "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", - DEFAULT_CONTENT_MAX_WIDTH, - ), + estimateAssistantMessageHeightFromCache({ + markdown: "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBeGreaterThan(220); }); @@ -62,14 +90,14 @@ describe("assistant image metadata", () => { const source = `data:image/png;base64,${"a".repeat(512)}`; setAssistantImageMetadata({ source }, { width: 1200, height: 800 }); - const imageOnlyHeight = estimateAssistantMessageHeightFromCache( - `![Screenshot](${source})`, - DEFAULT_CONTENT_MAX_WIDTH, - ); - const mixedHeight = estimateAssistantMessageHeightFromCache( - `Text\n\n![Screenshot](${source})`, - DEFAULT_CONTENT_MAX_WIDTH, - ); + const imageOnlyHeight = estimateAssistantMessageHeightFromCache({ + markdown: `![Screenshot](${source})`, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }); + const mixedHeight = estimateAssistantMessageHeightFromCache({ + markdown: `Text\n\n![Screenshot](${source})`, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }); expect(imageOnlyHeight).toBeGreaterThan(220); expect(mixedHeight).toBeGreaterThan(imageOnlyHeight ?? 0); diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts index 76b8eafbb1..ac7bd217b2 100644 --- a/packages/app/src/utils/assistant-image-metadata.ts +++ b/packages/app/src/utils/assistant-image-metadata.ts @@ -1,6 +1,11 @@ import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; import { createImageSourceCacheKey } from "@/attachments/utils"; +export interface AssistantImageContext { + serverId?: string; + workspaceRoot?: string; +} + export interface AssistantImageMetadata { width: number; height: number; @@ -13,6 +18,7 @@ const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500; const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500; const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g; +export const ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO = 3 / 2; const ASSISTANT_IMAGE_INSET = 8; const ASSISTANT_IMAGE_MIN_HEIGHT = 160; const ASSISTANT_IMAGE_BLOCK_GAP = 24; @@ -65,15 +71,9 @@ function parseAssistantImageMarkdown(markdown: string): { }; } -function createSourceAliasKey(source: string): string { - return `source:${createImageSourceCacheKey(source)}`; -} - -function createResolutionKey(input: { - source: string; - workspaceRoot?: string; - serverId?: string; -}): string | null { +function getAssistantImageMetadataKey( + input: AssistantImageContext & { source: string }, +): string | null { const resolution = resolveAssistantImageSource({ source: input.source, workspaceRoot: input.workspaceRoot, @@ -84,53 +84,28 @@ function createResolutionKey(input: { if (resolution.kind === "direct") { return `direct:${createImageSourceCacheKey(resolution.uri)}`; } - return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`; + // A file path identifies an image only within its daemon and workspace. + // Callers without that context must use the default aspect ratio. + if (!input.serverId) { + return null; + } + return JSON.stringify(["file", input.serverId, resolution.cwd, resolution.path]); } -function getAssistantImageMetadataKeys(input: { - source: string; - workspaceRoot?: string; - serverId?: string; -}): string[] { - const source = input.source.trim(); - if (!source) { - return []; +export function getAssistantImageMetadata( + input: AssistantImageContext & { source: string }, +): AssistantImageMetadata | null { + const key = getAssistantImageMetadataKey(input); + const metadata = key ? assistantImageMetadataCache.get(key) : undefined; + if (!key || !metadata) { + return null; } - - const keys = [createSourceAliasKey(source)]; - const resolutionKey = createResolutionKey(input); - if (resolutionKey) { - keys.unshift(resolutionKey); - } - return [...new Set(keys)]; -} - -export function getAssistantImageMetadata(input: { - source: string; - workspaceRoot?: string; - serverId?: string; -}): AssistantImageMetadata | null { - for (const key of getAssistantImageMetadataKeys(input)) { - const metadata = assistantImageMetadataCache.get(key); - if (metadata) { - touchCacheEntry( - assistantImageMetadataCache, - key, - metadata, - ASSISTANT_IMAGE_METADATA_CACHE_LIMIT, - ); - return metadata; - } - } - return null; + touchCacheEntry(assistantImageMetadataCache, key, metadata, ASSISTANT_IMAGE_METADATA_CACHE_LIMIT); + return metadata; } export function setAssistantImageMetadata( - input: { - source: string; - workspaceRoot?: string; - serverId?: string; - }, + input: AssistantImageContext & { source: string }, dimensions: { width: number; height: number }, ): AssistantImageMetadata | null { const { width, height } = dimensions; @@ -144,7 +119,8 @@ export function setAssistantImageMetadata( aspectRatio: width / height, }; - for (const key of getAssistantImageMetadataKeys(input)) { + const key = getAssistantImageMetadataKey(input); + if (key) { touchCacheEntry( assistantImageMetadataCache, key, @@ -176,28 +152,31 @@ export function extractAssistantImageSources(markdown: string): string[] { return parsed.sources; } -export function estimateAssistantMessageHeightFromCache( - markdown: string, - contentMaxWidth: number, -): number | null { +export interface AssistantMessageHeightEstimateInput { + markdown: string; + contentMaxWidth: number; + imageContext?: AssistantImageContext; +} + +export function estimateAssistantMessageHeightFromCache({ + markdown, + contentMaxWidth, + imageContext, +}: AssistantMessageHeightEstimateInput): number | null { const parsed = assistantImageParseCache.get(markdown) ?? parseAssistantImageMarkdown(markdown); if (parsed.sources.length === 0) { return null; } - const knownHeights = parsed.sources - .map((source) => getAssistantImageMetadata({ source })) - .filter((metadata): metadata is AssistantImageMetadata => metadata !== null) - .map((metadata) => - Math.max( - ASSISTANT_IMAGE_MIN_HEIGHT, - Math.round((contentMaxWidth - ASSISTANT_IMAGE_INSET) / metadata.aspectRatio), - ), + const imageHeights = parsed.sources.map((source) => { + const aspectRatio = + getAssistantImageMetadata({ source, ...imageContext })?.aspectRatio ?? + ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO; + return Math.max( + ASSISTANT_IMAGE_MIN_HEIGHT, + Math.round((contentMaxWidth - ASSISTANT_IMAGE_INSET) / aspectRatio), ); - - if (knownHeights.length === 0) { - return null; - } + }); const baseHeight = parsed.hasNonImageText ? ASSISTANT_MESSAGE_BASE_HEIGHT @@ -205,8 +184,8 @@ export function estimateAssistantMessageHeightFromCache( const estimatedHeight = baseHeight + - knownHeights.reduce((sum, height) => sum + height, 0) + - ASSISTANT_IMAGE_BLOCK_GAP * knownHeights.length; + imageHeights.reduce((sum, height) => sum + height, 0) + + ASSISTANT_IMAGE_BLOCK_GAP * imageHeights.length; return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight); } 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/assistant-message-height-estimate.test.ts b/packages/app/src/utils/assistant-message-height-estimate.test.ts index 8df70fe588..d4e3a2ff94 100644 --- a/packages/app/src/utils/assistant-message-height-estimate.test.ts +++ b/packages/app/src/utils/assistant-message-height-estimate.test.ts @@ -29,19 +29,27 @@ describe("assistant message height estimate", () => { }); expect( - estimateAssistantMessageHeightFromCache( - "First paragraph\n\nSecond paragraph", - DEFAULT_CONTENT_MAX_WIDTH, - ), + estimateAssistantMessageHeightFromCache({ + markdown: "First paragraph\n\nSecond paragraph", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBe(97); }); it("reads block heights measured at the configured content width", () => { setAssistantMarkdownBlockHeight({ block: "Wide paragraph", width: 1584, height: 20 }); - expect(estimateAssistantMessageHeightFromCache("Wide paragraph", 1600)).toBe(44); expect( - estimateAssistantMessageHeightFromCache("Wide paragraph", DEFAULT_CONTENT_MAX_WIDTH), + estimateAssistantMessageHeightFromCache({ + markdown: "Wide paragraph", + contentMaxWidth: 1600, + }), + ).toBe(44); + expect( + estimateAssistantMessageHeightFromCache({ + markdown: "Wide paragraph", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBeNull(); }); @@ -54,10 +62,10 @@ describe("assistant message height estimate", () => { ); expect( - estimateAssistantMessageHeightFromCache( - "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", - DEFAULT_CONTENT_MAX_WIDTH, - ), + estimateAssistantMessageHeightFromCache({ + markdown: "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBeGreaterThan(220); }); }); diff --git a/packages/app/src/utils/assistant-message-height-estimate.ts b/packages/app/src/utils/assistant-message-height-estimate.ts index 638c961e01..02e153e0bb 100644 --- a/packages/app/src/utils/assistant-message-height-estimate.ts +++ b/packages/app/src/utils/assistant-message-height-estimate.ts @@ -1,4 +1,7 @@ -import { estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache } from "@/utils/assistant-image-metadata"; +import { + type AssistantMessageHeightEstimateInput, + estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache, +} from "@/utils/assistant-image-metadata"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; const ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT = 1000; @@ -106,13 +109,14 @@ function estimateAssistantMarkdownBlockHeightFromCache( ); } -export function estimateAssistantMessageHeightFromCache( - markdown: string, - contentMaxWidth: number, -): number | null { +export function estimateAssistantMessageHeightFromCache({ + markdown, + contentMaxWidth, + imageContext, +}: AssistantMessageHeightEstimateInput): number | null { return ( estimateAssistantMarkdownBlockHeightFromCache(markdown, contentMaxWidth) ?? - estimateAssistantImageMessageHeightFromCache(markdown, contentMaxWidth) + estimateAssistantImageMessageHeightFromCache({ markdown, contentMaxWidth, imageContext }) ); } 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/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts index 5d39d55cfb..de88638b06 100644 --- a/packages/app/src/utils/terminal-keys.test.ts +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -7,6 +7,7 @@ import { mergeTerminalModifiers, normalizeDomTerminalKey, normalizeTerminalTransportKey, + resolveMacTerminalEditingShortcut, resolvePendingModifierDataInput, shouldInterceptDomTerminalKey, } from "./terminal-keys"; @@ -351,3 +352,45 @@ describe("terminal key helpers", () => { }); }); }); + +describe("resolveMacTerminalEditingShortcut", () => { + function shortcut( + key: string, + modifiers: Partial>, + ) { + return resolveMacTerminalEditingShortcut({ + key, + ctrlKey: modifiers.ctrl ?? false, + shiftKey: modifiers.shift ?? false, + altKey: modifiers.alt ?? false, + metaKey: modifiers.meta ?? false, + }); + } + + it("maps cmd+arrow to line start/end", () => { + expect(shortcut("ArrowLeft", { meta: true })).toBe("\x01"); + expect(shortcut("ArrowRight", { meta: true })).toBe("\x05"); + }); + + it("maps option+arrow to word movement", () => { + expect(shortcut("ArrowLeft", { alt: true })).toBe("\x1bb"); + expect(shortcut("ArrowRight", { alt: true })).toBe("\x1bf"); + }); + + it("maps cmd+backspace to kill-line", () => { + expect(shortcut("Backspace", { meta: true })).toBe("\x15"); + }); + + it("leaves other combinations to xterm", () => { + expect(shortcut("ArrowLeft", {})).toBeNull(); + expect(shortcut("ArrowRight", { meta: true, shift: true })).toBeNull(); + expect(shortcut("ArrowRight", { meta: true, ctrl: true })).toBeNull(); + expect(shortcut("ArrowRight", { meta: true, alt: true })).toBeNull(); + expect(shortcut("ArrowLeft", { alt: true, shift: true })).toBeNull(); + expect(shortcut("ArrowUp", { meta: true })).toBeNull(); + expect(shortcut("ArrowDown", { alt: true })).toBeNull(); + // Option+Backspace already works natively in xterm (ESC DEL). + expect(shortcut("Backspace", { alt: true })).toBeNull(); + expect(shortcut("k", { meta: true })).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index a25d4e08a7..09ba53c182 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -135,6 +135,51 @@ export function shouldInterceptDomTerminalKey(args: { return false; } +interface MacTerminalEditingShortcutInput { + key: string; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; +} + +// macOS terminals translate these editing chords instead of forwarding raw modified +// keys: Terminal.app and iTerm2 ("Natural Text Editing") send ^A/^E for cmd+arrows, +// ESC b / ESC f for option+arrows, and ^U for cmd+backspace. xterm ignores meta+arrow +// entirely, and 6.x dropped the 5.x macOS conversion of option+arrow to ESC b/f, so +// without this the shell receives nothing or an `ESC[1;3C`-style sequence it can't +// parse (which zsh renders as a stray "C"/"D" plus a bell). +export function resolveMacTerminalEditingShortcut( + args: MacTerminalEditingShortcutInput, +): string | null { + if (args.ctrlKey || args.shiftKey) { + return null; + } + if (args.metaKey && !args.altKey) { + switch (args.key) { + case "ArrowLeft": + return "\x01"; + case "ArrowRight": + return "\x05"; + case "Backspace": + return "\x15"; + default: + return null; + } + } + if (args.altKey && !args.metaKey) { + switch (args.key) { + case "ArrowLeft": + return "\x1bb"; + case "ArrowRight": + return "\x1bf"; + default: + return null; + } + } + return null; +} + export function mergeTerminalModifiers(args: { pendingModifiers: PendingTerminalModifiers; ctrlKey: boolean; 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/voice/audio-engine.native.ts b/packages/app/src/voice/audio-engine.native.ts deleted file mode 100644 index 465bab3c73..0000000000 --- a/packages/app/src/voice/audio-engine.native.ts +++ /dev/null @@ -1,350 +0,0 @@ -import type { - AudioEngine, - AudioEngineCallbacks, - AudioPlaybackSource, -} from "@/voice/audio-engine-types"; - -interface QueuedAudio { - audio: AudioPlaybackSource; - resolve: (duration: number) => void; - reject: (error: Error) => void; -} - -interface AudioEngineTraceOptions { - traceLabel?: string; -} - -function parsePcmSampleRate(mimeType: string): number | null { - const match = /rate=(\d+)/i.exec(mimeType); - if (!match) { - return null; - } - const rate = Number(match[1]); - return Number.isFinite(rate) && rate > 0 ? rate : null; -} - -function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array { - if (fromRate === toRate) { - return pcm; - } - - const inputSamples = Math.floor(pcm.length / 2); - const outputSamples = Math.floor((inputSamples * toRate) / fromRate); - const out = new Uint8Array(outputSamples * 2); - const ratio = fromRate / toRate; - - const readInt16 = (sampleIndex: number): number => { - const i = sampleIndex * 2; - if (i + 1 >= pcm.length) { - return 0; - } - const lo = pcm[i]; - const hi = pcm[i + 1]; - let value = (hi << 8) | lo; - if (value & 0x8000) { - value = value - 0x10000; - } - return value; - }; - - const writeInt16 = (sampleIndex: number, value: number): void => { - const clamped = Math.max(-32768, Math.min(32767, Math.round(value))); - const i = sampleIndex * 2; - out[i] = clamped & 0xff; - out[i + 1] = (clamped >> 8) & 0xff; - }; - - for (let i = 0; i < outputSamples; i++) { - const srcPos = i * ratio; - const i0 = Math.floor(srcPos); - const frac = srcPos - i0; - const s0 = readInt16(i0); - const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1)); - writeInt16(i, s0 + (s1 - s0) * frac); - } - - return out; -} - -export function createAudioEngine( - callbacks: AudioEngineCallbacks, - _options?: AudioEngineTraceOptions, -): AudioEngine { - const native = require("@getpaseo/expo-two-way-audio"); - - const refs: { - initialized: boolean; - captureActive: boolean; - muted: boolean; - queue: QueuedAudio[]; - processingQueue: boolean; - playbackTimeout: ReturnType | null; - activePlayback: { - resolve: (duration: number) => void; - reject: (error: Error) => void; - settled: boolean; - } | null; - destroyed: boolean; - } = { - initialized: false, - captureActive: false, - muted: false, - queue: [], - processingQueue: false, - playbackTimeout: null, - activePlayback: null, - destroyed: false, - }; - - const microphoneSubscription = native.addExpoTwoWayAudioEventListener( - "onMicrophoneData", - (event: { data: Uint8Array }) => { - if (!refs.captureActive || refs.muted) { - return; - } - const pcm = event.data; - callbacks.onCaptureData(pcm); - }, - ); - const volumeSubscription = native.addExpoTwoWayAudioEventListener( - "onInputVolumeLevelData", - (event: { data: number }) => { - if (!refs.captureActive) { - return; - } - const level = refs.muted ? 0 : event.data; - callbacks.onVolumeLevel(level); - }, - ); - const interruptionSubscription = native.addExpoTwoWayAudioEventListener( - "onAudioInterruption", - (event: { data: string }) => { - if (event.data !== "blocked") { - return; - } - const wasCaptureActive = refs.captureActive; - refs.captureActive = false; - refs.muted = false; - callbacks.onVolumeLevel(0); - if (wasCaptureActive) { - callbacks.onInterruption?.(); - } - }, - ); - - async function ensureInitialized(): Promise { - if (refs.initialized) { - return; - } - const success = await native.initialize(); - if (!success) { - throw new Error("expo-two-way-audio: native initialize() returned false"); - } - refs.initialized = true; - } - - /** - * Release the OS audio session as soon as we are neither capturing nor playing. - * Holding it keeps the user's background music paused — on iOS the non-mixing - * `.playAndRecord` category survives backgrounding and is re-asserted on every - * foreground, so an unreleased session means their music never comes back. - */ - function releaseSessionIfIdle(): void { - if (!refs.initialized || refs.destroyed) { - return; - } - if (refs.captureActive || refs.activePlayback || refs.queue.length > 0) { - return; - } - // The wrapper no-ops on binaries whose native module predates this function. - native.releaseAudioSession(); - } - - async function ensureMicrophonePermission(): Promise { - let permission = await native.getMicrophonePermissionsAsync().catch(() => null); - if (!permission?.granted) { - permission = await native.requestMicrophonePermissionsAsync().catch(() => null); - } - if (!permission?.granted) { - throw new Error( - "Microphone permission is required to capture audio. Please enable microphone access in system settings.", - ); - } - } - - function clearPlaybackTimeout(): void { - if (refs.playbackTimeout) { - clearTimeout(refs.playbackTimeout); - refs.playbackTimeout = null; - } - } - - async function playAudio(audio: AudioPlaybackSource): Promise { - await ensureInitialized(); - - return await new Promise((resolve, reject) => { - refs.activePlayback = { resolve, reject, settled: false }; - - audio - .arrayBuffer() - .then((arrayBuffer) => { - const pcm = new Uint8Array(arrayBuffer); - const inputRate = parsePcmSampleRate(audio.type || "") ?? 24000; - - // Native AudioEngine expects 16kHz PCM16 - const pcm16k = resamplePcm16(pcm, inputRate, 16000); - const durationSec = pcm16k.length / 2 / 16000; - - native.resumePlayback(); - native.playPCMData(pcm16k); - - clearPlaybackTimeout(); - refs.playbackTimeout = setTimeout(() => { - clearPlaybackTimeout(); - const active = refs.activePlayback; - if (!active || active.settled) { - return; - } - active.settled = true; - refs.activePlayback = null; - resolve(durationSec); - }, durationSec * 1000); - return undefined; - }) - .catch((error: unknown) => { - clearPlaybackTimeout(); - const active = refs.activePlayback; - if (active && !active.settled) { - active.settled = true; - refs.activePlayback = null; - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - }); - } - - async function processQueue(): Promise { - if (refs.processingQueue || refs.queue.length === 0) { - return; - } - - refs.processingQueue = true; - while (refs.queue.length > 0) { - const item = refs.queue.shift()!; - try { - const duration = await playAudio(item.audio); - item.resolve(duration); - } catch (error) { - item.reject(error instanceof Error ? error : new Error(String(error))); - } - } - refs.processingQueue = false; - releaseSessionIfIdle(); - } - - return { - async initialize() { - await ensureInitialized(); - }, - - async destroy() { - if (refs.destroyed) { - return; - } - refs.destroyed = true; - this.stop(); - this.clearQueue(); - if (refs.captureActive) { - native.toggleRecording(false); - refs.captureActive = false; - } - clearPlaybackTimeout(); - refs.muted = false; - callbacks.onVolumeLevel(0); - if (refs.initialized) { - native.tearDown(); - refs.initialized = false; - } - microphoneSubscription.remove(); - volumeSubscription.remove(); - interruptionSubscription.remove(); - }, - - async startCapture() { - if (refs.captureActive) { - return; - } - - try { - await ensureMicrophonePermission(); - await ensureInitialized(); - const isRecording = native.toggleRecording(true); - if (!isRecording) { - throw new Error( - "Microphone capture could not start because Android audio focus is unavailable.", - ); - } - refs.captureActive = true; - } catch (error) { - const wrapped = error instanceof Error ? error : new Error(String(error)); - callbacks.onError?.(wrapped); - throw wrapped; - } - }, - - async stopCapture() { - if (refs.captureActive) { - native.toggleRecording(false); - } - refs.captureActive = false; - refs.muted = false; - callbacks.onVolumeLevel(0); - releaseSessionIfIdle(); - }, - - toggleMute() { - refs.muted = !refs.muted; - if (refs.muted) { - callbacks.onVolumeLevel(0); - } - return refs.muted; - }, - - isMuted() { - return refs.muted; - }, - - async play(audio: AudioPlaybackSource) { - return await new Promise((resolve, reject) => { - refs.queue.push({ audio, resolve, reject }); - if (!refs.processingQueue) { - void processQueue(); - } - }); - }, - - stop() { - native.stopPlayback(); - clearPlaybackTimeout(); - const active = refs.activePlayback; - refs.activePlayback = null; - if (active && !active.settled) { - active.settled = true; - active.reject(new Error("Playback stopped")); - } - releaseSessionIfIdle(); - }, - - clearQueue() { - while (refs.queue.length > 0) { - refs.queue.shift()!.reject(new Error("Playback stopped")); - } - refs.processingQueue = false; - releaseSessionIfIdle(); - }, - - isPlaying() { - return refs.activePlayback !== null; - }, - }; -} diff --git a/packages/app/src/voice/voice-runtime.test.ts b/packages/app/src/voice/voice-runtime.test.ts index eb0e8c88f3..189140c7f6 100644 --- a/packages/app/src/voice/voice-runtime.test.ts +++ b/packages/app/src/voice/voice-runtime.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DaemonServerInfo } from "@/stores/session-store"; -import type { AudioEngine } from "@/voice/audio-engine-types"; +import type { AudioEngine } from "@/audio"; import { createVoiceRuntime, type VoiceSessionAdapter } from "@/voice/voice-runtime"; import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config"; @@ -307,8 +307,9 @@ describe("voice runtime", () => { runtime.onServerSpeechStateChanged("server-1", true); - expect(engine.stop).toHaveBeenCalledTimes(1); - expect(engine.clearQueue).toHaveBeenCalledTimes(1); + expect(engine.stop).not.toHaveBeenCalled(); + expect(engine.clearQueue).not.toHaveBeenCalled(); + expect(vi.mocked(engine.play).mock.calls[0][1]?.aborted).toBe(true); resolvePlay(0.1); }); diff --git a/packages/app/src/voice/voice-runtime.ts b/packages/app/src/voice/voice-runtime.ts index a8281acbc3..3b7843e2f7 100644 --- a/packages/app/src/voice/voice-runtime.ts +++ b/packages/app/src/voice/voice-runtime.ts @@ -2,7 +2,7 @@ import { Buffer } from "buffer"; import type { AgentStreamEventPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; import type { DaemonServerInfo } from "@/stores/session-store"; -import type { AudioEngine } from "@/voice/audio-engine-types"; +import type { AudioEngine } from "@/audio"; import { THINKING_TONE_NATIVE_PCM_BASE64, THINKING_TONE_NATIVE_PCM_DURATION_MS, @@ -118,10 +118,8 @@ interface RuntimePlaybackState { } interface CueState { - active: boolean; - token: number; + controller: AbortController | null; timeout: ReturnType | null; - playing: boolean; } const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = { @@ -210,10 +208,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { generation: 0, }; const cue: CueState = { - active: false, - token: 0, + controller: null, timeout: null, - playing: false, }; const cuePcm16 = Uint8Array.from(Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64")); const cueSource = { @@ -442,18 +438,12 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { } function stopCue(): void { - const hadActive = cue.active || cue.timeout !== null || cue.playing; - cue.active = false; - cue.token += 1; + cue.controller?.abort(); + cue.controller = null; if (cue.timeout) { clearTimeout(cue.timeout); cue.timeout = null; } - cue.playing = false; - if (hadActive) { - deps.engine.stop(); - deps.engine.clearQueue(); - } } function resetCaptureTelemetry(): void { @@ -467,29 +457,26 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { stopCue(); return; } - if (cue.active) { + if (cue.controller) { return; } - cue.active = true; - cue.token += 1; - const token = cue.token; + const controller = new AbortController(); + cue.controller = controller; const playNext = () => { - if (!cue.active || cue.token !== token) { + if (controller.signal.aborted) { return; } - cue.playing = true; void deps.engine - .play(cueSource) + .play(cueSource, controller.signal) .catch((error) => { - if (cue.token !== token) { + if (controller.signal.aborted) { return; } console.warn(`[VoiceRuntime#${instanceId}] Cue playback failed:`, error); }) .finally(() => { - cue.playing = false; - if (!cue.active || cue.token !== token) { + if (controller.signal.aborted) { return; } cue.timeout = setTimeout( @@ -920,10 +907,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { if (isSpeaking) { const shouldInterruptPlayback = state.snapshot.phase === "playing" || playback.groups.size > 0; - const hadCue = cue.active || cue.timeout !== null || cue.playing; resetPlaybackState(); stopCue(); - if (shouldInterruptPlayback && !hadCue) { + if (shouldInterruptPlayback) { deps.engine.stop(); deps.engine.clearQueue(); } 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/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index 8f10527d75..ea983b4bd8 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -18,7 +18,7 @@ import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { NewTabSelection } from "@/workspace-tabs/new-tab"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; -import { panelSupportsHost, type PaneHost } from "@/panels/panel-manifest"; +import { panelCanLaunchInPane, panelSupportsHost, type PaneHost } from "@/panels/panel-manifest"; import { getPanelRegistration, type PanelIconProps, @@ -53,8 +53,6 @@ export interface WorkspaceTabLaunchItem { shortcutActionId?: string; disabled: boolean; panelKind: WorkspaceTabTarget["kind"]; - /** The fixed view this item can toggle in a configuration menu, or null for launch-only items. */ - toggleTarget: WorkspaceTabTarget | null; launch: (destination: WorkspaceTabLaunchDestination) => void; } @@ -65,6 +63,8 @@ export interface WorkspaceTabLaunchGroup { accessory?: { id: string; label: string; run: () => void }; } +const EMPTY_PANE_PANEL_KINDS: readonly WorkspaceTabTarget["kind"][] = []; + const NewTabLauncherContext = createContext(null); export function NewTabLauncherProvider({ @@ -97,8 +97,10 @@ export function useWorkspaceTabLaunchCatalog(input: { serverId: string; purpose: WorkspaceTabLaunchPurpose; host: PaneHost; + surface: "menu" | "panel"; + panePanelKinds?: readonly WorkspaceTabTarget["kind"][]; }): readonly WorkspaceTabLaunchGroup[] { - const { serverId, purpose, host } = input; + const { serverId, purpose, host, surface, panePanelKinds = EMPTY_PANE_PANEL_KINDS } = input; const { t } = useTranslation(); const router = useRouter(); const launcher = useContext(NewTabLauncherContext); @@ -118,6 +120,7 @@ export function useWorkspaceTabLaunchCatalog(input: { }, [router, serverId]); return useMemo(() => { + const isExplorerMenu = host === "explorer" && surface === "menu"; const changesPresentation = getLaunchPresentation("changes_tree"); const diffPresentation = getLaunchPresentation("working_diff"); const filesPresentation = getLaunchPresentation("files"); @@ -130,7 +133,7 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-agent", disabled: false, panelKind: "draft", - toggleTarget: null, + hidden: isExplorerMenu, launch: launchSelection(BUILT_IN_SELECTIONS.agent), }, terminal: { @@ -140,7 +143,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-terminal-new", disabled: launcher.terminalDisabled, panelKind: "terminal", - toggleTarget: null, launch: launchSelection(BUILT_IN_SELECTIONS.terminal), }, changes: { @@ -149,7 +151,6 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: changesPresentation.icon, disabled: false, panelKind: "changes_tree", - toggleTarget: BUILT_IN_SELECTIONS.changes.target, hidden: !launcher.showChanges, launch: launchSelection(BUILT_IN_SELECTIONS.changes), }, @@ -160,7 +161,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-changes", disabled: false, panelKind: "working_diff", - toggleTarget: null, hidden: !launcher.showChanges, launch: launchSelection(BUILT_IN_SELECTIONS.diff), }, @@ -171,7 +171,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-files", disabled: false, panelKind: "files", - toggleTarget: BUILT_IN_SELECTIONS.files.target, launch: launchSelection(BUILT_IN_SELECTIONS.files), }, browser: { @@ -181,7 +180,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-browser", disabled: false, panelKind: "browser", - toggleTarget: null, hidden: !launcher.showBrowser, launch: launchSelection(BUILT_IN_SELECTIONS.browser), }, @@ -191,7 +189,6 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: pullRequestPresentation.icon, disabled: false, panelKind: "pull_request", - toggleTarget: null, hidden: !launcher.showPullRequest, launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest), }, @@ -218,7 +215,6 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: resolvePluginIcon(panel.icon), disabled: false, panelKind: "plugin", - toggleTarget: selection.target, launch: launchSelection(selection), }); } @@ -229,7 +225,7 @@ export function useWorkspaceTabLaunchCatalog(input: { if (pluginItems.length > 0) { groups.push({ id: "plugin-panels", label: null, items: pluginItems }); } - if (profiles.length > 0) { + if (!isExplorerMenu && profiles.length > 0) { groups.push({ id: "terminal-profiles", label: t("workspace.tabs.actions.terminalProfilesMenu"), @@ -239,7 +235,6 @@ export function useWorkspaceTabLaunchCatalog(input: { terminalIconKey: getTerminalProfileIcon(profile), disabled: launcher.terminalDisabled, panelKind: "terminal", - toggleTarget: null, launch: launchSelection({ kind: "terminal", profile }), })), accessory: { @@ -249,7 +244,13 @@ export function useWorkspaceTabLaunchCatalog(input: { }, }); } - return groups; + if (surface !== "menu") return groups; + return groups.flatMap((group) => { + const items = group.items.filter((item) => + panelCanLaunchInPane(item.panelKind, panePanelKinds), + ); + return items.length > 0 ? [{ ...group, items }] : []; + }); }, [ config?.terminalProfiles, editTerminalProfiles, @@ -258,6 +259,8 @@ export function useWorkspaceTabLaunchCatalog(input: { plugins, purpose, host, + surface, + panePanelKinds, serverId, t, ]); diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts index a2ede20ddf..864e0ec43f 100644 --- a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts @@ -17,9 +17,9 @@ describe("getBuiltInLaunchOrder", () => { it("leads with companion tools in a supporting pane", () => { expect(getBuiltInLaunchOrder("supporting")).toEqual([ "changes", + "terminal", "diff", "files", - "terminal", "agent", "browser", "pullRequest", diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts index dfed374170..5c2c77cdfe 100644 --- a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts @@ -10,9 +10,9 @@ export const PRIMARY_LAUNCH_ORDER = [ export const SUPPORTING_LAUNCH_ORDER = [ "changes", + "terminal", "diff", "files", - "terminal", "agent", "browser", "pullRequest", diff --git a/packages/app/src/workspace-tabs/layout-mode-publisher.tsx b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx new file mode 100644 index 0000000000..03df9b695a --- /dev/null +++ b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx @@ -0,0 +1,21 @@ +import { useLayoutEffect } from "react"; +import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; +import { useWorkspaceLayoutStore, type WorkspaceLayoutMode } from "@/stores/workspace-layout-store"; + +function resolveWorkspaceLayoutMode(isCompact: boolean): WorkspaceLayoutMode { + if (isCompact) return "compact"; + return supportsDesktopPaneSplits() ? "split-panes" : "single-pane"; +} + +/** + * Publishes the shell's workspace layout to the layout store, which decides where opened targets + * land. Render it before the routes: React runs an earlier sibling's layout effects before a later + * sibling's subtree, so a route sees the mode even from its first layout effect. + */ +export function WorkspaceLayoutModePublisher(): null { + const isCompact = useIsCompactFormFactor(); + useLayoutEffect(() => { + useWorkspaceLayoutStore.getState().setLayoutMode(resolveWorkspaceLayoutMode(isCompact)); + }, [isCompact]); + return null; +} diff --git a/packages/app/src/workspace-tabs/open-beside.test.ts b/packages/app/src/workspace-tabs/open-beside.test.ts new file mode 100644 index 0000000000..9e80745db8 --- /dev/null +++ b/packages/app/src/workspace-tabs/open-beside.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined), + }, +})); + +import { DEFAULT_APP_SETTINGS } from "@/hooks/use-settings"; +import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; +import { + collectAllPanes, + collectAllTabs, + findPaneById, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { + openPreferredWorkspacePreview, + openPreferredWorkspaceTarget, + openWorkspaceTargetBeside, +} from "@/workspace-tabs/open-beside"; + +const WORKSPACE_KEY = "server-1:workspace-1"; + +beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + sheetsByWorkspace: {}, + layoutByWorkspace: {}, + explorerSidebarPaneIdByWorkspace: {}, + sidePaneIdByWorkspace: {}, + }); +}); + +describe("openPreferredWorkspacePreview", () => { + it("opens a compact file preview in the sheet instead of replacing the active file tab", () => { + const store = useWorkspaceLayoutStore.getState(); + const fileTabId = store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/open.ts" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + + const tabId = openPreferredWorkspacePreview({ + workspaceKey: WORKSPACE_KEY, + serverId: "server-1", + workspaceId: "workspace-1", + explorerSidebarPaneId: "explorer", + lastMainPaneId: "main", + target: { kind: "file", path: "src/preview.ts" }, + source: "explorerFiles", + preferences: DEFAULT_APP_SETTINGS.openInSidePane, + }); + + const state = useWorkspaceLayoutStore.getState(); + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.at(-1)?.tab).toMatchObject({ + tabId, + target: { kind: "file", path: "src/preview.ts" }, + }); + const root = state.layoutByWorkspace[WORKSPACE_KEY].root; + expect(findPaneById(root, "main")?.tabIds).toEqual([fileTabId]); + expect(collectAllTabs(root).find((tab) => tab.tabId === fileTabId)?.target).toEqual({ + kind: "file", + path: "src/open.ts", + }); + }); +}); + +describe("previews under a sheet with unsaved work", () => { + const sheetTarget = { kind: "file" as const, path: "src/draft.ts" }; + let sheetTabId: string | null = null; + + afterEach(() => { + if (sheetTabId) { + setPanelInstanceAttributes( + { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId }, + { modified: false }, + ); + } + sheetTabId = null; + }); + + function previewFile(path: string): string | null { + return openPreferredWorkspacePreview({ + workspaceKey: WORKSPACE_KEY, + serverId: "server-1", + workspaceId: "workspace-1", + explorerSidebarPaneId: "explorer", + lastMainPaneId: "main", + target: { kind: "file", path }, + source: "explorerFiles", + preferences: DEFAULT_APP_SETTINGS.openInSidePane, + }); + } + + it("stacks link previews over the sheet instead of dropping its unsaved work", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "terminal", terminalId: "term-1" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + sheetTabId = store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: sheetTarget, + intent: "reveal", + }); + setPanelInstanceAttributes( + { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId ?? "" }, + { modified: true }, + ); + + previewFile("src/first.ts"); + previewFile("src/second.ts"); + + const state = useWorkspaceLayoutStore.getState(); + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.map((sheet) => sheet.tab.target)).toEqual([ + sheetTarget, + { kind: "file", path: "src/first.ts" }, + { kind: "file", path: "src/second.ts" }, + ]); + const root = state.layoutByWorkspace[WORKSPACE_KEY].root; + expect(findPaneById(root, "main")?.tabIds).toHaveLength(1); + }); +}); + +describe("opening beside", () => { + const sidePreference = { ...DEFAULT_APP_SETTINGS.openInSidePane, subagents: true }; + + function openSubagentWithSidePreference(): string | null { + useWorkspaceLayoutStore.getState().openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/open.ts" }, + intent: "reveal", + }); + return openPreferredWorkspaceTarget({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/subagent.ts" }, + source: "subagents", + preferences: sidePreference, + }); + } + + function paneIdHolding(tabId: string | null): string | undefined { + const root = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root; + return collectAllPanes(root).find((pane) => pane.tabIds.includes(tabId ?? ""))?.id; + } + + it("follows the side preference where the workspace renders split panes", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("split-panes"); + + const tabId = openSubagentWithSidePreference(); + + const sidePaneId = useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]; + expect(sidePaneId).toEqual(expect.any(String)); + expect(paneIdHolding(tabId)).toBe(sidePaneId); + }); + + it("keeps a single-pane workspace in one pane despite the side preference", () => { + useWorkspaceLayoutStore.getState().setLayoutMode("single-pane"); + + const preferredTabId = openSubagentWithSidePreference(); + const besideTabId = openWorkspaceTargetBeside({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "file", path: "src/beside.ts" }, + }); + + expect(useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]).toBeFalsy(); + expect(paneIdHolding(preferredTabId)).toBe("main"); + expect(paneIdHolding(besideTabId)).toBe("main"); + }); +}); diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts index b36d227c3b..0c0c9ebdf5 100644 --- a/packages/app/src/workspace-tabs/open-beside.ts +++ b/packages/app/src/workspace-tabs/open-beside.ts @@ -22,13 +22,11 @@ interface OpenWorkspaceTargetInput { } export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetInput { - isCompact: boolean; source: OpenInSidePaneSource; preferences: OpenInSidePanePreferences; } interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput { - isCompact: boolean; location: WorkspaceTargetOpenLocation; } @@ -39,6 +37,11 @@ interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTarge lastMainPaneId: string | null; } +/** A side pane exists only where the workspace renders split panes. */ +function canOpenBeside(): boolean { + return useWorkspaceLayoutStore.getState().layoutMode === "split-panes"; +} + function resolveMainPane(input: { workspaceKey: string; explorerSidebarPaneId: string | null; @@ -86,7 +89,6 @@ export function openPreferredWorkspaceTarget( input: OpenPreferredWorkspaceTargetInput, ): string | null { return openWorkspaceTargetAtLocation({ - isCompact: input.isCompact, workspaceKey: input.workspaceKey, target: input.target, location: input.preferences[input.source] ? "side" : "main", @@ -105,7 +107,7 @@ export function openWorkspaceTargetAtLocation( layout && collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)), ); - const shouldOpenBeside = !input.isCompact && input.location === "side"; + const shouldOpenBeside = canOpenBeside() && input.location === "side"; let placement: WorkspaceTabPlacement | undefined; if (shouldOpenBeside && !targetAlreadyExists) { const paneId = store.ensureSidePane(input.workspaceKey); @@ -135,7 +137,13 @@ export function openPreferredWorkspacePreview( const existing = collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target), ); - if (existing) return openPreferredWorkspaceTarget(input); + // The sheet is already a single replaceable slot, so it needs no preview tab. + if ( + existing || + store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" }) + ) { + return openPreferredWorkspaceTarget(input); + } const mainPane = resolveMainPane({ workspaceKey: input.workspaceKey, @@ -143,7 +151,7 @@ export function openPreferredWorkspacePreview( lastMainPaneId: input.lastMainPaneId, }); const destinationPaneId = - !input.isCompact && input.preferences[input.source] + canOpenBeside() && input.preferences[input.source] ? store.ensureSidePane(input.workspaceKey) : mainPane?.id; if (!destinationPaneId) return null; @@ -173,9 +181,15 @@ export function openPreferredWorkspacePreview( }); } -/** Explicit Open to Side: creates the side pane and places the target there. */ +/** + * Explicit Open to Side: creates the side pane and places the target there. Without split panes + * the target opens like any other. + */ export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): string | null { if (!input.workspaceKey) return null; + if (!canOpenBeside()) { + return openWorkspaceTargetAtLocation({ ...input, location: "main" }); + } const store = useWorkspaceLayoutStore.getState(); const paneId = store.ensureSidePane(input.workspaceKey); return store.openTab({ diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts index b91bf732c9..bb567d4bd0 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.ts @@ -46,7 +46,6 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string | return null; } return openPreferredWorkspaceTarget({ - isCompact: input.isCompact, workspaceKey: input.workspaceKey, target: { kind: "working_diff" }, source: "diffs", @@ -70,7 +69,6 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): return null; } return openWorkspaceTargetAtLocation({ - isCompact: input.isCompact, workspaceKey: input.workspaceKey, target: { kind: "pull_request" }, location: input.destination, diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 784a7569ff..ee27421efd 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -3,11 +3,13 @@ // to keep the module graph importable. const Stub = () => null; const PassThrough = ({ children }: { children?: unknown }) => children; +const sharedValue = (value: unknown) => ({ get: () => value }); export default Stub; export const BottomSheetModal = Stub; export const BottomSheetModalProvider = PassThrough; export const BottomSheetBackdrop = Stub; +export const BottomSheetHandle = Stub; export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub; @@ -16,10 +18,27 @@ export function useBottomSheetModal(): never { throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests"); } +// A sheet host scope only redirects where sheets draw; with no sheet drawn there is nothing to host. +export function useBottomSheet(): never { + throw new Error("useBottomSheet is unavailable in tests"); +} +export const BottomSheetModalInternalProvider = PassThrough; +export const INITIAL_CONTAINER_LAYOUT = { + height: 0, + offset: { top: 0, bottom: 0, left: 0, right: 0 }, +}; +export function useBottomSheetModalInternal() { + return { + hostName: "stub", + containerLayoutState: sharedValue(INITIAL_CONTAINER_LAYOUT), + mountSheet: () => undefined, + unmountSheet: () => undefined, + willUnmountSheet: () => undefined, + }; +} + // The app imports the internal layout values to compute the visible sheet body. Browser unit tests // never render the native sheet, but Vite still validates this module's export surface. -const sharedValue = (value: unknown) => ({ get: () => value }); - export const KEYBOARD_STATUS = { SHOWN: "SHOWN" }; export function useBottomSheetInternal() { return { diff --git a/packages/app/vitest-environment.test.ts b/packages/app/vitest-environment.test.ts new file mode 100644 index 0000000000..95b9b9343b --- /dev/null +++ b/packages/app/vitest-environment.test.ts @@ -0,0 +1,67 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +const appDir = fileURLToPath(new URL(".", import.meta.url)); +const rootDir = path.resolve(appDir, "../.."); +const require = createRequire(import.meta.url); +const vitestEntry = path.join(path.dirname(require.resolve("vitest/package.json")), "vitest.mjs"); +const tsgoEntry = path.join( + path.dirname(require.resolve("@typescript/native-preview/package.json")), + "bin/tsgo.js", +); + +it("the test configuration typechecks with Expo's generated environment declaration", () => { + const fixtureDir = mkdtempSync(path.join(appDir, ".vitest-expo-env-")); + try { + writeFileSync(path.join(fixtureDir, "expo-env.d.ts"), '/// \n'); + writeFileSync( + path.join(fixtureDir, "tsconfig.json"), + JSON.stringify({ + extends: "../tsconfig.json", + files: ["../vitest.config.ts", "expo-env.d.ts"], + include: [], + }), + ); + const result = spawnSync( + process.execPath, + [tsgoEntry, "--noEmit", "--project", path.join(fixtureDir, "tsconfig.json")], + { cwd: appDir, encoding: "utf8", timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(0); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } +}, 40_000); + +it.each([ + { + name: "root", + cwd: rootDir, + args: ["packages/app/src/screens/workspace/use-mounted-tab-set.test.ts"], + }, + { + name: "app", + cwd: appDir, + args: ["--project", "unit", "src/screens/workspace/use-mounted-tab-set.test.ts"], + }, +])( + "$name tests can render React when the shell exports NODE_ENV=production", + ({ cwd, args }) => { + const result = spawnSync(process.execPath, [vitestEntry, "run", ...args], { + cwd, + env: { ...process.env, NODE_ENV: "production" }, + encoding: "utf8", + timeout: 30_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(0); + }, + 40_000, +); diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 521fb6dbe0..e2d67aca70 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -3,6 +3,9 @@ import { playwright } from "@vitest/browser-playwright"; import path from "path"; import fs from "fs"; +// Set this before Vite resolves dependencies and Vitest starts its workers. +Object.assign(process.env, { NODE_ENV: "test" }); + const appNodeModules = path.resolve(__dirname, "node_modules"); const rootNodeModules = path.resolve(__dirname, "../../node_modules"); const resolvePackageEntry = (packageName: string) => { @@ -22,7 +25,12 @@ export default defineConfig({ test: { name: "unit", environment: "node", - include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"], + include: [ + "src/**/*.{test,spec}.{ts,tsx}", + "native-release-version.test.ts", + "app.config.test.ts", + "vitest-environment.test.ts", + ], setupFiles: [path.resolve(__dirname, "vitest.setup.ts")], exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"], }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 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/delete.test.ts b/packages/cli/src/commands/agent/delete.test.ts index 60d5cec50f..b9d00cd5ef 100644 --- a/packages/cli/src/commands/agent/delete.test.ts +++ b/packages/cli/src/commands/agent/delete.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; @@ -7,18 +7,45 @@ import { runDeleteCommand } from "./delete.js"; const agent = { id: "11111111-1111-4111-8111-111111111111", status: "running", - archivedAt: null, + archivedAt: null as string | null, cwd: "/tmp/project", }; +const archivedAgent = { + id: "22222222-2222-4222-8222-222222222222", + status: "closed", + archivedAt: "2026-10-01T00:00:00.000Z", + cwd: "/tmp/project/nested", +}; +const archivedOutsideAgent = { + id: "33333333-3333-4333-8333-333333333333", + status: "closed", + archivedAt: "2026-10-01T00:00:00.000Z", + cwd: "/tmp/elsewhere", +}; +let daemonAgents: (typeof agent)[] = [agent]; +const toEntry = (entry: typeof agent) => ({ agent: entry }); +// The fake daemon pages history two agents at a time. +const historyPage = (cursor: string | undefined) => { + const start = Number(cursor ?? 0); + const end = start + 2; + return { + entries: daemonAgents.slice(start, end).map(toEntry), + pageInfo: { nextCursor: end < daemonAgents.length ? String(end) : null }, + }; +}; const cancelAgent = vi.fn(async () => { throw new Error("active run cancellation was not acknowledged"); }); +const fetchAgentHistory = vi.fn(async (options: { page: { cursor?: string } }) => + historyPage(options.page.cursor), +); const deleteAgent = vi.fn(async () => undefined); const close = vi.fn(async () => undefined); vi.mock("../../utils/client.js", () => ({ connectToDaemon: vi.fn(async () => ({ - fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })), + fetchAgents: vi.fn(async () => historyPage(undefined)), + fetchAgentHistory, fetchAgent: vi.fn(async () => ({ agent })), cancelAgent, deleteAgent, @@ -28,9 +55,16 @@ vi.mock("../../utils/client.js", () => ({ })); describe("runDeleteCommand", () => { + beforeEach(() => { + daemonAgents = [agent]; + deleteAgent.mockClear(); + fetchAgentHistory.mockClear(); + }); + it("force-deletes a running agent when graceful cancellation is refused", async () => { const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never); + expect(fetchAgentHistory).not.toHaveBeenCalled(); expect(cancelAgent).toHaveBeenCalledWith(agent.id); expect(deleteAgent).toHaveBeenCalledWith(agent.id); expect(result.data).toEqual({ @@ -38,4 +72,30 @@ describe("runDeleteCommand", () => { agentIds: [agent.id], }); }); + + it("deletes archived agents on every history page with --all", async () => { + daemonAgents = [agent, archivedAgent, archivedOutsideAgent]; + + const result = await runDeleteCommand(undefined, { daemonTarget, all: true }, {} as never); + + expect(result.data).toEqual({ + deletedCount: 3, + agentIds: [agent.id, archivedAgent.id, archivedOutsideAgent.id], + }); + }); + + it("deletes archived agents inside the directory with --cwd", async () => { + daemonAgents = [agent, archivedAgent, archivedOutsideAgent]; + + const result = await runDeleteCommand( + undefined, + { daemonTarget, cwd: "/tmp/project" }, + {} as never, + ); + + expect(result.data).toEqual({ + deletedCount: 2, + agentIds: [agent.id, archivedAgent.id], + }); + }); }); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index f1499b51f4..5e48751bb8 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { connectToDaemon } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; +import type { DaemonClient, FetchAgentHistoryEntry } from "@getpaseo/client/internal/daemon-client"; export function addDeleteOptions(cmd: Command): Command { return cmd @@ -33,6 +34,20 @@ export interface AgentDeleteOptions extends CommandOptions { export type AgentDeleteResult = SingleResult; +// History includes archived agents and agents whose provider is unavailable. +async function fetchHistoryAgents(client: DaemonClient) { + const agents: FetchAgentHistoryEntry["agent"][] = []; + let cursor: string | undefined; + do { + const payload = await client.fetchAgentHistory({ + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + agents.push(...payload.entries.map((entry) => entry.agent)); + cursor = payload.pageInfo.nextCursor ?? undefined; + } while (cursor); + return agents; +} + export async function runDeleteCommand( id: string | undefined, options: AgentDeleteOptions, @@ -50,19 +65,16 @@ export async function runDeleteCommand( const client = await connectToDaemon({ target: options.daemonTarget }); try { - const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } }); - let agents = fetchPayload.entries.map((entry) => entry.agent); const deletedIds: string[] = []; + let agents: FetchAgentHistoryEntry["agent"][]; - if (options.all) { - agents = agents.filter((a) => !a.archivedAt); - } else if (options.cwd) { - agents = agents.filter((a) => { - if (a.archivedAt) return false; - return isSameOrDescendantPath(options.cwd!, a.cwd); - }); - } else if (id) { - const fetchResult = await client.fetchAgent({ agentId: id }); + if (options.all || options.cwd) { + agents = await fetchHistoryAgents(client); + if (!options.all) { + agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd)); + } + } else { + const fetchResult = await client.fetchAgent({ agentId: id! }); if (!fetchResult) { const error: CommandError = { code: "AGENT_NOT_FOUND", 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 9de3cf6bb6..fd7e845ecd 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -146,7 +146,7 @@ test("managed two-home restart retains its supervisor and never routes ordinary expect(await f.ok(["project", "ls", "--home", a])).toEqual([]); expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1); const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned); - expect(stream.stderr).toContain("Agent not found: missing-agent"); + expect(stream.stderr).toContain("No agent found matching: missing-agent"); const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned); expect(restart.supervisorPid).toBe(launchB.pid); expect(restart.workerPid).not.toBe(beforeB.workerPid); @@ -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"); @@ -429,6 +436,37 @@ test("empty explicit selectors never select the ambient daemon or create local s } }, 60_000); +test.skipIf(process.platform === "win32")( + "restart confirms the replacement worker when a provider probe is slow", + async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const provider = path.join(f.root, "slow-provider"); + await writeFile( + provider, + `#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), 1800);\n`, + { mode: 0o700 }, + ); + const configPath = path.join(home, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + config.agents = { + ...config.agents, + providers: { claude: { command: { mode: "replace", argv: [provider] } } }, + }; + await writeFile(configPath, JSON.stringify(config)); + const launch = await f.ok(["start", "--home", home, "--timeout", "30"]); + const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]); + expect(restarted.supervisorPid).toBe(launch.pid); + expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid); + } finally { + await f.close(); + } + }, + 60_000, +); + test("IPv6 publication supports home-selected query and worker restart", async () => { const f = await fixture(); const home = f.homes[0]!; @@ -472,7 +510,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy const launchA = await f.ok(["start", "--home", a]); await f.ok(["start", "--home", b]); const absent = await f.run(["logs", agentId, "--home", a]); - expect(absent.stderr).toContain("Agent not found"); + expect(absent.stderr).toContain(`No agent found matching: ${agentId}`); follower = spawn( process.execPath, [cli, "logs", agentId, "--follow", "--tail", "0", "--home", b], @@ -491,7 +529,7 @@ test("raw log following subscribes to a stored agent that exists only in B", asy errors += data; }); await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;"); - expect(errors).not.toContain("Agent not found"); + expect(errors).not.toContain("No agent found matching"); } finally { if (follower && follower.exitCode === null) { const exited = new Promise((resolve) => follower!.once("exit", resolve)); diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 1fdbd7137c..ef88e39a23 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -73,7 +73,8 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma try { if (replacement.getLastServerInfoMessage()?.serverId !== serverId) throw new Error("Connected peer identity changed"); - const status = await replacement.getDaemonStatus({ timeout: Math.min(1_000, remaining()) }); + // Status probes every provider, so it can take seconds on a healthy worker. + const status = await replacement.getDaemonStatus({ timeout: remaining() }); if (status.pid !== workerPid) { await checkSupervisor(); return { diff --git a/packages/cli/src/commands/hooks.test.ts b/packages/cli/src/commands/hooks.test.ts index 3f8c31d855..dd99816b06 100644 --- a/packages/cli/src/commands/hooks.test.ts +++ b/packages/cli/src/commands/hooks.test.ts @@ -17,6 +17,15 @@ function inputFrom(value: string) { }; } +function claudeNotification(notificationType: string) { + return JSON.stringify({ + session_id: "session-1", + hook_event_name: "Notification", + message: "Claude is waiting for your input", + notification_type: notificationType, + }); +} + function ttyInput() { return { isTTY: true, @@ -123,7 +132,7 @@ describe("runHooksCommand", () => { const send = await runHook( claudeProvider.id, claudeProvider.events[4].event, - inputFrom('{"reason":"idle_prompt"}'), + inputFrom(claudeNotification("idle_prompt")), ); expectPostedState(send, "needs-input"); @@ -131,11 +140,11 @@ describe("runHooksCommand", () => { it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])( "ignores Claude %s notifications", - async (reason) => { + async (notificationType) => { const send = await runHook( claudeProvider.id, claudeProvider.events[4].event, - inputFrom(JSON.stringify({ reason })), + inputFrom(claudeNotification(notificationType)), ); expect(send.calls).toEqual([]); 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 ae3541eeb6..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, 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/schedule/update.ts b/packages/cli/src/commands/schedule/update.ts index 81f3762962..7d0079e269 100644 --- a/packages/cli/src/commands/schedule/update.ts +++ b/packages/cli/src/commands/schedule/update.ts @@ -23,10 +23,8 @@ export interface ScheduleUpdateOptions extends ScheduleCommandOptions { model?: string; mode?: string; cwd?: string; - maxRuns?: string; - noMaxRuns?: boolean; - expiresIn?: string; - noExpiresIn?: boolean; + maxRuns?: string | false; + expiresIn?: string | false; } export async function runUpdateCommand( @@ -45,10 +43,10 @@ export async function runUpdateCommand( model: options.model, mode: options.mode, cwd: options.cwd, - maxRuns: options.maxRuns, - expiresIn: options.expiresIn, - clearMaxRuns: options.noMaxRuns, - clearExpires: options.noExpiresIn, + maxRuns: options.maxRuns === false ? undefined : options.maxRuns, + expiresIn: options.expiresIn === false ? undefined : options.expiresIn, + clearMaxRuns: options.maxRuns === false, + clearExpires: options.expiresIn === false, }); const { client } = await connectScheduleClient(options.daemonTarget); try { 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/07-agent-stop.test.ts b/packages/cli/tests/07-agent-stop.test.ts index 81b0cba66c..9416884a7b 100644 --- a/packages/cli/tests/07-agent-stop.test.ts +++ b/packages/cli/tests/07-agent-stop.test.ts @@ -19,6 +19,7 @@ import assert from "node:assert"; import { getAvailablePort } from "./helpers/network.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -129,6 +130,28 @@ try { assert(!output.includes("error: option"), "should not have option parsing error"); console.log("✓ -q (quiet) flag is accepted with stop\n"); } + + { + console.log("Test 9: stop reports AGENT_NOT_FOUND for an unknown ID"); + const daemon = await startTestDaemon(); + try { + const result = await runPaseoCli(daemon, [ + "agent", + "stop", + "does-not-exist", + "--host", + `127.0.0.1:${daemon.port}`, + "--json", + ]); + assert.notStrictEqual(result.exitCode, 0, "stop should fail for an unknown ID"); + const { error } = JSON.parse(result.stderr); + assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr); + assert.match(error.details, /paseo ls/); + } finally { + await daemon.stop(); + } + console.log("✓ stop reports AGENT_NOT_FOUND for an unknown ID\n"); + } } finally { // Clean up temp directory await rm(paseoHome, { recursive: true, force: true }); 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/27-agent-delete.test.ts b/packages/cli/tests/27-agent-delete.test.ts index bec523f9ca..349125109b 100644 --- a/packages/cli/tests/27-agent-delete.test.ts +++ b/packages/cli/tests/27-agent-delete.test.ts @@ -13,6 +13,7 @@ import assert from "node:assert"; import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; @@ -112,6 +113,28 @@ try { assert(!output.includes("error: option"), "should not have option parsing error"); console.log("✓ -q (quiet) flag is accepted with delete\n"); } + + { + console.log("Test 9: delete reports AGENT_NOT_FOUND for an unknown ID"); + const daemon = await startTestDaemon(); + try { + const result = await runPaseoCli(daemon, [ + "agent", + "delete", + "does-not-exist", + "--host", + `127.0.0.1:${daemon.port}`, + "--json", + ]); + assert.notStrictEqual(result.exitCode, 0, "delete should fail for an unknown ID"); + const { error } = JSON.parse(result.stderr); + assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr); + assert.match(error.details, /paseo ls/); + } finally { + await daemon.stop(); + } + console.log("✓ delete reports AGENT_NOT_FOUND for an unknown ID\n"); + } } finally { await rm(paseoHome, { recursive: true, force: true }); } diff --git a/packages/cli/tests/31-schedule.test.ts b/packages/cli/tests/31-schedule.test.ts index 64cd6ca64b..8ecd098a07 100644 --- a/packages/cli/tests/31-schedule.test.ts +++ b/packages/cli/tests/31-schedule.test.ts @@ -130,7 +130,102 @@ try { } { - console.log("Test 1d: compatibility agent-target schedules remain deletable"); + console.log("Test 1d: schedule update clears limits with negated flags"); + const created = await ctx.paseo([ + "schedule", + "create", + "Review scheduled work", + "--cron", + "0 0 1 1 *", + "--provider", + "claude", + "--cwd", + ctx.workDir, + "--max-runs", + "3", + "--expires-in", + "1h", + "--json", + ]); + assert.strictEqual(created.exitCode, 0, created.stderr); + const { id } = JSON.parse(created.stdout); + + const inspect = async () => { + const result = await ctx.paseo(["schedule", "inspect", id, "--json"]); + assert.strictEqual(result.exitCode, 0, result.stderr); + return JSON.parse(result.stdout); + }; + const initial = await inspect(); + assert.strictEqual(initial.maxRuns, 3); + assert.strictEqual(typeof initial.expiresAt, "string"); + + const renamed = await ctx.paseo([ + "schedule", + "update", + id, + "--name", + "renamed-review", + "--json", + ]); + assert.strictEqual(renamed.exitCode, 0, renamed.stderr); + const afterRename = await inspect(); + assert.strictEqual(afterRename.name, "renamed-review"); + assert.strictEqual(afterRename.maxRuns, initial.maxRuns); + assert.strictEqual(afterRename.expiresAt, initial.expiresAt); + + const clearedMaxRuns = await ctx.paseo(["schedule", "update", id, "--no-max-runs", "--json"]); + assert.strictEqual(clearedMaxRuns.exitCode, 0, clearedMaxRuns.stderr); + const withoutMaxRuns = await inspect(); + assert.strictEqual(withoutMaxRuns.maxRuns, null); + assert.strictEqual(withoutMaxRuns.expiresAt, initial.expiresAt); + + const clearedExpiration = await ctx.paseo([ + "schedule", + "update", + id, + "--no-expires-in", + "--json", + ]); + assert.strictEqual(clearedExpiration.exitCode, 0, clearedExpiration.stderr); + const withoutExpiration = await inspect(); + assert.strictEqual(withoutExpiration.maxRuns, null); + assert.strictEqual(withoutExpiration.expiresAt, null); + + const updatedLimits = await ctx.paseo([ + "schedule", + "update", + id, + "--max-runs", + "5", + "--expires-in", + "2h", + "--json", + ]); + assert.strictEqual(updatedLimits.exitCode, 0, updatedLimits.stderr); + const withLimits = await inspect(); + assert.strictEqual(withLimits.maxRuns, 5); + assert.strictEqual(typeof withLimits.expiresAt, "string"); + + const clearedBoth = await ctx.paseo([ + "schedule", + "update", + id, + "--no-max-runs", + "--no-expires-in", + "--json", + ]); + assert.strictEqual(clearedBoth.exitCode, 0, clearedBoth.stderr); + const withoutLimits = await inspect(); + assert.strictEqual(withoutLimits.maxRuns, null); + assert.strictEqual(withoutLimits.expiresAt, null); + + const deleted = await ctx.paseo(["schedule", "delete", id, "--json"]); + assert.strictEqual(deleted.exitCode, 0, deleted.stderr); + console.log("schedule update clears limits\n"); + } + + { + console.log("Test 1e: compatibility agent-target schedules remain deletable"); const created = await ctx.paseo( [ "schedule", 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 9870db034d..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(); @@ -7206,11 +7458,15 @@ test("uses modern usage RPC when both capabilities are advertised", async () => const connected = client.connect(); mock.triggerOpen({ features: { usageSources: true, providerUsageList: true } }); await connected; - const result = client.listUsageReports({ - requestId: "modern-usage", - reportIds: ["claude:work"], - forceRefresh: true, - }); + const updates: unknown[] = []; + const result = client.listUsageReports( + { + requestId: "modern-usage", + reportIds: ["claude:work"], + forceRefresh: true, + }, + (report) => updates.push(report), + ); expect(parseSentFrame(mock.sent[0])).toEqual({ type: "usage.list_reports.request", requestId: "modern-usage", @@ -7230,8 +7486,35 @@ test("uses modern usage RPC when both capabilities are advertised", async () => }, ], }; - mock.triggerMessage(wrapSessionMessage({ type: "usage.list_reports.response", payload })); + const report = payload.reports[0]; + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { requestId: "foreign", report }, + }), + ); + expect(updates).toEqual([]); + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { requestId: payload.requestId, report }, + }), + ); + expect(updates).toEqual([report]); + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.response", + payload: { requestId: payload.requestId, error: null }, + }), + ); + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { requestId: payload.requestId, report }, + }), + ); expect(await result).toStrictEqual(payload); + expect(updates).toEqual([report]); }); test("rejects usage requests when the host has neither capability", async () => { @@ -7250,3 +7533,119 @@ test("rejects usage requests when the host has neither capability", async () => await expect(client.listUsageReports()).rejects.toThrow("Update the host to see usage."); expect(mock.sent).toEqual([]); }); + +test("usage request timeout detaches its update listener", async () => { + vi.useFakeTimers(); + try { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: { usageSources: true } }); + await connected; + const updates: unknown[] = []; + const result = client.listUsageReports({ requestId: "timeout-usage" }, (report) => + updates.push(report), + ); + const rejected = expect(result).rejects.toThrow("Timeout waiting for message (60000ms)"); + await vi.advanceTimersByTimeAsync(60_001); + await rejected; + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { + requestId: "timeout-usage", + report: { + id: "source:late", + account: {}, + sourceId: "source", + sourceLabel: "Source", + fetchedAt: "2026-01-01T00:00:00.000Z", + report: { status: "available", windows: [] }, + }, + }, + }), + ); + expect(updates).toEqual([]); + } finally { + 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 ab5427fe31..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, @@ -95,7 +97,7 @@ import type { RefreshProvidersSnapshotResponseMessage, ProviderDiagnosticResponseMessage, ProviderUsageListResponseMessage, - UsageListReportsResponseMessage, + UsageReportEntry, DaemonGetStatusResponse, DaemonGetPairingOfferResponse, DaemonConfigReloadResponse, @@ -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< @@ -562,7 +569,10 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload" type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; -type UsageListReportsPayload = UsageListReportsResponseMessage["payload"]; +interface UsageListReportsPayload { + requestId: string; + reports: UsageReportEntry[]; +} type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type DiagnosticsPayload = DiagnosticsResponse["payload"]; @@ -1124,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, @@ -1192,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), @@ -1740,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)), @@ -2338,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", @@ -2349,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", @@ -2364,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"); @@ -2371,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, @@ -2399,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", @@ -2462,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"); @@ -2471,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, @@ -2849,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), @@ -2882,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 } @@ -3444,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({ @@ -3451,6 +3510,7 @@ export class DaemonClient { requestId, agentId, text, + sourceAgentId: options?.sourceAgentId, ...(messageId ? { messageId } : {}), ...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}), ...(options?.images ? { images: options.images } : {}), @@ -4549,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, @@ -4580,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 } : {}), @@ -4693,6 +4758,7 @@ export class DaemonClient { requestId?: string, acceptBinary = false, maxBytes?: number, + timeout?: number, ): Promise<FileExplorerPayload> { return this.sendCorrelatedSessionRequest({ requestId, @@ -4705,6 +4771,7 @@ export class DaemonClient { ...(maxBytes ? { maxBytes } : {}), }, responseType: "file_explorer_response", + timeout, }); } @@ -4723,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 }); @@ -4739,6 +4812,7 @@ export class DaemonClient { resolvedRequestId, true, maxBytes, + timeout, ); if (payload.error) { throw new Error(payload.error); @@ -5259,17 +5333,25 @@ export class DaemonClient { }); } - async listUsageReports(options?: { - requestId?: string; - forceRefresh?: boolean; - reportIds?: string[]; - }): Promise<UsageListReportsPayload> { + async listUsageReports( + options?: { + agentId?: string; + requestId?: string; + forceRefresh?: boolean; + reportIds?: string[]; + }, + onReport?: (report: UsageReportEntry) => void, + ): Promise<UsageListReportsPayload> { const features = this.getLastServerInfoMessage()?.features; if (!supportsUsageReports(features)) { throw new Error("Update the host to see usage."); } + if (options?.agentId !== undefined && options.reportIds !== undefined) + throw new Error("agentId and reportIds cannot be combined"); // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. if (features?.usageSources !== true) { + if (options?.agentId !== undefined) + return { requestId: this.createRequestId(options.requestId), reports: [] }; // Released hosts serve a five-minute cache and have no forceRefresh option. const payload = await this.listProviderUsage({ requestId: options?.requestId }); return { @@ -5310,14 +5392,46 @@ export class DaemonClient { }), }; } - return this.sendNamespacedCorrelatedSessionRequest({ - requestId: options?.requestId, - message: { - type: "usage.list_reports.request", - forceRefresh: options?.forceRefresh, - reportIds: options?.reportIds, - }, + const requestId = this.createRequestId(options?.requestId); + const reports: UsageReportEntry[] = []; + let active = true; + const unsubscribe = this.subscribeRawMessages((message) => { + if ( + !active || + !("payload" in message) || + !("requestId" in message.payload) || + message.payload.requestId !== requestId + ) + return; + if (message.type === "usage.list_reports.response" || message.type === "rpc_error") { + active = false; + return; + } + if (message.type !== "usage.list_reports.update") return; + reports.push(message.payload.report); + onReport?.(message.payload.report); }); + try { + const response = await this.sendRequest({ + requestId, + message: { + type: "usage.list_reports.request", + requestId, + forceRefresh: options?.forceRefresh, + reportIds: options?.reportIds, + agentId: options?.agentId, + }, + select: (message) => + message.type === "usage.list_reports.response" && message.payload.requestId === requestId + ? message.payload + : null, + }); + if (response.error !== null) throw new Error(response.error); + return { requestId, reports }; + } finally { + active = false; + unsubscribe(); + } } async listCommands(options: ListCommandsOptions): Promise<ListCommandsPayload>; @@ -6435,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, { @@ -6889,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/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 239b3580ee..5bc82d8a58 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -131,6 +131,8 @@ interface PluginClientContextAliases { } export interface PluginClientContext extends PluginCommandCapabilities, PluginClientContextAliases { + /** Play a base64-encoded audio file on this client; resolves when playback ends. */ + playAudio(source: { base64: string; mimeType: string }): Promise<void>; addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; addScreen(contribution: PluginScreenContribution): PluginCleanup; addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index a71f2ca696..05403594cf 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,6 +359,8 @@ class AcpRuntime { private emit: (event: ProviderEvent) => void; private readonly messages = new Map<string, string>(); private readonly toolCalls = new Map<string, AcpToolCallSnapshot>(); + // Running tool rows published by transformers; the turn's terminal closes any left open. + private readonly vendorToolCalls = new Map<string, ToolCallTimelineItem>(); private readonly pendingCompactions = new Set<string>(); private readonly permissions = new Map< string, @@ -400,9 +403,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); @@ -544,8 +547,13 @@ class AcpRuntime { return sessionCapabilities; } - async discover(cwd = process.cwd()): Promise<ProviderCatalog> { - const response = await this.call(this.connection.newSession({ cwd, mcpServers: [] })); + async discover(cwd?: string): Promise<ProviderCatalog> { + if (cwd === undefined && this.options.options.acpOptions?.workspaceOnlyCatalog) { + return { models: [], modes: [] }; + } + const response = await this.call( + this.connection.newSession({ cwd: cwd ?? process.cwd(), mcpServers: [] }), + ); this.nativeSessionId = response.sessionId; this.modes = response.modes; this.configOptions = response.configOptions ?? []; @@ -594,10 +602,13 @@ class AcpRuntime { turnId, state: "started", }); + // The SDK dispatches notifications without awaiting them, so the prompt response can resolve + // while earlier session updates are still queued. Apply them before the turn ends. const settled = this.call( this.connection.prompt({ sessionId: this.nativeSessionId, prompt }), ).then( - (response): void => { + async (response): Promise<void> => { + await this.drainNotifications(); const state = response.stopReason === "cancelled" ? "canceled" : "completed"; this.fallbackChunkIds.clear(); this.terminalizeTransientItems(state); @@ -609,7 +620,8 @@ class AcpRuntime { }); return undefined; }, - (error): void => { + async (error): Promise<void> => { + await this.drainNotifications(); this.fallbackChunkIds.clear(); this.terminalizeTransientItems("failed"); this.emit({ @@ -734,9 +746,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`); } @@ -1026,9 +1038,17 @@ class AcpRuntime { this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, - item: toolTimelineItem(completed), + item: terminalToolItem(toolTimelineItem(snapshot), state), }); } + for (const item of this.vendorToolCalls.values()) { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: terminalToolItem(item, state), + }); + } + this.vendorToolCalls.clear(); for (const id of this.pendingCompactions) { this.emit({ type: "timeline.item", @@ -1075,6 +1095,10 @@ class AcpRuntime { if (this.configTransaction) this.stagedTransformerConfig = update.config; else this.emitConfigSnapshot(update.config); } else if (update.type === "timeline") { + if (update.item.type === "tool_call") { + if (update.item.status === "running") this.vendorToolCalls.set(update.item.id, update.item); + else this.vendorToolCalls.delete(update.item.id); + } this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, @@ -1382,7 +1406,9 @@ function configValues( return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); } -function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { +type ToolCallTimelineItem = Extract<ProviderTimelineItem, { type: "tool_call" }>; + +function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem { const input = jsonRecord(snapshot.input); const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; const detail = isEdit @@ -1410,6 +1436,18 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { return { ...base, status, error: null }; } +// A row the turn left open ends with the turn. The wire contract requires a non-null error on a +// failed tool call, so a canceled turn cancels its rows instead of failing them. +function terminalToolItem( + item: ToolCallTimelineItem, + state: "completed" | "failed" | "canceled", +): ToolCallTimelineItem { + if (state === "failed") { + return { ...item, status: "failed", error: item.error ?? { message: "Turn failed" } }; + } + return { ...item, status: state, error: null }; +} + function jsonValue(value: unknown) { return z.json().parse(value); } diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index a62b2f2c48..40c8019439 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -272,6 +272,39 @@ lines.on("line", (line) => { } }); + it("still reports initialization failures for workspace-only global discovery", async () => { + let unavailable = false; + const { connector } = connectorHarness({ + handleRequest(instance, request) { + if (unavailable && request.method === "initialize") { + instance.reject(request, "Bridge unavailable"); + return true; + } + return false; + }, + }); + const registration = runAcpProvider({ + id: "workspace-acp", + label: "Workspace ACP", + connector, + acpOptions: { workspaceOnlyCatalog: true }, + }); + const connection = await registration.connect({ versions: [1], capabilities: [] }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + try { + unavailable = true; + await connection.send({ type: "catalog", requestId: "global" }); + expect(await waitForEvent(events, (event) => event.type === "request.failed")).toMatchObject({ + requestId: "global", + error: { message: expect.stringContaining("Bridge unavailable") }, + }); + expect(events.some((event) => event.type === "catalog")).toBe(false); + } finally { + await connection.close(); + } + }); + it("rejects connection cleanly when the ACP executable does not exist", async () => { const registration = runAcpProvider({ id: "missing-acp", @@ -619,6 +652,146 @@ lines.on("line", (line) => { await connection.close(); }); + it.each([ + ["end_turn", "completed", "completed"], + ["cancelled", "canceled", "canceled"], + ] as const)( + "closes a transformer's running tool row when the turn ends with %s", + async (stopReason, turnState, rowStatus) => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("vendor/phase", { id: "phase-1", label: "Preparing" }); + instance.respond(message as AcpRequestMessage, { stopReason }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "vendor-row-acp", + label: "Vendor row ACP", + connector: harness.connector, + transformers: [ + { + notification(notification) { + if (notification.method !== "vendor/phase") return null; + return { + type: "timeline", + item: { + type: "tool_call", + id: "phase-1", + callId: "phase-1", + name: "Vendor phase", + detail: { type: "plain_text", label: "Preparing" }, + status: "running", + error: null, + }, + }; + }, + }, + ], + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === turnState, + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "phase-1" ? [event.item] : [], + ); + expect(rows.map((row) => row.type === "tool_call" && row.status)).toEqual([ + "running", + rowStatus, + ]); + const closed = events.findIndex( + (event) => + event.type === "timeline.item" && + event.item.id === "phase-1" && + event.item.type === "tool_call" && + event.item.status === rowStatus, + ); + const terminal = events.findIndex( + (event) => event.type === "session.turn" && event.state === turnState, + ); + expect(closed).toBeLessThan(terminal); + // The wire contract only accepts a failed tool call with a non-null error. + expect(rows.at(-1)).toMatchObject({ error: null }); + await connection.close(); + }, + ); + + it("cancels an in-progress ACP tool call with the canceled turn instead of failing it without an error", async () => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("session/update", { + sessionId: "connector-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Run tests", + kind: "execute", + status: "in_progress", + }, + }); + instance.respond(message as AcpRequestMessage, { stopReason: "cancelled" }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "cancel-tool-acp", + label: "Cancel tool ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === "canceled", + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "tool-1" ? [event.item] : [], + ); + expect(rows.at(-1)).toMatchObject({ type: "tool_call", status: "canceled", error: null }); + await connection.close(); + }); + it("serializes configuration mutations", async () => { let releaseMutation: (() => void) | null = null; const configOptions = [ diff --git a/packages/plugin/src/server/acp.ts b/packages/plugin/src/server/acp.ts index 533453f15e..826bc23235 100644 --- a/packages/plugin/src/server/acp.ts +++ b/packages/plugin/src/server/acp.ts @@ -44,6 +44,8 @@ export type AcpConnector = () => AcpStream | Promise<AcpStream>; export interface AcpOptions { protocol?: "auto" | "v1"; startupTimeoutMs?: number; + /** Global discovery initializes ACP but returns an empty catalog without opening a session. */ + workspaceOnlyCatalog?: boolean; waitForInitialCommands?: boolean; initialCommandsTimeoutMs?: number; } diff --git a/packages/plugin/src/server/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/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index 9115145cde..acebde32f1 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -60,6 +60,7 @@ export interface PluginLifecycleEvents { }; "agent.archived": { agent: PluginHookAgent; archivedAt: string }; "agent.created": { agent: PluginHookAgent }; + "agent.closed": { agent: PluginHookAgent }; "workspace.created": { workspace: PluginHookWorkspace }; "workspace.archived": { workspace: PluginHookWorkspace }; } 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/provider.ts b/packages/plugin/src/server/provider.ts index 33b49ab447..1646e8473b 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -26,6 +26,8 @@ export const PROVIDER_CAPABILITIES = [ export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; export interface ProviderRegistration { + /** Activate only while agents.providers contains this provider ID, independently of enabled. */ + requiresConfiguration?: boolean; /** Equal keys share discovery within this provider. Include effective configuration and execution environment. */ getCatalogCacheKey?(options: ProviderCatalogOptions): Promise<string | undefined>; /** Default executable and arguments; the daemon resolves overrides before status/connect. */ diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 1b78f8fe12..fa23ca9c91 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import type { ZodType } from "zod"; +import { z, type ZodType } from "zod"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; export interface UsageWindow { @@ -58,17 +58,30 @@ 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; } +export const UsageScopeSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("global") }), + z.object({ + kind: z.literal("session"), + provider: z.string(), + model: z.string().optional(), + env: z.record(z.string(), z.string()), + }), +]); +export type UsageScope = z.infer<typeof UsageScopeSchema>; + export interface UsageSourceRegistration { id: string; label: string; icon?: string; input: ZodType; - /** Every account whose login exists on this machine. Empty when none. */ - discover(): Promise<UsageAccount[]>; + /** Accounts for this scope only. The same key in any scope identifies the same account. */ + discover(scope: UsageScope): Promise<UsageAccount[]>; /** Re-reads the login store; never writes it. */ fetch(input: unknown): Promise<UsageReport>; } 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.checkout-pr-schema.test.ts b/packages/protocol/src/messages.checkout-pr-schema.test.ts index cf15e47962..3966d6eb71 100644 --- a/packages/protocol/src/messages.checkout-pr-schema.test.ts +++ b/packages/protocol/src/messages.checkout-pr-schema.test.ts @@ -812,3 +812,14 @@ describe("checkout PR schemas", () => { }); }); }); + +test.each([undefined, false, true])( + "accepts optional Hoplite capability (%s)", + (hopliteProvider) => { + const features = hopliteProvider === undefined ? {} : { hopliteProvider }; + expect( + ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "test", features }) + .features, + ).toEqual(features); + }, +); diff --git a/packages/protocol/src/messages.create-agent-internal.test.ts b/packages/protocol/src/messages.create-agent-internal.test.ts new file mode 100644 index 0000000000..5d4e75649f --- /dev/null +++ b/packages/protocol/src/messages.create-agent-internal.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import { SessionInboundMessageSchema } from "./messages.js"; + +describe("create request internal field", () => { + test("accepts internal on create_agent_request", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "create-agent-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + }); + + expect(parsed).toEqual({ + type: "create_agent_request", + requestId: "create-agent-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + labels: {}, + }); + }); + + test("accepts internal on agent.create.request", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "agent.create.request", + requestId: "agent-create-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + }); + + expect(parsed).toEqual({ + type: "agent.create.request", + requestId: "agent-create-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + labels: {}, + }); + }); +}); diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index 5c62e90669..f266b65988 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => { expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); }, ); + it("preserves manifest metadata in plugin list responses", () => { + const message = { + type: "plugin.list.response", + payload: { + requestId: "metadata", + plugins: [ + { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + path: "/plugins/review", + enabled: false, + status: "disabled", + }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }); + it("parses plugin timeline append messages and advertises the capability", () => { expect( SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 3394c8e15f..ca3ab86135 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + AgentTimelineItemPayloadSchema, FileExplorerRequestSchema, PaseoWorktreeArchiveRequestSchema, parseServerInfoStatusPayload, @@ -520,3 +521,30 @@ describe("viewed timeline subscription messages", () => { }); }); }); + +test("agent messages are an optional semantic payload on existing tool rows", () => { + const legacy = { + type: "tool_call", + callId: "delivery", + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Done" }, + }; + const agentMessage = { + event: "finished", + sender: { id: "server::worker", title: "Reviewer" }, + text: "Done", + }; + expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy); + expect(AgentTimelineItemPayloadSchema.parse({ ...legacy, agentMessage })).toEqual({ + ...legacy, + agentMessage, + }); + expect( + AgentTimelineItemPayloadSchema.safeParse({ + ...legacy, + agentMessage: { ...agentMessage, sender: { id: 42 } }, + }).success, + ).toBe(false); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a4f883604e..c8fe60ff67 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,3 +1,5 @@ +import { AgentMessageSchema } from "./agent-message.js"; +import { PluginRegistryIdentitySchema } from "./plugin-registry.js"; import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js"; export { AgentProfileSchema, @@ -624,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(), @@ -836,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>; @@ -907,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({ @@ -1234,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( @@ -1267,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 @@ -1275,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 @@ -1353,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(), @@ -1451,8 +1466,18 @@ export const PluginSourceInstallRequestSchema = z.object({ export const PluginSourceIdentitySchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("directory"), path: z.string() }), - z.object({ kind: z.literal("git"), remote: z.string(), pluginPath: z.string() }), - z.object({ kind: z.literal("npm"), packageName: z.string(), pluginPath: z.string() }), + z.object({ + kind: z.literal("git"), + remote: z.string(), + pluginPath: z.string(), + registry: PluginRegistryIdentitySchema.optional(), + }), + z.object({ + kind: z.literal("npm"), + packageName: z.string(), + pluginPath: z.string(), + registry: PluginRegistryIdentitySchema.optional(), + }), ]); export const PluginInstallationSchema = z.object({ identity: PluginSourceIdentitySchema, @@ -1699,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(), }); @@ -1777,6 +1809,7 @@ export const ProviderUsageListRequestMessageSchema = z.object({ export const UsageListReportsRequestMessageSchema = z.object({ type: z.literal("usage.list_reports.request"), + agentId: z.string().optional(), requestId: z.string(), reportIds: z.array(z.string()).optional(), forceRefresh: z.boolean().optional(), @@ -2617,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"), @@ -3542,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. @@ -3668,6 +3710,8 @@ export const ServerInfoStatusPayloadSchema = z commitBaseClassification: z.boolean().optional(), // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: z.boolean().optional(), + // COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter. + hopliteProvider: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), // COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02. @@ -3683,6 +3727,7 @@ export const ServerInfoStatusPayloadSchema = z ownedSubscriptions: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), + agentMessageProvenance: z.boolean().optional(), // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. agentTurnIdentity: z.boolean().optional(), // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. @@ -4007,6 +4052,9 @@ export const WorkspaceDescriptorPayloadSchema = z pinnedAt: z.string().nullable().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14. labels: z.array(z.string()).optional(), + // Retained for wire parsing of older daemon descriptors. + internal: z.boolean().optional(), + background: z.boolean().optional(), archivingAt: z.string().nullable().optional().default(null), status: WorkspaceStateBucketSchema, // Best-effort workspace status entry timestamp. Old daemons omit the @@ -6277,10 +6325,25 @@ 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"), + payload: z.object({ requestId: z.string(), report: UsageReportEntrySchema }), }); export const UsageListReportsResponseMessageSchema = z.object({ type: z.literal("usage.list_reports.response"), - payload: z.object({ requestId: z.string(), reports: z.array(UsageReportEntrySchema) }), + payload: z.object({ requestId: z.string(), error: z.string().nullable() }), }); const AgentSlashCommandSchema = z.object({ @@ -6605,6 +6668,9 @@ export const PluginNpmInstallationSchema = z.object({ export const PluginListItemSchema = z.object({ id: PluginIdSchema, + name: z.string().optional(), + icon: z.string().optional(), + media: z.array(z.string()).optional(), description: z.string().optional(), path: z.string(), enabled: z.boolean(), @@ -6968,6 +7034,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ RefreshProvidersSnapshotResponseMessageSchema, ProviderDiagnosticResponseMessageSchema, ProviderUsageListResponseMessageSchema, + UsageListReportsUpdateMessageSchema, UsageListReportsResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, 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 new file mode 100644 index 0000000000..90854962ba --- /dev/null +++ b/packages/protocol/src/plugin-registry.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +export const PluginRegistryIdSchema = z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/); +export const PluginRegistryIdentitySchema = z.object({ + url: z.string().url(), + id: PluginRegistryIdSchema, +}); +export const PluginRegistriesSchema = z.record( + z.string(), + z.object({ authorization: z.string().min(1) }), +); +export const PluginRegistryArtifactSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("npm"), + package: z.string().regex(/^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/), + version: z.string().regex(/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/), + resolved: z.string().url(), + integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]+=*$/), + }), + z.object({ + kind: z.literal("git"), + remote: z.string().min(1), + commit: z.string().regex(/^[0-9a-f]{40,64}$/), + pluginPath: z + .string() + .regex(/^[^\\:]+$/) + .optional(), + }), +]); +export const PublishedPluginSchema = z.object({ + id: PluginRegistryIdSchema, + name: z.string(), + description: z.string(), + license: z.string().optional(), + categories: z.array(z.string()), + author: z.object({ github: z.string(), name: z.string().optional(), npm: z.string().optional() }), + repository: z.object({ url: z.string().url(), commit: z.string().optional() }), + artifact: PluginRegistryArtifactSchema, + icon: z.string().url().optional(), + /** 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), + registry: z.object({ name: z.string(), url: z.string().url() }), + categories: z.array( + 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>; +export type PluginRegistries = z.infer<typeof PluginRegistriesSchema>; + +export function parsePluginRegistryReference( + source: string, + defaultUrl = "https://plugins.paseo.sh", +): PluginRegistryIdentity | null { + const segments = source.split("/"); + if (segments.length === 2 && PluginRegistryIdSchema.safeParse(source).success) + return { url: defaultUrl.replace(/\/+$/, ""), id: source }; + const [host, ...rest] = segments; + if (segments.length !== 3 || !host || !/[.:]/.test(host) || /[@?#\\]/.test(host)) return null; + const id = rest.join("/"); + if (!PluginRegistryIdSchema.safeParse(id).success) return null; + const url = new URL(`https://${host}`); + return { url: url.origin, id }; +} diff --git a/packages/protocol/src/plugin-source-reference.test.ts b/packages/protocol/src/plugin-source-reference.test.ts index 8d12f0b0c5..83f41ce3df 100644 --- a/packages/protocol/src/plugin-source-reference.test.ts +++ b/packages/protocol/src/plugin-source-reference.test.ts @@ -1,7 +1,37 @@ +import { parsePluginRegistryReference } from "./plugin-registry.js"; 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], @@ -13,7 +43,9 @@ describe("plugin source references", () => { undefined, ], ["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", @@ -37,9 +69,23 @@ 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 }); }); }); + +it("addresses default and private registries without treating explicit sources as registry IDs", () => { + expect(parsePluginRegistryReference("omercnet/dracula")).toEqual({ + url: "https://plugins.paseo.sh", + id: "omercnet/dracula", + }); + expect(parsePluginRegistryReference("acme/plugin", "https://internal.example/registry/")).toEqual( + { url: "https://internal.example/registry", id: "acme/plugin" }, + ); + expect(parsePluginRegistryReference("internal.example:8443/acme/plugin")).toEqual({ + url: "https://internal.example:8443", + id: "acme/plugin", + }); + expect(parsePluginRegistryReference("github:acme/plugin")).toBeNull(); + expect(parsePluginRegistryReference("npm:@acme/plugin")).toBeNull(); +}); diff --git a/packages/protocol/src/plugin-source-reference.ts b/packages/protocol/src/plugin-source-reference.ts index d944ae13b7..15171531f0 100644 --- a/packages/protocol/src/plugin-source-reference.ts +++ b/packages/protocol/src/plugin-source-reference.ts @@ -1,5 +1,7 @@ +import { parsePluginRegistryReference } from "./plugin-registry.js"; import type { PluginInstallation } from "./messages.js"; export interface PluginSourceReference { + kind: "directory" | "managed"; source: string; pluginPath: string | undefined; } @@ -8,10 +10,18 @@ export function parsePluginSourceReference(reference: string): PluginSourceRefer 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 }; @@ -56,6 +66,7 @@ function isPortableRelativePluginPath(pluginPath: string): boolean { export function formatPluginIdentity(identity: PluginInstallation["identity"]): string { if (identity.kind === "directory") return identity.path; + if (identity.registry) return `${new URL(identity.registry.url).host}/${identity.registry.id}`; const source = identity.kind === "npm" ? `npm:${identity.packageName}` : `git:${identity.remote}`; return identity.pluginPath === "." ? source : `${source}:${identity.pluginPath}`; } diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index 6265b631c3..4ca8ea44e3 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -31,6 +31,7 @@ export const ACP_PROVIDER_ICON_NAMES = [ "glm-acp-agent", "goose", "grok", + "hoplite", "junie", "kilo", "kimi", diff --git a/packages/protocol/src/terminal-snapshot.test.ts b/packages/protocol/src/terminal-snapshot.test.ts index 0262f0e45f..96d82a9205 100644 --- a/packages/protocol/src/terminal-snapshot.test.ts +++ b/packages/protocol/src/terminal-snapshot.test.ts @@ -47,3 +47,33 @@ describe("renderTerminalSnapshotToAnsi", () => { expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP"); }); }); + +describe("renderTerminalSnapshotToAnsi wide chars", () => { + it("skips wide-char continuation cells so the glyph keeps its two columns", () => { + // The daemon captures "통" as the glyph followed by an empty continuation + // cell. Emitting anything for that cell pushes the rest of the row right. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]], + cursor: { row: 0, col: 5 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!"); + }); + + it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => { + // Old daemons already collapsed the continuation to " ". The serializer + // cannot tell that apart from a real space, so it must not guess. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [cells("통 합 !")], + cursor: { row: 0, col: 6 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !"); + }); +}); diff --git a/packages/protocol/src/terminal-snapshot.ts b/packages/protocol/src/terminal-snapshot.ts index f978cd884b..1860067573 100644 --- a/packages/protocol/src/terminal-snapshot.ts +++ b/packages/protocol/src/terminal-snapshot.ts @@ -87,6 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string { for (let index = 0; index < length; index += 1) { const cell = row[index] ?? { char: " " }; + // An empty char marks a wide-char continuation cell. The glyph in the + // previous cell already advances the cursor two columns, so emitting + // anything here would shift the rest of the row one column right. + if (cell.char === "") { + continue; + } const nextStyle = getTerminalStyle(cell); if (!terminalStylesEqual(previousStyle, nextStyle)) { output.push(styleToAnsi(nextStyle)); diff --git a/packages/protocol/src/tool-call-display.ts b/packages/protocol/src/tool-call-display.ts index 6930fe187a..ab597b5acd 100644 --- a/packages/protocol/src/tool-call-display.ts +++ b/packages/protocol/src/tool-call-display.ts @@ -1,10 +1,11 @@ +import { buildAgentMessageDisplay } from "./agent-message-display.js"; import type { ToolCallTimelineItem } from "./agent-types.js"; import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js"; import { stripCwdPrefix } from "./path-utils.js"; export type ToolCallDisplayInput = Pick< ToolCallTimelineItem, - "name" | "status" | "error" | "metadata" | "detail" + "name" | "status" | "error" | "metadata" | "detail" | "agentMessage" > & { cwd?: string; }; @@ -151,6 +152,9 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay } export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel { + if (input.agentMessage) { + return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName }; + } const canonicalDisplay = buildCanonicalDetailDisplay(input); const unknownDetailOverride = buildUnknownDetailOverride(input); const displayName = diff --git a/packages/protocol/src/websocket-message-limit.ts b/packages/protocol/src/websocket-message-limit.ts new file mode 100644 index 0000000000..9e2bb48e21 --- /dev/null +++ b/packages/protocol/src/websocket-message-limit.ts @@ -0,0 +1,5 @@ +/** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */ +export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024; + +/** Largest encrypted frame the relay accepts. */ +export const MAX_RELAY_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024; diff --git a/packages/protocol/tests/fixtures/plugin-overview.json b/packages/protocol/tests/fixtures/plugin-overview.json new file mode 100644 index 0000000000..bbe8575ac8 --- /dev/null +++ b/packages/protocol/tests/fixtures/plugin-overview.json @@ -0,0 +1,1202 @@ +[ + { + "name": "script", + "markdown": "<script>alert(1)</script>", + "links": [], + "images": [] + }, + { + "name": "image event", + "markdown": "<img src=\"https://example.com/x\" onerror=\"alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "svg event", + "markdown": "<svg onload=\"alert(1)\"></svg>", + "links": [], + "images": [] + }, + { + "name": "iframe", + "markdown": "<iframe src=\"https://example.com\"></iframe>", + "links": [], + "images": [] + }, + { + "name": "HTML javascript link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "HTML block", + "markdown": "<div>\n<img src=x onerror=alert(1)>\n</div>", + "links": [], + "images": [] + }, + { + "name": "comment", + "markdown": "<!-- <img src=x onerror=alert(1)> -->", + "links": [], + "images": [] + }, + { + "name": "meta refresh", + "markdown": "<meta http-equiv=\"refresh\" content=\"0;url=https://example.com\">", + "links": [], + "images": [] + }, + { + "name": "base", + "markdown": "<base href=\"https://example.com\">", + "links": [], + "images": [] + }, + { + "name": "form", + "markdown": "<form action=\"https://example.com\"><input name=\"password\"></form>", + "links": [], + "images": [] + }, + { + "name": "style expression", + "markdown": "<p style=\"width:expression(alert(1));position:fixed\">click</p>", + "links": [], + "images": [] + }, + { + "name": "style block", + "markdown": "<style>body{display:none}</style>", + "links": [], + "images": [] + }, + { + "name": "closing script", + "markdown": "</script><script>alert(1)</script>", + "links": [], + "images": [] + }, + { + "name": "HTML entities", + "markdown": "<img src=x onerror=alert(1)>", + "links": [], + "images": [] + }, + { + "name": "nested HTML entities", + "markdown": "&lt;script&gt;alert(1)&lt;/script&gt;", + "links": [], + "images": [] + }, + { + "name": "javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript HTML link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "javascript HTML image", + "markdown": "<img src=\"javascript:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript link", + "markdown": "[click](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript image", + "markdown": "![picture](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference link", + "markdown": "[click][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML link", + "markdown": "<a href=\"JaVaScRiPt:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML image", + "markdown": "<img src=\"JaVaScRiPt:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "data HTML link", + "markdown": "[click](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML image", + "markdown": "![picture](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML reference link", + "markdown": "[click][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML reference image", + "markdown": "![picture][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML link", + "markdown": "<a href=\"data:text/html;base64,PHNjcmlwdD4=\">click</a>", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML image", + "markdown": "<img src=\"data:text/html;base64,PHNjcmlwdD4=\">", + "links": [], + "images": [] + }, + { + "name": "data image link", + "markdown": "[click](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image image", + "markdown": "![picture](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image reference link", + "markdown": "[click][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image reference image", + "markdown": "![picture][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image HTML link", + "markdown": "<a href=\"data:image/png;base64,AAAA\">click</a>", + "links": [], + "images": [] + }, + { + "name": "data image HTML image", + "markdown": "<img src=\"data:image/png;base64,AAAA\">", + "links": [], + "images": [] + }, + { + "name": "vbscript link", + "markdown": "[click](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript image", + "markdown": "![picture](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript reference link", + "markdown": "[click][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript reference image", + "markdown": "![picture][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML link", + "markdown": "<a href=\"vbscript:msgbox(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML image", + "markdown": "<img src=\"vbscript:msgbox(1)\">", + "links": [], + "images": [] + }, + { + "name": "http link", + "markdown": "[click](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http image", + "markdown": "![picture](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http reference link", + "markdown": "[click][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http reference image", + "markdown": "![picture][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http HTML link", + "markdown": "<a href=\"http://example.com/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "http HTML image", + "markdown": "<img src=\"http://example.com/path\">", + "links": [], + "images": [] + }, + { + "name": "ftp link", + "markdown": "[click](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp image", + "markdown": "![picture](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp reference link", + "markdown": "[click][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp reference image", + "markdown": "![picture][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp HTML link", + "markdown": "<a href=\"ftp://example.com/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "ftp HTML image", + "markdown": "<img src=\"ftp://example.com/path\">", + "links": [], + "images": [] + }, + { + "name": "mailto link", + "markdown": "[click](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto image", + "markdown": "![picture](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto reference link", + "markdown": "[click][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto reference image", + "markdown": "![picture][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto HTML link", + "markdown": "<a href=\"mailto:attacker@example.com\">click</a>", + "links": [], + "images": [] + }, + { + "name": "mailto HTML image", + "markdown": "<img src=\"mailto:attacker@example.com\">", + "links": [], + "images": [] + }, + { + "name": "protocol relative link", + "markdown": "[click](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative image", + "markdown": "![picture](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference link", + "markdown": "[click][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference image", + "markdown": "![picture][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML link", + "markdown": "<a href=\"//example.com/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML image", + "markdown": "<img src=\"//example.com/path\">", + "links": [], + "images": [] + }, + { + "name": "relative link", + "markdown": "[click](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative image", + "markdown": "![picture](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative reference link", + "markdown": "[click][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative HTML link", + "markdown": "<a href=\"./image.png\">click</a>", + "links": [], + "images": [] + }, + { + "name": "relative HTML image", + "markdown": "<img src=\"./image.png\">", + "links": [], + "images": [] + }, + { + "name": "parent relative link", + "markdown": "[click](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative image", + "markdown": "![picture](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative reference link", + "markdown": "[click][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML link", + "markdown": "<a href=\"../login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML image", + "markdown": "<img src=\"../login\">", + "links": [], + "images": [] + }, + { + "name": "root relative link", + "markdown": "[click](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative image", + "markdown": "![picture](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative reference link", + "markdown": "[click][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative reference image", + "markdown": "![picture][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative HTML link", + "markdown": "<a href=\"/login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "root relative HTML image", + "markdown": "<img src=\"/login\">", + "links": [], + "images": [] + }, + { + "name": "fragment link", + "markdown": "[click](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment image", + "markdown": "![picture](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment reference link", + "markdown": "[click][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment reference image", + "markdown": "![picture][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment HTML link", + "markdown": "<a href=\"#login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "fragment HTML image", + "markdown": "<img src=\"#login\">", + "links": [], + "images": [] + }, + { + "name": "query link", + "markdown": "[click](?login)", + "links": [], + "images": [] + }, + { + "name": "query image", + "markdown": "![picture](?login)", + "links": [], + "images": [] + }, + { + "name": "query reference link", + "markdown": "[click][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query reference image", + "markdown": "![picture][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query HTML link", + "markdown": "<a href=\"?login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "query HTML image", + "markdown": "<img src=\"?login\">", + "links": [], + "images": [] + }, + { + "name": "entity javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML image", + "markdown": "<img src=\"javascript:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "entity letter link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML link", + "markdown": "<a href=\"javascript:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML image", + "markdown": "<img src=\"javascript:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "nested entity link", + "markdown": "[click](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity image", + "markdown": "![picture](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity reference link", + "markdown": "[click][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML link", + "markdown": "<a href=\"javascript&#58;alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML image", + "markdown": "<img src=\"javascript&#58;alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "percent encoded link", + "markdown": "[click](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded image", + "markdown": "![picture](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML link", + "markdown": "<a href=\"javascript%3Aalert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML image", + "markdown": "<img src=\"javascript%3Aalert(1)\">", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded link", + "markdown": "[click](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded image", + "markdown": "![picture](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML link", + "markdown": "<a href=\"javascript%253Aalert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML image", + "markdown": "<img src=\"javascript%253Aalert(1)\">", + "links": [], + "images": [] + }, + { + "name": "tab scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML link", + "markdown": "<a href=\"java script:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML image", + "markdown": "<img src=\"java script:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "newline scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML link", + "markdown": "<a href=\"java script:alert(1)\">click</a>", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML image", + "markdown": "<img src=\"java script:alert(1)\">", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials link", + "markdown": "[click](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials image", + "markdown": "![picture](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference link", + "markdown": "[click][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference image", + "markdown": "![picture][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML link", + "markdown": "<a href=\"https://paseo.sh@evil.example/login\">click</a>", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML image", + "markdown": "<img src=\"https://paseo.sh@evil.example/login\">", + "links": [], + "images": [] + }, + { + "name": "backslash link", + "markdown": "[click](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash image", + "markdown": "![picture](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash reference link", + "markdown": "[click][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash reference image", + "markdown": "![picture][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash HTML link", + "markdown": "<a href=\"https:\\evil.example/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "backslash HTML image", + "markdown": "<img src=\"https:\\evil.example/path\">", + "links": [], + "images": [] + }, + { + "name": "missing authority link", + "markdown": "[click](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority image", + "markdown": "![picture](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority reference link", + "markdown": "[click][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority reference image", + "markdown": "![picture][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML link", + "markdown": "<a href=\"https:evil.example/path\">click</a>", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML image", + "markdown": "<img src=\"https:evil.example/path\">", + "links": [], + "images": [] + }, + { + "name": "javascript autolink", + "markdown": "<javascript:alert(1)>", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript autolink", + "markdown": "<JaVaScRiPt:alert(1)>", + "links": [], + "images": [] + }, + { + "name": "data HTML autolink", + "markdown": "<data:text/html;base64,PHNjcmlwdD4=>", + "links": [], + "images": [] + }, + { + "name": "data image autolink", + "markdown": "<data:image/png;base64,AAAA>", + "links": [], + "images": [] + }, + { + "name": "vbscript autolink", + "markdown": "<vbscript:msgbox(1)>", + "links": [], + "images": [] + }, + { + "name": "http autolink", + "markdown": "<http://example.com/path>", + "links": [], + "images": [] + }, + { + "name": "ftp autolink", + "markdown": "<ftp://example.com/path>", + "links": [], + "images": [] + }, + { + "name": "mailto autolink", + "markdown": "<mailto:attacker@example.com>", + "links": [], + "images": [] + }, + { + "name": "HTTPS link", + "markdown": "[click](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "HTTPS image", + "markdown": "![picture](https://example.com/image.png)", + "links": [], + "images": ["https://example.com/image.png"] + }, + { + "name": "HTTPS autolink", + "markdown": "<https://example.com/path>", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTPS", + "markdown": "https://example.com/path", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTP", + "markdown": "http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "deceptive RTL text", + "markdown": "[paseo.sh‮evil](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "title injection", + "markdown": "[click](https://example.com/path \"\\\" onmouseover=alert(1)\")", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "table alignment", + "markdown": "| A |\n| :-: |\n| safe |", + "links": [], + "images": [] + }, + { + "name": "heading", + "markdown": "## Heading", + "links": [], + "images": [] + }, + { + "name": "IRC link", + "markdown": "[click](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC image", + "markdown": "![picture](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC autolink", + "markdown": "<irc://example.com/channel>", + "links": [], + "images": [] + }, + { + "name": "IRCS link", + "markdown": "[click](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS image", + "markdown": "![picture](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS autolink", + "markdown": "<ircs://example.com/channel>", + "links": [], + "images": [] + }, + { + "name": "XMPP link", + "markdown": "[click](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP image", + "markdown": "![picture](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP autolink", + "markdown": "<xmpp:attacker@example.com>", + "links": [], + "images": [] + }, + { + "name": "tel link", + "markdown": "[click](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel image", + "markdown": "![picture](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel autolink", + "markdown": "<tel:12345>", + "links": [], + "images": [] + }, + { + "name": "file link", + "markdown": "[click](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file image", + "markdown": "![picture](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file autolink", + "markdown": "<file:///etc/passwd>", + "links": [], + "images": [] + }, + { + "name": "blob link", + "markdown": "[click](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob image", + "markdown": "![picture](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob autolink", + "markdown": "<blob:https://example.com/id>", + "links": [], + "images": [] + }, + { + "name": "named entity link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity autolink", + "markdown": "<javascript:alert(1)>", + "links": [], + "images": [] + }, + { + "name": "double encoded entity link", + "markdown": "[click](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity image", + "markdown": "![picture](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity autolink", + "markdown": "<javascript&colon;alert(1)>", + "links": [], + "images": [] + } +] diff --git a/packages/relay/package.json b/packages/relay/package.json index 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..a69a2c3c2a 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/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/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-entrypoint.env.test.ts b/packages/server/scripts/supervisor-entrypoint.env.test.ts new file mode 100644 index 0000000000..d87bc678e8 --- /dev/null +++ b/packages/server/scripts/supervisor-entrypoint.env.test.ts @@ -0,0 +1,85 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, test } from "vitest"; +import { + resolveSherpaLoaderEnv, + sherpaLoaderEnvKey, +} from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; + +const loaderKey = sherpaLoaderEnvKey(); +const loaderEnv = resolveSherpaLoaderEnv(); + +// Agents and terminals inherit the daemon worker's environment, so the local speech +// runtime's library directory must stay scoped to the speech worker. +test.runIf(loaderKey && loaderEnv)( + "daemon worker environment does not carry the local speech library directory", + async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-supervisor-env-")); + const reportPath = path.join(root, "worker-env.json"); + const preloadPath = path.join(root, "report-worker-env.mjs"); + await writeFile( + preloadPath, + ` + import { writeFileSync } from "node:fs"; + if (/daemon-worker\\.(ts|js)$/.test(process.argv[1] ?? "")) { + writeFileSync(${JSON.stringify(reportPath)}, JSON.stringify({ value: process.env[${JSON.stringify(loaderKey)}] ?? "" })); + process.exit(0); + } + `, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ); + // Start from a parent environment that does not already list the speech library directory. + const envLoaderKey = + Object.keys(env).find((key) => key.toLowerCase() === loaderKey!.toLowerCase()) ?? loaderKey!; + const parentValue = (env[envLoaderKey] ?? "") + .split(path.delimiter) + .filter((entry) => entry && entry !== loaderEnv!.libDir) + .join(path.delimiter); + env[envLoaderKey] = parentValue; + const child = spawn( + process.execPath, + ["--import", "tsx", fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url))], + { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + env: { + ...env, + HOME: root, + USERPROFILE: root, + PASEO_HOME: path.join(root, "home"), + NODE_OPTIONS: `--import=${pathToFileURL(preloadPath).href}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + try { + await expect + .poll(async () => readFile(reportPath, "utf8").catch(() => null), { + timeout: 20_000, + message: output, + }) + .not.toBeNull(); + const report = JSON.parse(await readFile(reportPath, "utf8")) as { value: string }; + expect(report.value).toBe(parentValue); + } finally { + if (child.exitCode === null) { + const closed = new Promise((resolve) => child.once("close", resolve)); + child.kill("SIGKILL"); + await closed; + } + await rm(root, { recursive: true, force: true }); + } + }, + 30_000, +); diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index 6a27b5d2f6..ee3746accf 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -14,7 +14,6 @@ import { PRIVATE_FILE_MODE } from "../src/server/private-files.js"; import { loadPersistedConfig } from "../src/server/persisted-config.js"; import { runSupervisor } from "./supervisor.js"; import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; -import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; process.title = "Paseo Supervisor"; @@ -108,8 +107,6 @@ async function main(): Promise<void> { ? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url)) : null; - applySherpaLoaderEnv(workerEnv); - const paseoHome = resolvePaseoHome(workerEnv); const persistedConfig = loadPersistedConfig(paseoHome); const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv); 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 2f9cd425ae..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); @@ -5034,6 +5050,7 @@ test("setAgentThinkingOption surfaces a failed state read and keeps the previous test("session config drift events update state through the stream channel", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); let capturedSession: TestAgentSession | null = null; class ConfigEventClient extends TestAgentClient { override async createSession(config: AgentSessionConfig): Promise<AgentSession> { @@ -5046,6 +5063,7 @@ test("session config drift events update state through the stream channel", asyn clients: { codex: new ConfigEventClient(), }, + registry: storage, logger, idFactory: () => "00000000-0000-4000-8000-000000000133", }); @@ -5100,6 +5118,7 @@ test("session config drift events update state through the stream channel", asyn const agent = manager.getAgent(snapshot.id); expect(agent?.currentModeId).toBe("build"); + expect(agent?.config.modeId).toBe("build"); expect(agent?.config.thinkingOptionId).toBe("high"); expect(agent?.availableModes).toEqual([ { id: "plan", label: "Plan" }, @@ -5111,6 +5130,10 @@ test("session config drift events update state through the stream channel", asyn thinkingOptionId: "high", }); expect(streams.map((event) => event.type)).toEqual([]); + + const persisted = await storage.get(snapshot.id); + expect(persisted?.lastModeId).toBe("build"); + expect(persisted?.config?.modeId).toBe("build"); }); test("setLabels merges and persists labels", async () => { @@ -7687,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 () => { @@ -7719,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"); @@ -8129,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({ @@ -9287,6 +9514,7 @@ test("respondToPermission updates currentModeId after plan approval", async () = class PlanModeTestClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; + resumedModeId: string | null | undefined; async isAvailable(): Promise<boolean> { return true; @@ -9296,14 +9524,19 @@ test("respondToPermission updates currentModeId after plan approval", async () = return new PlanModeTestSession(); } - async resumeSession(): Promise<AgentSession> { + async resumeSession( + _handle: AgentPersistenceHandle, + overrides?: Partial<AgentSessionConfig>, + ): Promise<AgentSession> { + this.resumedModeId = overrides?.modeId; return new PlanModeTestSession(); } } + const client = new PlanModeTestClient(); const manager = new AgentManager({ clients: { - codex: new PlanModeTestClient(), + codex: client, }, registry: storage, logger, @@ -9347,6 +9580,13 @@ test("respondToPermission updates currentModeId after plan approval", async () = await manager.flush(); const persisted = await storage.get(snapshot.id); expect(persisted?.lastModeId).toBe("acceptEdits"); + + // The provider-managed switch must also reach config.modeId — otherwise a + // reload resumes the session with the stale creation-time mode ("plan"). + expect(persisted?.config?.modeId).toBe("acceptEdits"); + const reloaded = await manager.reloadAgentSession(snapshot.id); + expect(reloaded.config.modeId).toBe("acceptEdits"); + expect(client.resumedModeId).toBe("acceptEdits"); }); test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => { @@ -11417,3 +11657,203 @@ test("failed startup history closes the session without registering an agent", a for (const agent of manager.listAgents()) await manager.closeAgent(agent.id); } }); + +test("usage session is a pure read of the live adapter and disappears on close", async () => { + const descriptor = { + provider: "claude", + model: "opus", + env: { HOME: "/fixture", CLAUDE_CONFIG_DIR: "/fixture/work" }, + sessionKey: "launch-1", + }; + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise<AgentSession> { + return new (class extends TestAgentSession { + usageSession() { + return descriptor; + } + })(config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + expect(manager.usageSession("missing")).toBeNull(); + const agent = await manager.createAgent({ provider: "codex", cwd: tmpdir() }, undefined, { + workspaceId: undefined, + }); + try { + expect(manager.usageSession(agent.id)).toBe(descriptor); + expect(manager.usageSession(agent.id)).toBe(descriptor); + } finally { + await manager.closeAgent(agent.id); + } + 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 aebede5fa1..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"; @@ -190,10 +191,13 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig { provider: record.provider, cwd: record.cwd, }; + // lastModeId is the last live mode — it also covers provider-side switches + // that never reach record.config.modeId. + const modeId = record.lastModeId ?? record.config?.modeId; + if (modeId != null) config.modeId = modeId; if (!record.config) { return config; } - if (record.config.modeId != null) config.modeId = record.config.modeId; if (record.config.model != null) config.model = record.config.model; if (record.config.thinkingOptionId != null) { config.thinkingOptionId = record.config.thinkingOptionId; @@ -240,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 { @@ -313,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; @@ -565,6 +572,7 @@ function attachPersistenceCwd( interface SubscriptionRecord { callback: AgentSubscriber; agentId: string | null; + includeInternal: boolean; } interface SteerEventBarrier { @@ -649,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; @@ -691,7 +698,7 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str } function shouldDetachFromArchivedParent( - parent: StoredAgentRecord, + parent: Pick<StoredAgentRecord, "workspaceId">, child: StoredAgentRecord, ): boolean { const isCrossWorkspace = @@ -711,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>(); @@ -946,6 +970,7 @@ export class AgentManager { const record: SubscriptionRecord = { callback, agentId: targetAgentId, + includeInternal: options?.includeInternal === true, }; this.subscribers.add(record); @@ -959,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({ @@ -981,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> { @@ -1157,6 +1196,11 @@ export class AgentManager { } } + usageSession(id: string) { + const agent = this.agents.get(id); + return agent?.session?.usageSession?.() ?? null; + } + getAgent(id: string): ManagedAgent | null { const agent = this.agents.get(id); return agent ? { ...agent } : null; @@ -1665,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) { @@ -1750,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"); } @@ -1768,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 }; } @@ -1777,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; @@ -1816,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( @@ -2191,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; } @@ -2353,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 => { @@ -2449,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, @@ -2538,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: @@ -2844,7 +2961,7 @@ export class AgentManager { if (!clientMessageId) { return; } - this.recordSubmittedPrompt(agent, prompt, clientMessageId, { + this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", { messageId: clientMessageId, turnId: expectedTurnId, }); @@ -3503,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 }); } } @@ -3730,6 +3847,11 @@ export class AgentManager { private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { this.emitState(agent, options); + if (!agent.internal) { + this.pluginLifecycle?.emit("agent.closed", { + agent: describeHookAgent({ ...agent, title: agent.config.title }), + }); + } } private subscribeToSession(agent: ActiveManagedAgent): void { if (agent.unsubscribeSession) { @@ -3904,6 +4026,19 @@ export class AgentManager { return this.registry; } + /** + * Provider-side mode switches (ACP current_mode_update, in-session + * commands, permission-driven transitions) must land in config.modeId too — + * reloadAgentSession and the persisted record derive the resumed session's + * mode from it, so leaving it stale silently downgrades the mode on resume. + */ + private applyObservedMode(agent: ActiveManagedAgent, modeId: string | null): void { + agent.currentModeId = modeId; + if (modeId != null) { + agent.config.modeId = modeId; + } + } + private async refreshSessionState( agent: ActiveManagedAgent, options?: { emit?: boolean }, @@ -3916,7 +4051,7 @@ export class AgentManager { } try { - agent.currentModeId = await agent.session.getCurrentMode(); + this.applyObservedMode(agent, await agent.session.getCurrentMode()); } catch { agent.currentModeId = null; } @@ -3992,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); } @@ -4060,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"); @@ -4296,7 +4429,7 @@ export class AgentManager { this.emitState(agent); return undefined; case "mode_changed": - agent.currentModeId = event.currentModeId; + this.applyObservedMode(agent, event.currentModeId); agent.availableModes = event.availableModes; if (agent.runtimeInfo) { agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId }; @@ -4312,7 +4445,7 @@ export class AgentManager { agent.cwd, ); } - agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId; + this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId); flags.shouldDispatchEvent = false; this.emitState(agent); return undefined; @@ -4395,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 && @@ -4671,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); } @@ -4696,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; @@ -5035,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 a0ed0eedc3..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[]; } /** @@ -647,7 +654,15 @@ export interface AgentPermissionResult { followUpPrompt?: AgentPromptInput; } +export interface AgentUsageSession { + provider: string; + model?: string; + env: Record<string, string>; + sessionKey: string; +} + export interface AgentSession { + usageSession?(): AgentUsageSession | null; readonly provider: AgentProvider; readonly id: string | null; readonly capabilities: AgentCapabilityFlags; 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 dbe0885d5c..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"; @@ -806,7 +807,6 @@ function createPaseoWorktreeForMcpTest(options: { emit: () => {}, sessionLogger: createTestLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -833,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(); @@ -1327,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, @@ -1338,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({ @@ -1395,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", @@ -1445,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"); @@ -1482,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 } }, }; @@ -1535,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( @@ -1861,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); @@ -1949,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); @@ -2030,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"); @@ -2132,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({ @@ -2222,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"); @@ -2329,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, @@ -2436,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); @@ -2457,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(); @@ -2543,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( @@ -3055,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({ @@ -3083,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", @@ -3137,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", @@ -3183,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", @@ -3224,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", @@ -3280,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", @@ -3594,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", @@ -3646,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", @@ -3694,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. @@ -3705,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. */ @@ -3724,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(); @@ -3738,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 () => { @@ -3810,6 +3983,7 @@ class HeldTurnAgentClient implements AgentClient { constructor( readonly provider: AgentProvider, private readonly holdTurns: boolean, + private readonly options: HeldTurnOptions = {}, ) {} async isAvailable(): Promise<boolean> { @@ -3817,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; } @@ -3839,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", @@ -3876,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, }); @@ -3889,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", @@ -3917,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", @@ -3965,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); @@ -4006,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; @@ -4082,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() { @@ -4092,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); } @@ -4134,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; @@ -4206,7 +4540,11 @@ describe("send_agent_prompt MCP tool", () => { }, ); unsubscribe(); - expect(response.structuredContent).toMatchObject({ success: true, status: "idle" }); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "turn_started", + }); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle")); await vi.waitFor(() => { const parentPrompts = parentClient.sessions[0]!.prompts; expect(parentPrompts).toHaveLength(1); diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 8b894a7765..ae8ed805d7 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1121,3 +1121,50 @@ describe("plugin provider launch and status", () => { }, ); }); + +test("config-required plugin membership is independent of enablement and other providers", async () => { + const logger = createTestLogger(); + const plugins = new PluginAgentClientRegistry(logger); + const harness = createProviderHarness(); + let connections = 0; + let probes = 0; + plugins.replace([ + { + ...harness.registration, + requiresConfiguration: true, + async status() { + probes++; + return { available: true }; + }, + async connect(request) { + connections++; + return harness.registration.connect(request); + }, + }, + ]); + const build = (providerOverrides = {}) => + buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides, + }); + try { + const before = build(); + expect(before["plugin-direct"]).toBeUndefined(); + expect({ probes, connections }).toEqual({ probes: 0, connections: 0 }); + const added = build({ "plugin-direct": {} }); + expect(added["plugin-direct"].enabled).toBe(true); + const client = added["plugin-direct"].createClient(logger); + expect(await client.isAvailable()).toBe(true); + const catalog = await client.fetchCatalog({ scope: "global" }); + expect(catalog.models.map((model) => model.id)).toEqual(["plugin-model"]); + expect({ probes, connections }).toEqual({ probes: 1, connections: 1 }); + const disabled = build({ "plugin-direct": { enabled: false } }); + expect(disabled["plugin-direct"].enabled).toBe(false); + expect(Object.keys(build())).toEqual(Object.keys(before)); + for (const id of Object.keys(before)) { + expect(added[id].enabled).toBe(before[id].enabled); + } + } finally { + await plugins.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 58bddd07b6..e575ff0d45 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -885,6 +885,7 @@ function createPluginProviderDefinition( label: registration.label, description: registration.description ?? `Plugin provider ${registration.label}`, iconSvg: registration.icon, + requiresConfiguration: registration.requiresConfiguration, defaultModeId: null, modes: [], supportsExactMcpPreapproval: true, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 3a66ffcfcf..9f85e4d37f 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -97,6 +97,7 @@ export interface ProviderDefinition extends AgentProviderDefinition { /** Provider metadata and an unconfigured factory; the registry owns override application. */ export interface RegisteredProviderDefinition extends AgentProviderDefinition { + requiresConfiguration?: boolean; iconSvg?: string; supportsExactMcpPreapproval: boolean; createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient; @@ -449,6 +450,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession get features() { return inner.features; }, + usageSession: inner.usageSession?.bind(inner), run: (prompt, options) => inner.run(prompt, options), startTurn: (prompt, options) => inner.startTurn(prompt, options), steerActiveTurn: inner.steerActiveTurn?.bind(inner), @@ -491,6 +493,7 @@ function wrapClientProvider( const listFeatures = inner.listFeatures?.bind(inner); const archiveNativeSession = inner.archiveNativeSession?.bind(inner); const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner); + const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id); return { provider, @@ -508,7 +511,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), resumeSession: async (handle, overrides, launchContext, options) => @@ -525,7 +528,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), fetchCatalog: async (options, context) => { @@ -923,6 +926,8 @@ export function buildProviderRegistry( throw new Error(`Plugin provider '${provider}' conflicts with a built-in provider`); } const override = providerOverrides[provider]; + if (definition.requiresConfiguration && !override) continue; + if (override?.extends) { logger.warn({ provider }, "Plugin provider shadowed by configured provider"); continue; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 82acd13e1f..b10a4f8e02 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -348,6 +348,10 @@ export class ProviderSnapshotManager { return Object.prototype.hasOwnProperty.call(this.generation.definitions, provider); } + supportsConfigRequiredProvider(provider: AgentProvider): boolean { + return this.pluginProviders.definitions()[provider]?.requiresConfiguration === true; + } + getProviderLabel(provider: AgentProvider): string { return this.generation.definitions[provider]?.label ?? provider; } diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 9d106a2c1a..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"; @@ -825,6 +829,46 @@ describe("mapACPUsage", () => { }); }); +describe("ACP context-window usage", () => { + async function emitUsageUpdate(update: { + used: number; + size: number; + }): Promise<{ events: unknown[] }> { + const session = createSessionWithConfig({ provider: "dsh" }); + asInternals<ACPSessionInternals>(session).sessionId = "session-1"; + const events: unknown[] = []; + session.subscribe((event) => { + if (event.type === "usage_updated") events.push(event); + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { sessionUpdate: "usage_update", used: update.used, size: update.size }, + }); + return { events }; + } + + test("forwards usage_update as context-window usage state", async () => { + const { events } = await emitUsageUpdate({ used: 13_759, size: 1_000_000 }); + + expect(events).toEqual([ + { + type: "usage_updated", + provider: "dsh", + usage: { contextWindowMaxTokens: 1_000_000, contextWindowUsedTokens: 13_759 }, + }, + ]); + }); + + test("emits nothing when size and used cannot both drive a meter", async () => { + await expect( + emitUsageUpdate({ used: -1, size: 0 }).then((result) => result.events), + ).resolves.toEqual([]); + await expect( + emitUsageUpdate({ used: 13_759, size: 0 }).then((result) => result.events), + ).resolves.toEqual([]); + }); +}); + describe("deriveModesFromACP", () => { test("prefers explicit ACP mode state", () => { const result = deriveModesFromACP( @@ -3396,6 +3440,8 @@ interface ACPCloseInternals { child: ChildProcess | null; connection: unknown; sessionId: string | null; + activeForegroundTurnId: string | null; + agentCapabilities: { sessionCapabilities?: { close?: unknown } } | null; } async function startTerminal( @@ -3462,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 }); @@ -4180,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 524ede92dd..3b61fcb863 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -76,6 +76,9 @@ import { type AgentPersistenceHandle, type AgentPromptContentBlock, type AgentPromptInput, + type AgentResumePurpose, + type AgentCreateSessionOptions, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -148,6 +151,18 @@ function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promis }); } +async function isDirectory(target: string): Promise<boolean> { + try { + return (await fs.stat(target)).isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + function isRecord(value: unknown): value is Record<string, unknown> { return value != null && typeof value === "object" && !Array.isArray(value); } @@ -301,6 +316,10 @@ export function buildACPClientCapabilities( const PROBE_ENV: Record<string, string> = { NO_BROWSER: "true" }; const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000; const ACP_PROBE_CLOSE_TIMEOUT_MS = 2_000; +// Archive and delete await close(). The ACP SDK does not reject a pending +// request when the provider never answers, so these calls must be bounded +// or the provider process is never terminated. +const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000; const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000; const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000; @@ -493,6 +512,8 @@ interface ACPAgentSessionOptions { capabilities: AgentCapabilityFlags; extensionCommandsParser?: ACPExtensionCommandsParser; handle?: AgentPersistenceHandle; + resumePurpose?: AgentResumePurpose; + configuredModelIds?: readonly string[]; agentId?: string; launchEnv?: Record<string, string>; waitForInitialCommands?: boolean; @@ -960,6 +981,7 @@ export class ACPAgentClient implements AgentClient { async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise<AgentSession> { this.assertProvider(config); const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {}); @@ -987,6 +1009,7 @@ export class ACPAgentClient implements AgentClient { supportsMcpServers: providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1002,6 +1025,7 @@ export class ACPAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise<AgentSession> { if (handle.provider !== this.provider) { throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`); @@ -1043,6 +1067,8 @@ export class ACPAgentClient implements AgentClient { providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, handle, + resumePurpose: options?.purpose, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1671,6 +1697,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly configuredModelIds: ReadonlySet<string>; private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; @@ -1696,6 +1723,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly terminalEntries = new Map<string, TerminalEntry>(); private readonly persistedHistory: AgentTimelineItem[] = []; private readonly initialHandle?: AgentPersistenceHandle; + private readonly resumePurpose: AgentResumePurpose; private readonly config: AgentSessionConfig; private child: ChildProcessWithoutNullStreams | null = null; @@ -1722,6 +1750,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private fallbackAssistantMessageId: string | null = null; private closed = false; private historyPending = false; + private historyRuntimeReleased = false; private replayingHistory = false; private bootstrapThreadEventPending = false; private readonly terminateProcess: ProcessTerminator; @@ -1738,6 +1767,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.configuredModelIds = new Set(options.configuredModelIds); this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; @@ -1749,6 +1779,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.agentId = options.agentId; this.launchEnv = options.launchEnv; this.initialHandle = options.handle; + this.resumePurpose = options.resumePurpose ?? "interactive"; this.config = { ...config, provider: options.provider }; this.currentMode = config.modeId ?? null; this.currentModel = config.model ?? null; @@ -1837,11 +1868,38 @@ export class ACPAgentSession implements AgentSession, ACPClient { } await this.applyConfiguredOverrides(); + + if (this.resumePurpose === "history") { + await this.releaseHistoryRuntime(); + } } catch (error) { await this.closeAfterInitializationFailure(error); } } + // History resumes exist to replay persisted events into the canonical + // timeline; the provider process is released once replay finishes so + // archived agents hold no resident runtime. Interactive work goes through + // reloadAgentSession, which spawns a fresh process. + private async releaseHistoryRuntime(): Promise<void> { + const child = this.child; + if (child) { + const result = await this.terminateProcess(child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }); + if (result === "kill-timeout") { + // Keep the child registered so close() can retry the shutdown. + this.logger.warn("ACP history resume could not release the provider process"); + return; + } + } + this.historyRuntimeReleased = true; + this.connection = null; + this.child = null; + this.settleCommandsReady(); + } + private async closeAfterInitializationFailure(error: unknown): Promise<never> { try { await this.close(); @@ -2171,12 +2229,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } + await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) }); + } + + private resolveModelSelection(modelId: string): ACPModelSelection { const selection = resolveACPModelSelection({ modelId, availableModels: this.availableModels, configOptions: this.configOptions, }); - await this.setModelWithSelection({ modelId, selection }); + if (selection.availableModel || !this.configuredModelIds.has(modelId)) { + return selection; + } + // A model the user added in provider config is applied even when the agent does not advertise it. + return { ...selection, availableModel: { modelId, name: modelId } }; } private async setModelWithSelection({ @@ -2190,21 +2256,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error("ACP session not initialized"); } - if (selection.hasAvailableModels) { - if (!selection.availableModel) { - this.warnInvalidSelection( - modelId, - `is not a valid ${this.provider} model. Available options: ${this.availableModels - ?.map((model) => model.modelId) - .join(", ")}`, - ); - return; - } - - if (typeof this.connection.unstable_setSessionModel !== "function") { - throw new Error(this.modelSelectionUnavailableMessage()); - } + if (selection.hasAvailableModels && !selection.availableModel) { + this.warnInvalidSelection( + modelId, + `is not a valid ${this.provider} model. Available options: ${this.availableModels + ?.map((model) => model.modelId) + .join(", ")}`, + ); + return; + } + if ( + selection.availableModel && + typeof this.connection.unstable_setSessionModel === "function" + ) { try { await this.connection.unstable_setSessionModel({ sessionId: this.sessionId, @@ -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)) { @@ -3023,7 +3098,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.cachedCommands = update.availableCommands.map((command) => ({ name: command.name, description: command.description, - argumentHint: "", + argumentHint: command.input?.hint ?? "", kind: "command", })); this.settleCommandsReady(); @@ -3178,8 +3253,29 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + // ACP reports context occupancy out of band from the prompt response, so the + // composer's context meter cannot read it from `turn_completed`. Forward it as + // the same `usage_updated` snapshot the native adapters emit, preserving the + // turn usage this session already recorded. The guards match the meter's own + // validity rules, so a provider cannot push a window the meter would reject. private handleUsageUpdate(update: UsageUpdate): void { - void update; + const contextWindowMaxTokens = + Number.isFinite(update.size) && update.size > 0 ? update.size : undefined; + const contextWindowUsedTokens = + Number.isFinite(update.used) && update.used >= 0 ? update.used : undefined; + if (contextWindowMaxTokens === undefined || contextWindowUsedTokens === undefined) { + return; + } + this.pushEvent({ + type: "usage_updated", + provider: this.provider, + usage: { + ...this.currentTurnUsage, + contextWindowMaxTokens, + contextWindowUsedTokens, + }, + turnId: this.activeForegroundTurnId ?? undefined, + }); } private handlePromptResponse(response: PromptResponse, turnId: string): void { @@ -3277,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) { @@ -3285,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.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index a767dc9c2a..630bdbf0a6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -314,6 +314,127 @@ describe("background Claude subagents", () => { }); }); + test("keeps the Task card labeled when the child's frames arrive after its launch result", async () => { + // Claude Code answers a background Agent call at once ("Agent launched") and only then streams + // the child's frames, so they arrive after the parent's tool call has its result. + queryFactory.mockImplementation(() => + buildQueryMock([ + { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, + { + type: "assistant", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_background", + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + }, + ], + }, + }, + { + type: "system", + subtype: "task_started", + task_id: "bg-task", + tool_use_id: "toolu_background", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_background", + content: "Async agent launched successfully.", + }, + ], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_ls", + name: "Bash", + input: { command: "ls -1", description: "List files" }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "toolu_background", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] }, + }, + { + type: "system", + subtype: "task_updated", + task_id: "bg-task", + patch: { status: "completed" }, + }, + { + type: "system", + subtype: "task_notification", + task_id: "bg-task", + tool_use_id: "toolu_background", + status: "completed", + }, + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + const rows: AgentTimelineRow[] = events + .filter((event) => event.type === "timeline") + .map((event, index) => ({ + seq: index + 1, + timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, index)).toISOString(), + item: event.item, + })); + const cards = projectTimelineRows({ rows, mode: "projected" }) + .map((entry) => entry.item) + .filter((item) => item.type === "tool_call" && item.callId === "toolu_background"); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + status: "completed", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "Count files here", + }, + }); + }); + test.each([ { source: "system task protocol", diff --git a/packages/server/src/server/agent/providers/claude/agent.context-switch.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.context-switch.real.e2e.test.ts new file mode 100644 index 0000000000..f05a6ee271 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.context-switch.real.e2e.test.ts @@ -0,0 +1,42 @@ +import pino from "pino"; +import { expect, test } from "vitest"; +import { ClaudeAgentClient } from "./agent.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; + +// Explicit real-provider test: run separately from the default suite. +test("uses the active model context window after switching from a 1M model", async () => { + const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) }); + const session = await client.createSession( + { provider: "claude", cwd: process.cwd(), model: "claude-haiku-4-5" }, + undefined, + { persistSession: false }, + ); + try { + for (const [model, maxTokens] of [ + ["claude-haiku-4-5", 200_000], + ["claude-opus-5-5", 1_000_000], + ["claude-haiku-4-5", 200_000], + ] as const) { + await session.setModel(model); + const events = await collectSessionTurnEvents( + session, + "Reply with exactly: OK. Do not use tools.", + { signal: AbortSignal.timeout(90_000) }, + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }), + }), + ); + } + } finally { + await session.close(); + } +}, 300_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.context-usage.local.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.context-usage.local.e2e.test.ts new file mode 100644 index 0000000000..7c3883986a --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.context-usage.local.e2e.test.ts @@ -0,0 +1,140 @@ +import { createServer } from "node:http"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +// Exercise the real Claude Code process against a local Messages API fixture. +// The input counter's location is the only difference between the two backends. +test.each([ + { placement: "start", cacheTokens: 0 }, + { placement: "delta", cacheTokens: 0 }, + { placement: "start", cacheTokens: 1_000 }, + { placement: "delta", cacheTokens: 1_000 }, + { placement: "start", cacheTokens: 0, model: "glm-5.2[1m]" }, + { placement: "start", cacheTokens: 0, model: "sonnet", resolvedModel: "glm-5.2[1m]" }, +])( + "counts $placement input and $cacheTokens cached tokens through Claude Code ($model)", + async ({ placement, cacheTokens, model = "claude-sonnet-4-6", resolvedModel = model }) => { + const cwd = await mkdtemp(path.resolve(".claude-context-usage-")); + const configDir = path.join(cwd, "config"); + await mkdir(configDir); + await writeFile( + path.join(configDir, "settings.json"), + JSON.stringify({ env: { ANTHROPIC_DEFAULT_SONNET_MODEL: resolvedModel } }), + ); + let requestCount = 0; + const server = createServer(async (request, response) => { + for await (const chunk of request) { + void chunk; + } + if (!request.url?.startsWith("/v1/messages")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end("{}"); + return; + } + if (request.url.includes("count_tokens")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ input_tokens: 20_236 })); + return; + } + const inputTokens = 20_236 + requestCount++; + response.writeHead(200, { "content-type": "text/event-stream" }); + const emit = (data: { type: string; [key: string]: unknown }) => { + response.write(`event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`); + }; + emit({ + type: "message_start", + message: { + id: `msg_context_usage_${requestCount}`, + type: "message", + role: "assistant", + model: resolvedModel, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: placement === "start" ? inputTokens : 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: cacheTokens, + }, + }, + }); + emit({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }); + emit({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hello" } }); + emit({ type: "content_block_stop", index: 0 }); + emit({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { + output_tokens: 64, + ...(placement === "delta" ? { input_tokens: inputTokens } : {}), + }, + }); + emit({ type: "message_stop" }); + response.end(); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Fixture has no TCP address"); + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + runtimeSettings: { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`, + ANTHROPIC_AUTH_TOKEN: "fixture-token", + ANTHROPIC_API_KEY: "", + CLAUDE_CONFIG_DIR: configDir, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }, + }, + }); + const session = await client.createSession({ provider: "claude", cwd, model }, undefined, { + persistSession: false, + }); + try { + for (let turn = 0; turn < 2; turn++) { + const events = await collectSessionTurnEvents(session, "say hello", { + signal: AbortSignal.timeout(20_000), + }); + const expectedTokens = 20_236 + turn + cacheTokens + 64; + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ + inputTokens: 20_236 + turn, + outputTokens: 64, + cachedInputTokens: cacheTokens, + contextWindowUsedTokens: expectedTokens, + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ + contextWindowUsedTokens: expectedTokens, + contextWindowMaxTokens: resolvedModel.endsWith("[1m]") ? 1_000_000 : 200_000, + }), + }), + ); + } + expect(requestCount).toBe(2); + } finally { + try { + await session.close(); + } finally { + try { + server.closeAllConnections(); + await new Promise<void>((resolve) => server.close(() => resolve())); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + } + }, + 60_000, +); diff --git a/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts b/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts new file mode 100644 index 0000000000..84f31779e4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts @@ -0,0 +1,218 @@ +import { expect, test } from "vitest"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; +import type { ClaudeQueryFactory } from "./query.js"; + +// A query dependency that speaks the same stream interface as Claude Code. +// Each request repeats init so a second turn exercises runtime-limit retention. +function contextQuery({ + runtimeModel, + reportedLimit, + modelWindowsByTurn, +}: { + runtimeModel: string; + reportedLimit: number; + modelWindowsByTurn?: Array<Record<string, number>>; +}): ClaudeQueryFactory { + return ({ prompt }) => { + let model = runtimeModel; + let turn = 0; + const messages = (async function* () { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + for await (const _message of prompt) { + const modelWindows = modelWindowsByTurn?.[turn] ?? { [model]: reportedLimit }; + turn += 1; + const usageEntries = Object.entries(modelWindows).map(([id, contextWindow]) => [ + id, + { contextWindow }, + ]); + const modelUsage = Object.fromEntries(usageEntries); + yield { + type: "system", + subtype: "init", + session_id: "context-session", + permissionMode: "default", + model, + }; + yield { + type: "stream_event", + session_id: "context-session", + event: { type: "message_start", message: { usage: { input_tokens: 249_950 } } }, + }; + yield { + type: "stream_event", + session_id: "context-session", + event: { type: "message_delta", usage: { output_tokens: 50 } }, + }; + yield { + type: "result", + subtype: "success", + session_id: "context-session", + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: "done", + stop_reason: null, + total_cost_usd: 0, + usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 }, + modelUsage, + permission_denials: [], + uuid: "context-result", + }; + } + })(); + return Object.assign(messages, { + close: () => { + void messages.return(); + }, + interrupt: async () => undefined, + setModel: async (value: string) => { + model = value; + }, + setPermissionMode: async () => undefined, + supportedModels: async () => [], + supportedCommands: async () => [], + }) as ReturnType<ClaudeQueryFactory>; + }; +} + +test.each([ + { selected: "glm-5.2[1m]", runtime: "glm-5.2[1m]", expected: 1_000_000 }, + { selected: "sonnet", runtime: "glm-5.2[1m]", expected: 1_000_000 }, + { selected: "glm-5.2", runtime: "glm-5.2", expected: undefined }, +])( + "streams the context limit for $selected resolved as $runtime", + async ({ selected, runtime, expected }) => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: contextQuery({ runtimeModel: runtime, reportedLimit: 800_000 }), + resolveBinary: async () => process.execPath, + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: selected, + }); + try { + const first = await collectSessionTurnEvents(session, "first turn"); + const live = first.filter((event) => event.type === "usage_updated"); + expect(live.map((event) => event.usage.contextWindowMaxTokens)).toEqual([expected, expected]); + expect(live.map((event) => event.usage.contextWindowUsedTokens)).toEqual([249_950, 250_000]); + expect(first).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: 800_000 }), + }), + ); + + const second = await collectSessionTurnEvents(session, "second turn"); + expect( + second + .filter((event) => event.type === "usage_updated") + .map((event) => event.usage.contextWindowMaxTokens), + ).toEqual([800_000, 800_000]); + + await session.setModel?.("another-custom-model"); + const switched = await collectSessionTurnEvents(session, "after model switch"); + expect( + switched + .filter((event) => event.type === "usage_updated") + .map((event) => event.usage.contextWindowMaxTokens), + ).toEqual([undefined, undefined]); + } finally { + await session.close(); + } + }, +); + +test("completed turns use the active model's window after switching both ways", async () => { + const haiku = "claude-haiku-4-5"; + const opus = "claude-opus-5-5"; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => process.execPath, + queryFactory: contextQuery({ + runtimeModel: haiku, + reportedLimit: 200_000, + modelWindowsByTurn: [ + { [haiku]: 200_000 }, + { [haiku]: 200_000, [opus]: 1_000_000 }, + { [haiku]: 200_000, [opus]: 1_000_000 }, + ], + }), + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: haiku, + }); + try { + for (const [model, limit] of [ + [haiku, 200_000], + [opus, 1_000_000], + [haiku, 200_000], + ] as const) { + await session.setModel(model); + const events = await collectSessionTurnEvents(session, "OK"); + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ contextWindowMaxTokens: limit }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: limit }), + }), + ); + } + } finally { + await session.close(); + } +}); + +test.each([ + { + name: "runtime-reported custom limit", + model: "custom-model", + windows: { "custom-model": 400_000, "prior-model": 1_000_000 }, + expected: 400_000, + }, + { + name: "missing active model entry", + model: "claude-haiku-4-5", + windows: { "prior-model": 1_000_000 }, + expected: 200_000, + }, + { + name: "invalid active model limit", + model: "claude-haiku-4-5", + windows: { "claude-haiku-4-5": -1, "prior-model": 1_000_000 }, + expected: 200_000, + }, +])("preserves $name without using another model's window", async ({ model, windows, expected }) => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => process.execPath, + queryFactory: contextQuery({ + runtimeModel: model, + reportedLimit: expected, + modelWindowsByTurn: [windows], + }), + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd(), model }); + try { + const events = await collectSessionTurnEvents(session, "OK"); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: expected }), + }), + ); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index 642e562f61..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", @@ -90,8 +92,14 @@ describe("Claude SDK env", () => { expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); 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(); } }); @@ -150,12 +158,19 @@ describe("Claude SDK env", () => { ); try { + const descriptor = session.usageSession?.(); + expect(descriptor?.env.PASEO_TEST_FLAG).toBe("resume-launch-value"); + expect(descriptor?.sessionKey).toEqual(expect.any(String)); + expect(queryFactory).not.toHaveBeenCalled(); const result = await session.run("resume env check"); + expect(session.usageSession?.()?.sessionKey).toBe(descriptor?.sessionKey); + expect(capturedEnv).toBe(descriptor?.env); expect(result.sessionId).toBe("persisted-session"); expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID); expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); } }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index 223e2001f5..50d9c56ab5 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import pino from "pino"; @@ -90,4 +90,29 @@ describe("ClaudeAgentSession persisted history load", () => { }), ); }); + + // chmod does not remove read access on Windows, or for root. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "logs a warning with the error when Claude's project folders cannot be listed", + async () => { + // The transcript is not under cwd's folder, so the lookup lists the other project folders. + mkdirSync(path.join(configDir, "projects"), { recursive: true }); + chmodSync(path.join(configDir, "projects"), 0o000); + + try { + await resume(); + } finally { + chmodSync(path.join(configDir, "projects"), 0o755); + } + + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.warn, + msg: "Failed to load Claude history from transcript", + sessionId: SESSION_ID, + err: expect.objectContaining({ message: expect.stringContaining("EACCES") }), + }), + ); + }, + ); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts new file mode 100644 index 0000000000..e20b8d6faf --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts @@ -0,0 +1,239 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +const SESSION_CWD = "/tmp/paseo-claude-renamed-import"; + +interface ClaudeTranscriptRecord { + [key: string]: unknown; +} + +let configDir: string; +let previousConfigDir: string | undefined; + +interface UserRecordInput { + sessionId: string; + content: string; +} + +interface WriteSessionInput { + sessionId: string; + records: ClaudeTranscriptRecord[]; +} + +function userRecord({ sessionId, content }: UserRecordInput): ClaudeTranscriptRecord { + return { + isSidechain: false, + type: "user", + message: { role: "user", content }, + cwd: SESSION_CWD, + sessionId, + }; +} + +async function writeSession({ sessionId, records }: WriteSessionInput): Promise<void> { + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, + ); +} + +// Importing replays the transcript from disk, so this drives the real resume +// path without starting Claude Code. +async function importSession(sessionId: string) { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + }); + return client.importSession( + { providerHandleId: sessionId, cwd: SESSION_CWD }, + { + config: { provider: "claude", cwd: SESSION_CWD }, + storedConfig: { provider: "claude", cwd: SESSION_CWD }, + }, + ); +} + +beforeEach(async () => { + configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-renamed-")); + previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; +}); + +afterEach(async () => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + await fs.rm(configDir, { recursive: true, force: true }); +}); + +describe("importing a renamed Claude session", () => { + test.each(["provider", "launch"])( + "reads the rename from the %s config directory used for history", + async (source) => { + const sessionId = "provider-directory-rename"; + const providerConfigDir = path.join(configDir, "provider"); + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir: providerConfigDir }); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ] + .map((record) => JSON.stringify(record)) + .join("\n") + "\n", + ); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: + source === "provider" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined, + resolveBinary: async () => "/test/claude/bin", + }); + const imported = await client.importSession( + { providerHandleId: sessionId, cwd: SESSION_CWD }, + { + config: { provider: "claude", cwd: SESSION_CWD }, + storedConfig: { provider: "claude", cwd: SESSION_CWD }, + launchContext: + source === "launch" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined, + }, + ); + expect(imported.timeline.map((entry) => entry.item)).toContainEqual( + expect.objectContaining({ type: "user_message", text: "Review this project" }), + ); + expect(imported.config.title).toBe("My research session"); + }, + ); + + test("names the imported agent after a /rename", async () => { + const sessionId = "renamed-session"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("uses the last rename when the session was renamed more than once", async () => { + const sessionId = "renamed-twice"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Move invoices to the new schema" }), + { type: "custom-title", customTitle: "invoices", sessionId }, + { type: "custom-title", customTitle: "billing rework", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("billing rework"); + }); + + test("preserves a rename when the first prompt was /model", async () => { + const sessionId = "model-first"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "/model" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("keeps the rename when a later record carries no usable name", async () => { + const sessionId = "malformed-later-record"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + { type: "custom-title", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("ignores a rename recorded on a sidechain transcript", async () => { + const sessionId = "sidechain-rename"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "Subagent title", sessionId, isSidechain: true }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBeUndefined(); + }); + + test("leaves the agent untitled when the session was never renamed", async () => { + const sessionId = "never-renamed"; + await writeSession({ + sessionId, + records: [userRecord({ sessionId, content: "Review this project" })], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBeUndefined(); + }); + + test("does not name the imported agent after a generated ai-title", async () => { + const sessionId = "auto-titled"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Add pagination to the invoice list" }), + { type: "ai-title", aiTitle: "Invoice list pagination", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + // The Import Session list shows it, but agents are named from their first + // prompt line. + expect(imported.config.title).toBeUndefined(); + }); + + test("imports a session that has no transcript on disk", async () => { + const imported = await importSession("no-such-session"); + + expect(imported.config.title).toBeUndefined(); + }); + + test("surfaces an unreadable transcript instead of importing it as never renamed", async () => { + const sessionId = "unreadable-transcript"; + // A directory where the transcript belongs fails the read with EISDIR, the + // deterministic stand-in for a permissions or I/O fault. + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(path.join(projectDir, `${sessionId}.jsonl`), { recursive: true }); + + await expect(importSession(sessionId)).rejects.toThrow(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts index dada35945c..02a09ba44b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts @@ -91,6 +91,17 @@ function buildUsage() { }; } +/** Claude opening a turn of its own. Every main-session turn starts with one. */ +function buildTurnInit(sessionId: string) { + return { + type: "system", + subtype: "init", + session_id: sessionId, + permissionMode: "default", + model: "opus", + }; +} + function buildSuccessResult(sessionId: string) { return { type: "result", @@ -153,13 +164,7 @@ function createScriptedQuery(params: { }, } satisfies ScriptedQuery; - scriptedQuery.emit({ - type: "system", - subtype: "init", - session_id: params.sessionId, - permissionMode: "default", - model: "opus", - }); + scriptedQuery.emit(buildTurnInit(params.sessionId)); void (async () => { for await (const prompt of params.prompt) { @@ -252,6 +257,21 @@ async function waitFor( } } +/** + * Paseo interrupts Claude only while a main-session turn is in flight, which it learns from the + * init. The pump asks for its next frame only after routing the last one, so a second next() call + * proves the init was read. + */ +async function waitForInitRouted( + query: () => { next: ReturnType<typeof vi.fn> } | null | undefined, +) { + await waitFor(() => (query()?.next.mock.calls.length ?? 0) >= 2); +} + +function buildCommandLifecycle(commandUuid: string | null | undefined, state: string) { + return { type: "command_lifecycle", command_uuid: commandUuid, state }; +} + afterEach(() => { queryFactory.mockReset(); }); @@ -282,6 +302,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async () const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); + await waitForInitRouted(() => queries[0]); await session.interrupt(); await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1); @@ -322,6 +343,7 @@ async function startSteeredTurn(sessionId: string): Promise<{ throw new Error("Expected the original Claude turn to start"); } await waitFor(() => query?.prompts.length === 1); + await waitForInitRouted(() => query); const steered = await session.steerActiveTurn!("queued steer", { expectedTurnId: start.value.turnId, @@ -371,11 +393,8 @@ test("interrupt still stops the turn when Claude has already dequeued the steer" test("a steer Claude has already read is no longer discardable on interrupt", async () => { const { session, query, turn } = await startSteeredTurn("queued-steer-completed-session"); - query()?.emit({ - type: "command_lifecycle", - command_uuid: query()?.prompts[1]?.uuid, - state: "completed", - }); + query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started")); + query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "completed")); // Frames are translated in order, so the marker landing proves the lifecycle frame was read. query()?.emit({ type: "assistant", message: { content: "STEER_READ" } }); await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_READ")); @@ -388,6 +407,23 @@ test("a steer Claude has already read is no longer discardable on interrupt", as await session.close(); }); +test("a steer Claude cancelled is no longer withdrawn on interrupt", async () => { + const { session, query, turn } = await startSteeredTurn("queued-steer-cancelled-session"); + + query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started")); + // "cancelled" is terminal: API errors, interrupts and "now" pre-emption all end a message. + query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "cancelled")); + query()?.emit({ type: "assistant", message: { content: "STEER_CANCELLED" } }); + await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_CANCELLED")); + + await session.interrupt(); + await waitFor(() => query()?.interrupt.mock.calls.length === 1); + + expect(query()?.cancelAsyncMessage).not.toHaveBeenCalled(); + await collectUntilTerminal(turn); + await session.close(); +}); + function buildStoppedTaskNotification(sessionId: string) { return { type: "system", @@ -473,6 +509,7 @@ async function startInterruptedToolTurn(sessionId: string): Promise<{ const turn = streamSession(session, "run the slow tool"); await turn.next(); await waitFor(() => query?.prompts.length === 1); + await waitForInitRouted(() => query); await session.interrupt(); await collectUntilTerminal(turn); @@ -523,6 +560,7 @@ test("Claude can still wake into an autonomous turn once the interrupted request query()?.emit(buildStoppedTaskNotification(sessionId)); query()?.emit(buildAbortedResult(sessionId)); + query()?.emit(buildTurnInit(sessionId)); query()?.emit({ type: "assistant", message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, @@ -579,6 +617,7 @@ test("reuses the existing query after interrupt before starting the next prompt" const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); + await waitForInitRouted(() => queries[0]); await session.interrupt(); await collectUntilTerminal(firstTurn); @@ -652,6 +691,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const output = createAsyncQueue<Record<string, unknown>>(); const prompts: PromptRecord[] = []; let throwAbortOnNext = false; + let queryRef: Pick<ScriptedQuery, "next"> | null = null; queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { const scriptedQuery = { @@ -685,13 +725,8 @@ test("recovers when the query pump sees a single interrupt abort before the next }, } satisfies ScriptedQuery; - scriptedQuery.emit({ - type: "system", - subtype: "init", - session_id: "interrupt-abort-recovery-session", - permissionMode: "default", - model: "opus", - }); + queryRef = scriptedQuery; + scriptedQuery.emit(buildTurnInit("interrupt-abort-recovery-session")); void (async () => { for await (const promptMessage of prompt) { @@ -730,6 +765,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); + await waitForInitRouted(() => queryRef); await session.interrupt(); await collectUntilTerminal(firstTurn); @@ -768,6 +804,7 @@ test("stale abort result after replacement start does not poison the new foregro const firstTurn = streamSession(session, "first prompt"); const firstStarted = await firstTurn.next(); await waitFor(() => queryRef?.prompts.length === 1); + await waitForInitRouted(() => queryRef); await session.interrupt(); const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))]; @@ -812,7 +849,7 @@ test("stale abort result after replacement start does not poison the new foregro await session.close(); }); -test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => { +test("creates an autonomous live turn when Claude starts a turn without a foreground run", async () => { const logger = createTestLogger(); let queryRef: ScriptedQuery | null = null; @@ -848,6 +885,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo await collectUntilTerminal(streamSession(session, "seed prompt")); const subscribedEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-live-session")); queryRef?.emit({ type: "assistant", message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, @@ -916,6 +954,7 @@ test("steers an autonomous turn through its existing query without restarting it await collectUntilTerminal(streamSession(session, "seed prompt")); const autonomousEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-steer-session")); queryRef?.emit({ type: "assistant", message: { content: "AUTONOMOUS_RESPONSE" }, @@ -999,6 +1038,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a await collectUntilTerminal(streamSession(session, "seed prompt")); const subscribedEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-handoff-session")); queryRef?.emit({ type: "assistant", message: { content: "BACKGROUND_ONLY_RESPONSE" }, diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 5069c3653f..5393926520 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -327,6 +327,57 @@ test("fails an auto mode turn when Claude Code uses Vertex", async () => { } }); +test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => { + const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "auto", + }); + + try { + await session.setMode("acceptEdits"); + + expect(await session.getCurrentMode()).toBe("acceptEdits"); + expect(sdkQueryFactory).toHaveBeenCalledTimes(1); + expect(sdkQueryFactory.mock.calls[0]?.[0]?.options?.permissionMode).toBe("acceptEdits"); + } finally { + restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock); + await session.close(); + } +}); + +test("does not keep a query started in a mode that Claude Code rejected", async () => { + const launchedModes: Array<string | undefined> = []; + sdkQueryFactory.mockImplementation(({ options }: { options: { permissionMode?: string } }) => { + launchedModes.push(options.permissionMode); + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + if (launchedModes.length === 1) { + queryMock.setPermissionMode.mockRejectedValue(new Error("bypass refused")); + } + return queryMock; + }); + const session = await createSession(); + + try { + await expect(session.setMode("bypassPermissions")).rejects.toThrow("bypass refused"); + await session.listCommands(); + + expect(await session.getCurrentMode()).toBe("default"); + expect(launchedModes).toEqual(["bypassPermissions", "default"]); + } finally { + await session.close(); + } +}); + test("logs redacted query summary and never leaks sentinel secrets", async () => { const envSecret = "PASEO_ENV_SENTINEL_SECRET"; const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET"; @@ -435,6 +486,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a } | null; input: { end: () => void } | null; queryRestartNeeded: boolean; + mainTurnInFlight: boolean; interruptActiveTurn: () => Promise<void>; } = asInternals(session); const interrupt = vi.fn(async () => undefined); @@ -447,6 +499,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a }; internal.input = { end }; internal.queryRestartNeeded = false; + internal.mainTurnInFlight = true; try { await internal.interruptActiveTurn(); @@ -1037,11 +1090,11 @@ test("preserves bypass capability across query restarts triggered by thinking ch expect(capturedOptions).toHaveLength(2); expect(capturedOptions[0]).toMatchObject({ - permissionMode: "default", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, }); expect(capturedOptions[1]).toMatchObject({ - permissionMode: "acceptEdits", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, effort: "high", }); @@ -1140,6 +1193,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); internal.turnState = "idle"; + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); await internal.routeSdkMessageFromPump( { type: "stream_event", @@ -1182,6 +1236,468 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou await session.close(); }); +interface SessionStateInternals { + turnState: "idle" | "foreground" | "autonomous"; + routeSdkMessageFromPump: ( + message: Record<string, unknown>, + activeQuery: QueryMock, + ) => Promise<void>; + autonomousTurn: { id: string } | null; + activeForegroundTurnId: string | null; +} + +function sessionState(state: "idle" | "running" | "requires_action") { + return { + type: "system", + subtype: "session_state_changed", + state, + uuid: `state-${state}`, + session_id: "sess-1", + }; +} + +/** Claude opening a turn of its own. Every main-session turn starts with one. */ +function claudeTurnInit() { + return { + type: "system", + subtype: "init", + session_id: "sess-1", + permissionMode: "default", + model: "opus", + }; +} + +function successResult() { + return { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }; +} + +function commandLifecycle( + commandUuid: string, + state: "queued" | "started" | "completed" | "cancelled", +) { + return { type: "command_lifecycle", command_uuid: commandUuid, state }; +} + +function unboundTextDelta(text: string) { + return { + type: "stream_event", + parent_tool_use_id: null, + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + }; +} + +// What a background subagent emits on the main stream with no parent tool use id, so by frame +// shape alone each looks like main-session work. Verified on the wire: none arrives inside a turn +// Claude opened, and no result follows any of them. +const helperTaskStarted = { + type: "system", + subtype: "task_started", + task_id: "helper-task", + tool_use_id: "toolu_helper", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Background helper", + is_backgrounded: true, + uuid: "uuid-task-started", + session_id: "sess-1", +}; +const helperTaskProgress = { + type: "system", + subtype: "task_progress", + task_id: "helper-task", + tool_use_id: "toolu_helper", + description: "Background helper", + usage: { total_tokens: 1200, tool_uses: 3, duration_ms: 900 }, + last_tool_name: "Bash", + uuid: "uuid-task-progress", + session_id: "sess-1", +}; +// A subagent's own background Bash job completing. +const subagentOwnedTaskNotification = { + type: "system", + subtype: "task_notification", + task_id: "bg-job-of-subagent", + tool_use_id: "toolu_subagent_bash", + status: "completed", + summary: "Background command completed", + output_file: "/tmp/bg-job-of-subagent.output", + uuid: "uuid-task-notification", + session_id: "sess-1", +}; +const helperCompleted = { + type: "system", + subtype: "task_notification", + task_id: "helper-task", + tool_use_id: "toolu_helper", + status: "completed", + summary: "Background helper finished", + output_file: "/tmp/helper-task.output", + uuid: "uuid-helper-completed", + session_id: "sess-1", +}; +const rateLimitEvent = { + type: "rate_limit_event", + rate_limit_info: { status: "allowed" }, + uuid: "uuid-rate-limit", + session_id: "sess-1", +}; + +function createPendingQueryMock(): QueryMock { + return createBaseQueryMock(vi.fn(() => new Promise<never>(() => {}))); +} + +test("a main-session frame opens no turn until Claude starts one", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Main-session output only ever arrives inside a turn Claude opened with system/init, so a + // stray unbound frame is not the start of one. + await internal.routeSdkMessageFromPump(unboundTextDelta("stray"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + // The wake-up case autonomous turns exist for: a completed background subagent makes the main + // session start a turn of its own, with no prompt from the user. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + +test("a background subagent's frames never open a turn while the main session is idle", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Claude Code 2.1.280 reports "running" for as long as any background subagent is alive. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + for (const frame of [ + helperTaskStarted, + helperTaskProgress, + subagentOwnedTaskNotification, + rateLimitEvent, + helperCompleted, + ]) { + await internal.routeSdkMessageFromPump(frame, queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + + // The next user message starts its own foreground turn rather than landing on a phantom one. + const { turnId } = await session.startTurn("PING"); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await session.close(); +}); + +test("Claude waking up after a helper finishes gets an autonomous turn that its result closes", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // The helper's completion arrives outside any turn. Claude then starts one to react to it. + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(helperCompleted, queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + expect(internal.turnState).toBe("autonomous"); + + await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("a steer folded into a turn keeps one turn, and a turn Claude starts after it gets its own", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const { turnId } = await session.startTurn("hello"); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + // A message pushed mid-turn is folded in at the next tool boundary: Claude acknowledges it, but + // starts no new turn. + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "queued"), queryMock); + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "started"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + // A message that arrived while Claude wrote its final answer runs as a turn of its own. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2"); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(2); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(2); + + await session.close(); +}); + +test("session state never opens a turn", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // "running" covers background subagents too, so it says nothing about the main session. + for (const state of ["idle", "running", "requires_action", "idle"] as const) { + await internal.routeSdkMessageFromPump(sessionState(state), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + // State frames are bookkeeping, never timeline content. + expect(events).toEqual([]); + + await session.close(); +}); + +test("an idle session state closes an autonomous turn that no result closed", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // Safety net: Claude says nothing at all is running any more. + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("session state never opens or closes a foreground turn", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + const { turnId } = await session.startTurn("hello"); + + // The send's own running, then an idle that trails the previous turn and lands late. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await internal.routeSdkMessageFromPump( + { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }, + queryMock, + ); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + await session.close(); +}); + +interface InterruptInternals extends SessionStateInternals { + pendingInterruptAbort: boolean; +} + +type InterruptibleQueryMock = QueryMock & { cancelAsyncMessage: ReturnType<typeof vi.fn> }; + +function createInterruptibleQueryMock(): InterruptibleQueryMock { + return Object.assign(createPendingQueryMock(), { + cancelAsyncMessage: vi.fn(async () => true), + }); +} + +/** The uuid Paseo stamped on the first message it pushed into the SDK input. */ +async function readFirstPromptUuid(): Promise<string | null> { + const input = sdkQueryFactory.mock.calls[0]?.[0] as { prompt: AsyncIterable<unknown> }; + return await createPromptUuidReader(input.prompt)(); +} + +function abortedResult() { + return { + type: "result", + subtype: "error_during_execution", + errors: [], + usage: buildUsage(), + total_cost_usd: 0, + }; +} + +test("an interrupt before Claude starts the message withdraws it instead of interrupting", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("hello"); + // Claude has not acknowledged the message yet: no init, no command_lifecycle "started". + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(queryMock.interrupt).not.toHaveBeenCalled(); + // No result follows an interrupt that never happened, so no flag may wait for one. + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_canceled")).toHaveLength(1); + + await session.close(); +}); + +test("an interrupt after Claude took the message but before it started stops it", async () => { + const queryMock = createInterruptibleQueryMock(); + // Claude has dequeued the message for its next turn, so withdrawing it is a no-op. + queryMock.cancelAsyncMessage.mockImplementation(async () => false); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("hello"); + // No command_lifecycle "started" or init has reached Paseo yet. + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(internal.pendingInterruptAbort).toBe(true); + + await session.close(); +}); + +test("a prompt sent while a stopped prompt is being withdrawn is not withdrawn too", async () => { + const queryMock = createInterruptibleQueryMock(); + let settleWithdrawal: (cancelled: boolean) => void = () => {}; + queryMock.cancelAsyncMessage.mockImplementationOnce( + () => new Promise<boolean>((resolve) => (settleWithdrawal = resolve)), + ); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + + await session.startTurn("first"); + const firstUuid = await readFirstPromptUuid(); + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + + await session.startTurn("second"); + settleWithdrawal(false); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(firstUuid); + + await session.close(); +}); + +test("an interrupt while only background subagents run never reaches Claude", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("delegate to a helper"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + // The main session is done; its helper keeps working. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(helperTaskProgress, queryMock); + + // Without perTaskStopAffordance, an interrupt here kills every running helper. + await session.interrupt(); + + expect(queryMock.interrupt).not.toHaveBeenCalled(); + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(false); + + await session.close(); +}); + +test("an interrupt during a main turn interrupts Claude once and drops the aborted result", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("long task"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + // Claude already started the message, so there is nothing to withdraw. + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(true); + + // A replacement starts before the aborted turn reports its result. + const { turnId } = await session.startTurn("replacement"); + await internal.routeSdkMessageFromPump(abortedResult(), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(0); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(queryMock.interrupt).toHaveBeenCalledTimes(1); + + await session.close(); +}); + +test("a new turn clears an interrupt flag no result will consume", async () => { + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // An interrupt that landed just after its turn had ended: Claude emits no result for it. + internal.pendingInterruptAbort = true; + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.pendingInterruptAbort).toBe(false); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // This turn's own failure is reported, not swallowed as the interrupted turn's leftover. + await internal.routeSdkMessageFromPump( + { ...abortedResult(), errors: ["API Error: 500 upstream failure"] }, + queryMock, + ); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(1); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + test("tracks run lifecycle transitions for success, error, and interrupt", async () => { const session = await createSession(); let streamCase: "success" | "error" | "interrupt" = "success"; diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 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 29c00b1ed7..41c16d4e45 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -432,6 +432,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 } }, @@ -444,6 +452,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]", @@ -478,6 +487,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"); }, @@ -501,8 +518,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({ @@ -515,12 +540,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"); @@ -1015,7 +1057,7 @@ describe("ClaudeAgentSession features", () => { expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ effort: "xhigh", - thinking: { type: "adaptive" }, + thinking: { type: "adaptive", display: "summarized" }, settings: { ultracode: true }, }); @@ -1229,7 +1271,9 @@ describe("ClaudeAgentSession features", () => { test.each([ ["supported model", "claude-opus-4-8", { type: "disabled" }, undefined], - ["unsupported model", "claude-fable-5", { type: "adaptive" }, "high"], + ["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], ])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => { @@ -2668,13 +2712,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", }; @@ -3336,6 +3382,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 150, }, }), @@ -3371,7 +3418,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]" }, ); @@ -3411,6 +3464,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 175, }, }), @@ -3420,6 +3474,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([ [ @@ -3444,6 +3613,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 55, }, }), @@ -3453,6 +3623,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 62, }, }), @@ -3490,6 +3661,7 @@ describe("ClaudeAgentSession context window usage", () => { type: "usage_updated", provider: "claude", usage: { + contextWindowMaxTokens: 200_000, contextWindowUsedTokens: 704, }, }), @@ -3646,6 +3818,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. @@ -3699,10 +3909,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 84495ab65e..553b01322b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -33,7 +33,7 @@ import { readTaskNotificationToolUseIdFromHistoryRecord, } from "./task-notification-tool-call.js"; import { - findClaudeModel, + getClaudeContextWindowMaxTokens, getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId, resolveConfiguredClaudeModel, @@ -46,6 +46,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, @@ -77,6 +78,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, @@ -85,7 +87,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, @@ -132,6 +134,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"; @@ -202,24 +205,26 @@ function stripClaudeAskUserQuestionUiMetadata(input: AgentMetadata): AgentMetada }; } -export function normalizeClaudeAskUserQuestionUpdatedInput( +/** + * The answers this response will actually deliver, keyed by full question text, + * or null when there is no question list and answers map to work from. + * + * Shared by the normalizer and by the check that rejects an undeliverable + * answer, so the two can never disagree about what "deliverable" means. + */ +function resolveClaudeAskUserQuestionAnswers( updatedInput: AgentMetadata | undefined, fallbackInput: AgentMetadata | undefined, -): AgentMetadata { +): Record<string, string> | null { const fallback = isMetadata(fallbackInput) ? fallbackInput : {}; const base = isMetadata(updatedInput) ? updatedInput : {}; - // Paseo's shared question UI serializes answers by question header, but Claude's - // AskUserQuestion tool expects answer keys to match the full question text. Merge - // the original request payload back in so provider callbacks that only return - // `{ answers }` still satisfy Claude's full tool input schema. - const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base }); const questions = (Array.isArray(base.questions) ? base.questions : null) ?? (Array.isArray(fallback.questions) ? fallback.questions : null); const answers = isMetadata(base.answers) ? base.answers : null; if (!questions || !answers) { - return merged; + return null; } const normalizedAnswers: Record<string, string> = {}; @@ -243,7 +248,23 @@ export function normalizeClaudeAskUserQuestionUpdatedInput( } } - if (Object.keys(normalizedAnswers).length === 0) { + return normalizedAnswers; +} + +export function normalizeClaudeAskUserQuestionUpdatedInput( + updatedInput: AgentMetadata | undefined, + fallbackInput: AgentMetadata | undefined, +): AgentMetadata { + const fallback = isMetadata(fallbackInput) ? fallbackInput : {}; + const base = isMetadata(updatedInput) ? updatedInput : {}; + // Paseo's shared question UI serializes answers by question header, but Claude's + // AskUserQuestion tool expects answer keys to match the full question text. Merge + // the original request payload back in so provider callbacks that only return + // `{ answers }` still satisfy Claude's full tool input schema. + const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base }); + const normalizedAnswers = resolveClaudeAskUserQuestionAnswers(updatedInput, fallbackInput); + + if (!normalizedAnswers || Object.keys(normalizedAnswers).length === 0) { return merged; } @@ -253,6 +274,32 @@ export function normalizeClaudeAskUserQuestionUpdatedInput( }; } +class ClaudeQuestionAnswerRequiredError extends Error { + readonly code = "question_answer_required" as const; + + constructor(readonly requestId: string) { + super( + `Permission request '${requestId}' requires updatedInput.answers keyed by a ` + + `question's full text or header, with a non-empty string value. ` + + `The request is still pending; retry with that shape.`, + ); + this.name = "ClaudeQuestionAnswerRequiredError"; + } +} + +function assertClaudeQuestionAnswerDeliverable( + request: AgentPermissionRequest, + response: AgentPermissionResponse, +): void { + if (request.kind !== "question" || response.behavior !== "allow") { + return; + } + const answers = resolveClaudeAskUserQuestionAnswers(response.updatedInput, request.input); + if (!answers || Object.keys(answers).length === 0) { + throw new ClaudeQuestionAnswerRequiredError(request.id); + } +} + function isObjectRecord(value: unknown): value is Record<string, unknown> { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -297,6 +344,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 { @@ -405,6 +459,7 @@ export interface ClaudeContentChunk { } interface ClaudeAgentClientOptions { + discoverModels?: typeof discoverClaudeModels; defaults?: { agents?: Record<string, AgentDefinition> }; logger: Logger; runtimeSettings?: ProviderRuntimeSettings; @@ -449,6 +504,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, @@ -1502,6 +1566,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; @@ -1523,6 +1588,7 @@ export class ClaudeAgentClient implements AgentClient { }); 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; @@ -1605,8 +1671,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 { @@ -1708,11 +1801,21 @@ export class ClaudeAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + // Carry a `/rename` onto the imported agent so the name chosen in the CLI + // survives the import. Only an explicit rename counts: agents are named + // after their first prompt line since #1563, so adopting the generated + // `ai-title` here would put that LLM summary back. + const renamedTitle = await readClaudeRenamedSessionTitle({ + cwd: input.cwd, + sessionId: input.providerHandleId, + configDir: claudeConfigDir(this.buildProviderEnv(context.launchContext?.env)), + }); return importSessionFromPersistence({ provider: "claude", request: input, context, resumeSession: this.resumeSession.bind(this), + ...(renamedTitle ? { config: { title: renamedTitle } } : {}), }); } @@ -1844,56 +1947,77 @@ async function resolveClaudeAuth( } } -function extractContextWindowSize(modelUsage: unknown): number | undefined { +function extractContextWindowSize( + modelUsage: unknown, + modelId: string | null | undefined, +): number | undefined { const usageRecord = toObjectRecord(modelUsage); if (!usageRecord) { return undefined; } - let maxContextWindow: number | undefined; - for (const value of Object.values(usageRecord)) { - const valueRecord = toObjectRecord(value); - if (!valueRecord) { - continue; - } - const contextWindow = valueRecord.contextWindow; - if ( - typeof contextWindow !== "number" || - !Number.isFinite(contextWindow) || - contextWindow <= 0 - ) { - continue; - } - maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow); - } - - return maxContextWindow; + // modelUsage accumulates every model used in the session, including earlier turns + // and subagents. Only the active model describes this turn's context capacity. + const modelUsageEntry = modelId ? usageRecord[modelId] : undefined; + const contextWindow = toObjectRecord(modelUsageEntry)?.contextWindow; + return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + ? contextWindow + : undefined; } -function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined { - const messageUsage = toObjectRecord(toObjectRecord(event.message)?.usage); - if (!messageUsage) { +interface StreamRequestInputUsage { + inputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; +} + +function readUsageCount(usage: Record<string, unknown>, key: string): number | undefined { + const value = usage[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { return undefined; } - const usage = messageUsage; - const inputTokens = - typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens) - ? usage.input_tokens - : undefined; - const cacheCreationInputTokens = - typeof usage.cache_creation_input_tokens === "number" && - Number.isFinite(usage.cache_creation_input_tokens) - ? usage.cache_creation_input_tokens - : 0; - const cacheReadInputTokens = - typeof usage.cache_read_input_tokens === "number" && - Number.isFinite(usage.cache_read_input_tokens) - ? usage.cache_read_input_tokens - : 0; - if (typeof inputTokens !== "number" || inputTokens < 0) { + return value; +} + +function readStreamRequestInputUsage( + event: Record<string, unknown>, +): StreamRequestInputUsage | undefined { + const usage = toObjectRecord(toObjectRecord(event.message)?.usage); + if (!usage) { return undefined; } - return inputTokens + cacheCreationInputTokens + cacheReadInputTokens; + const inputTokens = readUsageCount(usage, "input_tokens"); + if (inputTokens === undefined) { + return undefined; + } + return { + inputTokens, + cacheCreationInputTokens: readUsageCount(usage, "cache_creation_input_tokens") ?? 0, + cacheReadInputTokens: readUsageCount(usage, "cache_read_input_tokens") ?? 0, + }; +} + +/** + * The input counters on `message_delta.usage` are cumulative whole-request totals and are + * optional. Mirror the Anthropic SDK's MessageStream: a counter present on the delta replaces + * the `message_start` value, a missing counter keeps it. Translating gateways report + * `input_tokens: 0` on `message_start` and only know the real count by `message_delta`. + */ +function mergeStreamDeltaInputUsage( + current: StreamRequestInputUsage, + event: Record<string, unknown>, +): StreamRequestInputUsage { + const usage = toObjectRecord(event.usage); + if (!usage) { + return current; + } + return { + inputTokens: readUsageCount(usage, "input_tokens") ?? current.inputTokens, + cacheCreationInputTokens: + readUsageCount(usage, "cache_creation_input_tokens") ?? current.cacheCreationInputTokens, + cacheReadInputTokens: + readUsageCount(usage, "cache_read_input_tokens") ?? current.cacheReadInputTokens, + }; } function readStreamRequestOutputTokens(event: Record<string, unknown>): number | undefined { @@ -1962,13 +2086,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; @@ -1978,7 +2107,7 @@ class ClaudeContextUsageState { } beginTurn(): void { - this.streamRequestInputTokens = undefined; + this.streamRequestInputUsage = undefined; this.streamRequestOutputTokens = undefined; this.compactedContextWindowUsedTokens = undefined; } @@ -1987,8 +2116,8 @@ class ClaudeContextUsageState { this.contextWindowMaxTokens = contextWindowMaxTokens; } - recordModelUsage(modelUsage: unknown): number | undefined { - const contextWindowMaxTokens = extractContextWindowSize(modelUsage); + recordModelUsage(modelUsage: unknown, modelId: string | null | undefined): number | undefined { + const contextWindowMaxTokens = extractContextWindowSize(modelUsage, modelId); if (contextWindowMaxTokens !== undefined) { this.contextWindowMaxTokens = contextWindowMaxTokens; } @@ -2002,11 +2131,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); @@ -2014,6 +2143,12 @@ class ClaudeContextUsageState { return null; } this.streamRequestOutputTokens = outputTokens; + if (this.streamRequestInputUsage) { + this.streamRequestInputUsage = mergeStreamDeltaInputUsage( + this.streamRequestInputUsage, + streamEvent, + ); + } } else { return null; } @@ -2025,7 +2160,10 @@ class ClaudeContextUsageState { return this.createUsageUpdatedEvent(usedTokens); } - buildResultUsage(message: SDKResultMessage, modelUsage: unknown): AgentUsage | undefined { + buildResultUsage( + message: SDKResultMessage, + modelId: string | null | undefined, + ): AgentUsage | undefined { try { if (!message.usage) { return undefined; @@ -2037,7 +2175,7 @@ class ClaudeContextUsageState { totalCostUsd: message.total_cost_usd, }; - const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage); + const modelContextWindowMaxTokens = this.recordModelUsage(message.modelUsage, modelId); if (this.contextWindowMaxTokens !== undefined) { usage.contextWindowMaxTokens = this.contextWindowMaxTokens; } else if (modelContextWindowMaxTokens !== undefined) { @@ -2060,13 +2198,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; } @@ -2085,7 +2225,7 @@ class ClaudeContextUsageState { } buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent { - this.streamRequestInputTokens = undefined; + this.streamRequestInputUsage = undefined; this.streamRequestOutputTokens = undefined; this.compactedContextWindowUsedTokens = postTokens; const usage: AgentUsage = {}; @@ -2117,17 +2257,20 @@ class ClaudeAgentSession implements AgentSession { private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise<string>; private query: Query | null = null; + private readonly harnessEnvironment: Record<string, string>; + private readonly usageSessionKey = randomUUID(); private childProcess: ChildProcess | null = null; private input: AsyncMessageInput<SDKUserMessage> | null = null; /** The exact SDK query/input pair that owns the current foreground turn. */ 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; @@ -2138,6 +2281,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; @@ -2154,7 +2298,11 @@ class ClaudeAgentSession implements AgentSession { // identity and status from frames for them. Detecting the capability beats comparing version // strings: it reacts to what this session actually does. isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive, + // The parent's tool call owns its card once its result arrives. A background child streams + // its frames after Claude has already answered the call, and re-emitting the card from them + // would replace the settled card with an unlabeled, running one. needsSyntheticParentToolCard: (toolUseId) => + this.toolUseCache.has(toolUseId) && this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId), }); private persistedHistory: PersistedTimelineEntry[] = []; @@ -2174,6 +2322,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; @@ -2192,13 +2346,14 @@ class ClaudeAgentSession implements AgentSession { this.agentId = options.agentId; this.defaults = options.defaults; this.runtimeSettings = options.runtimeSettings; + this.harnessEnvironment = this.buildSdkEnv(); this.persistSession = options.persistSession; this.logger = options.logger.child({ agentId: this.agentId }); this.queryFactory = options.queryFactory; 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; @@ -2319,6 +2474,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 = () => { @@ -2329,13 +2485,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"); }); }; @@ -2345,6 +2506,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"); } @@ -2358,6 +2524,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"), ); @@ -2406,7 +2573,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); } @@ -2417,7 +2584,7 @@ class ClaudeAgentSession implements AgentSession { } } catch (error) { if (uuid) { - this.queuedSteerUuids.delete(uuid); + this.unstartedMessageUuids.delete(uuid); this.permissionClearingSteerUuids.delete(uuid); } throw error; @@ -2486,10 +2653,17 @@ class ClaudeAgentSession implements AgentSession { } const normalized = isPermissionMode(modeId) ? modeId : "default"; - assertClaudeModeCanRun(normalized, this.buildSdkEnv()); + 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; @@ -2511,7 +2685,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; @@ -2668,6 +2842,12 @@ class ClaudeAgentSession implements AgentSession { if (!pending) { throw new Error(`No pending permission request with id '${requestId}'`); } + + // Before the delete below, so a rejected answer leaves the request pending + // and answerable rather than consuming it for nothing. AgentManager only + // drops its own copy once this resolves, so both maps survive the throw. + assertClaudeQuestionAnswerDeliverable(pending.request, response); + this.pendingPermissions.delete(requestId); pending.cleanup?.(); @@ -2837,9 +3017,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) => { @@ -3175,7 +3357,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; } @@ -3219,9 +3401,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 }, { @@ -3305,10 +3489,18 @@ class ClaudeAgentSession implements AgentSession { return { thinking: { type: "disabled" }, effort: undefined, ultracode: false }; } if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) { - return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true }; + return { + thinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + ultracode: true, + }; } if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) { - return { thinking: { type: "adaptive" }, effort: thinkingOptionId, ultracode: false }; + return { + thinking: { type: "adaptive", display: "summarized" }, + effort: thinkingOptionId, + ultracode: false, + }; } return { thinking: undefined, effort: undefined, ultracode: false }; } @@ -3319,15 +3511,30 @@ class ClaudeAgentSession implements AgentSession { ); } - private buildSdkEnv(): NodeJS.ProcessEnv { + usageSession() { + if (this.closed) return null; + return { + provider: "claude", + model: this.config.model, + env: this.harnessEnvironment, + sessionKey: this.usageSessionKey, + }; + } + + private buildSdkEnv() { 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( @@ -3335,8 +3542,8 @@ class ClaudeAgentSession implements AgentSession { this.config.toolPolicy, ); const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode }); - const sdkEnv = this.buildSdkEnv(); - assertClaudeModeCanRun(this.currentMode, sdkEnv); + const sdkEnv = this.harnessEnvironment; + assertClaudeModeCanRun(permissionMode, sdkEnv); const claudeBinary = await this.resolveBinary(); this.logger.debug( @@ -3359,7 +3566,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. @@ -3390,6 +3597,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, @@ -3920,35 +4131,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") { @@ -4090,7 +4333,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( @@ -4104,8 +4353,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(), @@ -4117,14 +4374,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 = ( @@ -4132,14 +4390,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( @@ -4188,7 +4449,7 @@ class ClaudeAgentSession implements AgentSession { } } - this.forgetReadSteer(message); + this.forgetStartedMessage(message); switch (message.type) { case "system": @@ -4222,11 +4483,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); } @@ -4288,16 +4552,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) }, }; } @@ -4360,6 +4615,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; @@ -4547,7 +4815,7 @@ class ClaudeAgentSession implements AgentSession { message: Extract<SDKMessage, { type: "result" }>, events: AgentStreamEvent[], ): void { - const usage = this.convertUsage(message, message.modelUsage); + const usage = this.convertUsage(message); if (message.subtype === "success") { events.push(...this.sidechainTracker.finishAll("completed")); // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") @@ -4671,12 +4939,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 }, @@ -4693,6 +4964,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; @@ -4716,8 +4997,8 @@ class ClaudeAgentSession implements AgentSession { return null; } - private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined { - return this.contextUsage.buildResultUsage(message, modelUsage); + private convertUsage(message: SDKResultMessage): AgentUsage | undefined { + return this.contextUsage.buildResultUsage(message, this.lastRuntimeModel ?? this.config.model); } private handlePermissionRequest: CanUseTool = async ( @@ -4996,6 +5277,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. @@ -5006,7 +5288,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 = [ @@ -5035,6 +5317,7 @@ class ClaudeAgentSession implements AgentSession { const replay = { restoredIds: restoredProviderSubagentIds, toolOwners: subagentReplay.toolOwners, + subagentToolCalls: parentFacts.toolCalls, }; if (observations.length === 0) return replay; @@ -5101,7 +5384,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" && @@ -5128,26 +5416,11 @@ class ClaudeAgentSession implements AgentSession { private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; - const configDir = claudeConfigDir(this.buildSdkEnv()); - 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[] { @@ -5197,7 +5470,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, { @@ -5285,6 +5558,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) { @@ -5851,6 +6142,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 @@ -6501,6 +6819,66 @@ function normalizeClaudeSessionTitle(title: string | null): string | null { return normalized ? normalized : null; } +const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title"; + +/** + * The `custom-title` record is re-emitted every time the session is saved, so + * the current name is the last usable one in the file. Searching backwards finds it without + * walking the transcript, which matters because sessions reach tens of + * megabytes. + */ +function readLastClaudeCustomTitle(content: string): string | null { + const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`; + let at = content.lastIndexOf(needle); + while (at >= 0) { + const lineStart = content.lastIndexOf("\n", at) + 1; + const lineEnd = content.indexOf("\n", at); + const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd); + let parsed: unknown; + try { + parsed = JSON.parse(line.trim()); + } catch { + parsed = undefined; + } + const entry = toObjectRecord(parsed); + if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) { + const title = normalizeClaudeSessionTitle( + typeof entry.customTitle === "string" ? entry.customTitle : null, + ); + if (title) { + return title; + } + } + // The needle also matches a prompt that quotes it, and a record can carry no + // usable name, so keep looking backwards for one that does. + at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1); + } + return null; +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +async function readClaudeRenamedSessionTitle(input: { + cwd: string; + sessionId: string; + configDir: string; +}): Promise<string | null> { + let content: string; + try { + content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8"); + } catch (error) { + // A session with no transcript on disk has no rename to carry over. Every + // other read failure is a real fault and stays explicit. + if (isErrnoException(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + return readLastClaudeCustomTitle(content); +} + function normalizeImportablePromptPreview(text: string): string | null { const normalized = text.trim().replace(/\s+/g, " "); if (!normalized) return null; @@ -6570,7 +6948,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. */ @@ -6579,7 +6958,7 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle | if (record?.type !== "command_lifecycle") return null; if ( typeof record.command_uuid !== "string" || - !["queued", "started", "completed"].includes(String(record.state)) + !["queued", "started", "completed", "cancelled"].includes(String(record.state)) ) { return null; } diff --git a/packages/server/src/server/agent/providers/claude/model-discovery.ts b/packages/server/src/server/agent/providers/claude/model-discovery.ts new file mode 100644 index 0000000000..d74898472b --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/model-discovery.ts @@ -0,0 +1,89 @@ +import type { ChildProcess } from "node:child_process"; +import os from "node:os"; +import type { ModelInfo, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { raceProviderRefreshAbort } from "../../provider-refresh-deadline.js"; +import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import { claudeQuery, type ClaudeQueryInput, type ClaudeQueryContext } from "./query.js"; + +interface ClaudeModelDiscoveryOptions { + resolveBinary: () => Promise<string>; + runtimeSettings?: ProviderRuntimeSettings; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; +} + +type OpenDiscoveryQuery = ( + input: ClaudeQueryInput, + context: ClaudeQueryContext, +) => Pick<Query, "supportedModels" | "close">; + +// The curated catalog survives a failed or slow probe. This process never receives +// a prompt and must not persist a session or launch user MCP servers/hooks. +export async function discoverClaudeModels( + options: ClaudeModelDiscoveryOptions, + openQuery: OpenDiscoveryQuery = claudeQuery, +): Promise<ModelInfo[]> { + const controller = new AbortController(); + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal; + const timer = setTimeout( + () => controller.abort(new Error("Claude model discovery timed out")), + 5_000, + ); + let endInput!: () => void; + const ended = new Promise<void>((resolve) => { + endInput = resolve; + }); + const input: AsyncIterable<SDKUserMessage> = { + [Symbol.asyncIterator]() { + return { + next: async () => { + await ended; + return { done: true, value: undefined }; + }, + }; + }, + }; + let query: ReturnType<OpenDiscoveryQuery> | undefined; + let child: ChildProcess | undefined; + try { + signal.throwIfAborted(); + const binary = await raceProviderRefreshAbort(signal, options.resolveBinary()); + query = openQuery( + { + prompt: input, + options: { + pathToClaudeCodeExecutable: binary, + cwd: os.homedir(), + env: options.env, + persistSession: false, + tools: [], + mcpServers: {}, + strictMcpConfig: true, + settingSources: ["user"], + settings: { disableAllHooks: true }, + abortController: controller, + }, + }, + { + runtimeSettings: options.runtimeSettings, + onChildProcess: (process) => { + child = process; + }, + }, + ); + return await raceProviderRefreshAbort(signal, query.supportedModels()); + } finally { + clearTimeout(timer); + endInput(); + try { + query?.close(); + } finally { + if (child) { + await terminateWithTreeKill(child, { gracefulTimeoutMs: 200, forceTimeoutMs: 200 }); + } + } + } +} diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index e2e64f28fd..45a5441109 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -9,6 +9,7 @@ interface ClaudeModelManifestEntry { description: string; defaultPriority?: number; minimumClaudeCodeVersion?: string; + requiresDiscovery?: boolean; contextWindowMaxTokens?: number; effortLevels?: readonly ClaudeEffortLevel[]; defaultThinkingOptionId?: ClaudeEffortLevel; @@ -57,6 +58,14 @@ export const CLAUDE_MODEL_MANIFEST = [ supportsThinkingDisabled: true, supportsFastMode: true, }, + { + id: "claude-mythos-5-1", + label: "Mythos 5.1", + requiresDiscovery: true, + description: "Mythos 5.1 · Requires verified access", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + }, { id: "claude-fable-5-1", label: "Fable 5.1", @@ -170,10 +179,20 @@ export const CLAUDE_MODEL_MANIFEST = [ effortLevels: CLAUDE_EFFORT_LEVELS.standard, supportsThinkingDisabled: true, }, + { + id: "claude-haiku-5-5", + label: "Haiku 5.5", + description: "Haiku 5.5 · Fastest for quick answers", + minimumClaudeCodeVersion: "2.1.293", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsThinkingDisabled: true, + }, { id: "claude-haiku-4-5", label: "Haiku 4.5", - description: "Haiku 4.5 · Fastest for quick answers", + description: "Haiku 4.5 · Previous release", contextWindowMaxTokens: 200_000, }, ] as const satisfies readonly ClaudeModelManifestEntry[]; @@ -207,9 +226,16 @@ function buildThinkingOptions( return options; } -export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelDefinition[] { +export function getClaudeManifestModels( + claudeCodeVersion?: string, + discoveredModelIds?: ReadonlySet<string>, +): AgentModelDefinition[] { const availableModels: readonly ClaudeModelManifestEntry[] = CLAUDE_MODEL_MANIFEST.filter( - (model) => isModelAvailableInClaudeCode(model, claudeCodeVersion), + (model) => + isModelAvailableInClaudeCode(model, claudeCodeVersion) && + (discoveredModelIds === undefined || + !("requiresDiscovery" in model && model.requiresDiscovery) || + discoveredModelIds.has(model.id)), ); const defaultModel = availableModels.reduce<ClaudeModelManifestEntry | undefined>( (selected, candidate) => @@ -349,7 +375,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const singleSegmentMatch = trimmed.match( - /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, + /^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (singleSegmentMatch) { return normalizeSingleSegmentClaudeModelId( @@ -360,7 +386,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const runtimeMatch = trimmed.match( - /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, + /^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (!runtimeMatch) { return null; @@ -393,7 +419,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): } const runtimeMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, + /claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, ); if (runtimeMatch) { const normalizedModelId = normalizeMajorMinorClaudeModelId( @@ -408,7 +434,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): } const singleSegmentMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, + /claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, ); if (!singleSegmentMatch) { return null; diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 0e187734b8..16d13b9d68 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -13,7 +13,29 @@ import { parseClaudeCodeVersion, resolveClaudeDisabledThinkingForModel, } from "./model-manifest.js"; -import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; +import { discoverClaudeModels } from "./model-discovery.js"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; +import { + findClaudeModel, + getClaudeContextWindowMaxTokens, + getClaudeModels, + normalizeClaudeRuntimeModelId, +} from "./models.js"; + +describe("getClaudeContextWindowMaxTokens", () => { + it.each([ + ["glm-5.2[1m]", 1_000_000], + [" glm-5.2[1M] ", 1_000_000], + ["claude-sonnet-4-6", 200_000], + ["claude-sonnet-4-6[1m]", 1_000_000], + ["glm-5.2", undefined], + ["glm-5.2[1m]-other", undefined], + [undefined, undefined], + [null, undefined], + ])("resolves %s to %s without guessing unknown models", (model, expected) => { + expect(getClaudeContextWindowMaxTokens(model)).toBe(expected); + }); +}); const createdClaudeConfigDirs: string[] = []; @@ -39,10 +61,18 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s return configDir; } -function createCatalogClient(claudeCodeVersion = "2.1.284"): ClaudeAgentClient { +function createCatalogClient(claudeCodeVersion = "2.1.293"): ClaudeAgentClient { return new ClaudeAgentClient({ logger: createTestLogger(), resolveVersion: async () => claudeCodeVersion, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ], }); } @@ -52,6 +82,7 @@ describe("getClaudeModels", () => { expect(models.map((m) => m.id)).toEqual([ "claude-opus-5-5", "claude-opus-5", + "claude-mythos-5-1", "claude-fable-5-1", "claude-fable-5", "claude-fable-5[1m]", @@ -66,6 +97,7 @@ describe("getClaudeModels", () => { "claude-opus-4-6", "claude-sonnet-4-6[1m]", "claude-sonnet-4-6", + "claude-haiku-5-5", "claude-haiku-4-5", ]); }); @@ -86,6 +118,7 @@ describe("getClaudeModels", () => { new Map([ ["claude-opus-5-5", 1_000_000], ["claude-opus-5", 1_000_000], + ["claude-mythos-5-1", 1_000_000], ["claude-fable-5-1", 1_000_000], ["claude-fable-5", 1_000_000], ["claude-fable-5[1m]", 1_000_000], @@ -100,6 +133,7 @@ describe("getClaudeModels", () => { ["claude-opus-4-6", 200_000], ["claude-sonnet-4-6[1m]", 1_000_000], ["claude-sonnet-4-6", 200_000], + ["claude-haiku-5-5", 1_000_000], ["claude-haiku-4-5", 200_000], ]), ); @@ -123,6 +157,12 @@ describe("getClaudeModels", () => { expect(getClaudeModels("2.1.284").map((model) => model.id)).toContain("claude-sonnet-5-5"); }); + it("gates Haiku 5.5 on Claude Code 2.1.293 without changing the default", () => { + expect(getClaudeModels("2.1.292").map((model) => model.id)).not.toContain("claude-haiku-5-5"); + expect(getClaudeModels("2.1.293").map((model) => model.id)).toContain("claude-haiku-5-5"); + expect(getClaudeModels("2.1.293").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); + }); + it("derives thinking options from model effort capabilities", () => { const models = new Map(getClaudeModels().map((model) => [model.id, model])); @@ -192,6 +232,8 @@ describe("getClaudeModels", () => { ["claude-sonnet-5-20260101", true, "high"], ["claude-fable-5", false, "high"], ["claude-fable-5-1", false, "high"], + ["claude-haiku-5-5", true, "medium"], + ["claude-haiku-5-5-20261007", true, "medium"], ["claude-haiku-4-5", false, undefined], ["openrouter/anthropic/claude-opus-4-8", false, undefined], [null, false, undefined], @@ -211,6 +253,118 @@ describe("getClaudeModels", () => { }); describe("ClaudeAgentClient.fetchCatalog", () => { + it.each([ + { value: "mythos", resolvedModel: "claude-mythos-5-1", expected: true }, + { value: "claude-mythos-5-1", expected: true }, + { value: "claude-mythos-5-1[1m]", expected: true }, + { value: "global.anthropic.claude-mythos-5-1", expected: true }, + { value: "mythos", expected: false }, + { value: "mythos", resolvedModel: "claude-mythos-5-2", expected: false }, + { value: "claude-mythos-5-1", resolvedModel: "claude-fable-5-1", expected: false }, + ])( + "uses advertised identity $value / $resolvedModel to gate Mythos", + async ({ value, resolvedModel, expected }) => { + const configDir = await createClaudeConfigDir({}); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => [ + { value, resolvedModel, displayName: "Untrusted label", description: "" }, + ], + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual( + expected ? findClaudeModel("claude-mythos-5-1") : undefined, + ); + }, + ); + + it("preserves the curated catalog and explicitly configured models after discovery failure", async () => { + const configDir = await createClaudeConfigDir({ model: "claude-mythos-5-1" }); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => { + throw new Error("discovery unavailable"); + }, + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.filter((model) => model.id !== "claude-mythos-5-1")).toEqual( + getClaudeModels("2.1.293").filter((model) => model.id !== "claude-mythos-5-1"), + ); + expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual({ + ...findClaudeModel("claude-mythos-5-1"), + label: "claude-mythos-5-1", + description: "From Claude settings.json model", + }); + }); + + it("omits unconfirmed Mythos after a failed probe and retries on refresh", async () => { + const configDir = await createClaudeConfigDir({}); + let fail = true; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => { + if (fail) throw new Error("discovery unavailable"); + return [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ]; + }, + }); + const options = { scope: "workspace" as const, cwd: configDir, force: true }; + expect( + (await client.fetchCatalog(options)).models.some((model) => model.id === "claude-mythos-5-1"), + ).toBe(false); + fail = false; + expect( + (await client.fetchCatalog(options)).models.find((model) => model.id === "claude-mythos-5-1"), + ).toEqual(findClaudeModel("claude-mythos-5-1")); + }); + + it("keeps the curated catalog when discovery does not advertise Mythos", async () => { + const configDir = await createClaudeConfigDir({}); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => [ + { + value: "opus", + resolvedModel: "claude-opus-5-5", + displayName: "Opus", + description: "Latest", + }, + ], + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.map((model) => model.id)).toEqual( + getClaudeModels("2.1.293") + .filter((model) => model.id !== "claude-mythos-5-1") + .map((model) => model.id), + ); + }); + it("appends concrete models from Claude settings.json", async () => { const configDir = await createClaudeConfigDir({ model: "us.anthropic.claude-opus-4-7[1m]", @@ -288,6 +442,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { const client = new ClaudeAgentClient({ logger: createTestLogger(), resolveVersion: async () => "2.1.280", + discoverModels: async () => [], runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } }, }); @@ -653,6 +808,51 @@ describe("Claude Sonnet 5.5 catalog", () => { }); }); +describe("Claude Haiku 5.5 catalog", () => { + it("offers one Haiku 5.5 entry with a 1M context window", () => { + const haiku55Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-haiku-5-5")) + .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); + + expect(haiku55Models).toEqual([ + { id: "claude-haiku-5-5", label: "Haiku 5.5", contextWindowMaxTokens: 1_000_000 }, + ]); + }); + + it("offers Off and every effort level with medium as the default", () => { + const haiku55 = getClaudeModels().find((model) => model.id === "claude-haiku-5-5"); + + expect(haiku55?.thinkingOptions?.map((option) => option.id)).toEqual([ + CLAUDE_DISABLED_THINKING_OPTION_ID, + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(haiku55?.defaultThinkingOptionId).toBe("medium"); + expect( + haiku55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["medium"]); + }); + + it("normalizes provider-prefixed runtime IDs", () => { + expect(normalizeClaudeRuntimeModelId("anthropic/claude-haiku-5-5")).toBe("claude-haiku-5-5"); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-haiku-5-5-v1:0")).toBe( + "claude-haiku-5-5", + ); + expect(claudeManifestModelSupportsFastMode("claude-haiku-5-5")).toBe(false); + }); + + it("resolves suffixed and dated Haiku 5.5 IDs to the single catalog entry", () => { + expect(findClaudeModel("claude-haiku-5-5[1m]")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5-20261007")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5-20261007[1m]")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000); + }); +}); + describe("claudeManifestModelSupportsFastMode", () => { it("keeps fast mode strict to first-party manifest model IDs", () => { expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull(); @@ -669,3 +869,124 @@ describe("claudeManifestModelSupportsFastMode", () => { expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false); }); }); + +describe("Claude Mythos 5.1 catalog", () => { + it("offers one 1M model with high default effort and always-on thinking", () => { + const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos")); + expect(models).toHaveLength(1); + expect(models[0]).toMatchObject({ + id: "claude-mythos-5-1", + label: "Mythos 5.1", + contextWindowMaxTokens: 1_000_000, + defaultThinkingOptionId: "high", + }); + expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect( + models[0].thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["high"]); + expect(resolveClaudeDisabledThinkingForModel("claude-mythos-5-1")).toEqual({ + supported: false, + fallbackThinkingOptionId: "high", + }); + expect(claudeManifestModelSupportsFastMode("claude-mythos-5-1")).toBe(false); + expect(getClaudeModels().find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); + }); + + it.each([ + "claude-mythos-5-1[1m]", + "claude-mythos-5-1-20260901", + "claude-mythos-5-1-20260901[1m]", + "Mythos 5.1", + ])("resolves %s to the single catalog entry", (id) => { + expect(findClaudeModel(id)?.id).toBe("claude-mythos-5-1"); + }); + + it.each(["anthropic/claude-mythos-5-1", "us.anthropic.claude-mythos-5-1-v1:0"])( + "normalizes runtime identity without granting first-party capabilities: %s", + (id) => { + expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-mythos-5-1"); + expect(normalizeClaudeManifestModelId(id)).toBeNull(); + }, + ); +}); + +describe("Claude model discovery process", () => { + it("does not send a prompt or persist a session and closes after discovery", async () => { + let closed = false; + let promptEnded: Promise<IteratorResult<unknown>> | undefined; + const advertised: ModelInfo[] = [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ]; + const models = await discoverClaudeModels( + { resolveBinary: async () => "/claude", env: { CLAUDE_CONFIG_DIR: "/provider-config" } }, + (input) => { + expect(input.options).toMatchObject({ + pathToClaudeCodeExecutable: "/claude", + env: { CLAUDE_CONFIG_DIR: "/provider-config" }, + persistSession: false, + tools: [], + mcpServers: {}, + strictMcpConfig: true, + settingSources: ["user"], + settings: { disableAllHooks: true }, + }); + if (typeof input.prompt === "string") throw new Error("unexpected inference prompt"); + promptEnded = input.prompt[Symbol.asyncIterator]().next(); + return { + supportedModels: async () => advertised, + close: () => { + closed = true; + }, + }; + }, + ); + expect(models).toEqual(advertised); + expect(closed).toBe(true); + await expect(promptEnded).resolves.toEqual({ done: true, value: undefined }); + }); + + it("times out and closes an unresponsive discovery process", async () => { + let closed = false; + await expect( + discoverClaudeModels({ resolveBinary: async () => "/claude", env: {} }, () => ({ + supportedModels: () => new Promise(() => {}), + close: () => { + closed = true; + }, + })), + ).rejects.toThrow("Claude model discovery timed out"); + expect(closed).toBe(true); + }, 10_000); + + it("closes a stalled probe when its refresh is canceled", async () => { + const controller = new AbortController(); + const failure = new Error("refresh canceled"); + let closed = false; + const result = discoverClaudeModels( + { resolveBinary: async () => "/claude", env: {}, signal: controller.signal }, + () => ({ + supportedModels: () => { + controller.abort(failure); + return new Promise(() => {}); + }, + close: () => { + closed = true; + }, + }), + ); + await expect(result).rejects.toBe(failure); + expect(closed).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 6b341fa9ea..2a9b801fdb 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -48,12 +48,22 @@ export function findClaudeModel( return getClaudeModels().find((model) => model.id === normalizedModelId); } +export function getClaudeContextWindowMaxTokens( + modelId: string | null | undefined, +): number | undefined { + if (modelId?.trim().toLowerCase().endsWith("[1m]")) { + return 1_000_000; + } + return findClaudeModel(modelId)?.contextWindowMaxTokens; +} + export async function getClaudeModelsWithSettings( logger: Logger, configDir: string, claudeCodeVersion?: string, + discoveredModelIds: ReadonlySet<string> = new Set(), ): Promise<AgentModelDefinition[]> { - const hardcodedModels = getClaudeModels(claudeCodeVersion); + const hardcodedModels = getClaudeManifestModels(claudeCodeVersion, discoveredModelIds); const settingsModels = await readClaudeSettingsModels(logger, configDir); if (settingsModels.length === 0) { return hardcodedModels; @@ -70,7 +80,10 @@ export async function getClaudeModelsWithSettings( } continue; } - models.push(model); + const manifestModel = getClaudeModels(claudeCodeVersion).find( + (candidate) => candidate.id === model.id, + ); + models.push({ ...manifestModel, ...model }); } return models; diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts index ccac0841de..030a54c813 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -1,4 +1,4 @@ -import { realpathSync } from "node:fs"; +import { existsSync, readdirSync, realpathSync } from "node:fs"; import { realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -30,6 +30,46 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti return join(projectsRoot, encode(canonical)); } +// Claude Code resumes a session by id from any working directory and keeps appending to the +// transcript in the project folder where the session started. That folder differs from the +// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the +// cwd under another name (a Windows binary under WSL). Returns the expected path when no +// transcript exists anywhere. +export function claudeTranscriptPathSync(input: { + cwd: string; + sessionId: string; + configDir?: string; +}): string { + const options = { configDir: input.configDir }; + const fileName = `${input.sessionId}.jsonl`; + const expected = join(claudeProjectDirSync(input.cwd, options), fileName); + if (existsSync(expected)) { + return expected; + } + const projectsRoot = join(resolveConfigDir(options), "projects"); + let projectDirs: string[]; + try { + projectDirs = readdirSync(projectsRoot); + } catch (error) { + if (isMissingDirectoryError(error)) { + return expected; + } + throw error; + } + for (const projectDir of projectDirs) { + const candidate = join(projectsRoot, projectDir, fileName); + if (existsSync(candidate)) { + return candidate; + } + } + return expected; +} + +function isMissingDirectoryError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} + async function canonicalize(input: string): Promise<string> { try { return normalizeProjectPath(await realpath(input)); diff --git a/packages/server/src/server/agent/providers/claude/question-response.test.ts b/packages/server/src/server/agent/providers/claude/question-response.test.ts new file mode 100644 index 0000000000..26909faab7 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/question-response.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "vitest"; +import type { CanUseTool, Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { ClaudeAgentClient } from "./agent.js"; +import type { ClaudeQueryFactory } from "./query.js"; +import type { AgentPermissionResponse } from "../../agent-sdk-types.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; + +// The SDK dependency receives the same permission callback as a real Claude process. +// Keep its stream open until close; permission state belongs entirely to the session. +function createSdkFake() { + let canUseTool: CanUseTool | undefined; + let finish!: () => void; + const finished = new Promise<void>((resolve) => { + finish = resolve; + }); + const queryFactory: ClaudeQueryFactory = ({ options }) => { + canUseTool = options.canUseTool; + return { + close: finish, + return: async () => { + finish(); + return { value: undefined, done: true }; + }, + [Symbol.asyncIterator]() { + return { + next: async (): Promise<IteratorResult<SDKMessage, void>> => { + await finished; + return { value: undefined, done: true }; + }, + }; + }, + } as Query; + }; + return { + queryFactory, + ask: (input: Record<string, unknown>) => { + if (!canUseTool) throw new Error("SDK permission callback was not configured"); + return canUseTool("AskUserQuestion", input, { + signal: new AbortController().signal, + toolUseID: "question-tool-call", + }); + }, + }; +} + +const questionInput = { + questions: [{ question: "Which colour?", header: "Colour", options: [], multiSelect: false }], +}; + +const invalidResponses: Array<[string, AgentPermissionResponse]> = [ + ["selectedActionId", { behavior: "allow", selectedActionId: "Viridian" }], + ["missing answers", { behavior: "allow", updatedInput: {} }], + ["unknown question", { behavior: "allow", updatedInput: { answers: { Hue: "Viridian" } } }], + ["blank answer", { behavior: "allow", updatedInput: { answers: { Colour: " " } } }], + ["non-string answer", { behavior: "allow", updatedInput: { answers: { Colour: 1 } } }], + [ + "empty echoed questions", + { behavior: "allow", updatedInput: { questions: [], answers: { Colour: "Viridian" } } }, + ], +]; + +describe("Claude question permission responses", () => { + test.each(invalidResponses)( + "rejects %s and delivers a corrected retry", + async (_label, response) => { + const sdk = createSdkFake(); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdk.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + try { + await session.startTurn("Ask which colour"); + const answer = sdk.ask(questionInput); + void answer.catch(() => undefined); + const request = session.getPendingPermissions()[0]!; + expect(request.kind).toBe("question"); + await expect(session.respondToPermission(request.id, response)).rejects.toMatchObject({ + code: "question_answer_required", + requestId: request.id, + }); + expect(session.getPendingPermissions()).toEqual([request]); + await session.respondToPermission(request.id, { + behavior: "allow", + updatedInput: { answers: { Colour: "Viridian" } }, + }); + await expect(answer).resolves.toMatchObject({ + behavior: "allow", + updatedInput: { answers: { "Which colour?": "Viridian" } }, + }); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }, + ); + + test.each([ + ["header", questionInput, { Colour: "Viridian" }, { "Which colour?": "Viridian" }], + ["question text", questionInput, { "Which colour?": "Ochre" }, { "Which colour?": "Ochre" }], + [ + "padded question text", + { questions: [{ question: " Which colour? ", header: " Colour " }] }, + { " Which colour? ": "Ochre" }, + { " Which colour? ": "Ochre" }, + ], + [ + "padded header", + { questions: [{ question: " Which colour? ", header: " Colour " }] }, + { " Colour ": "Viridian" }, + { " Which colour? ": "Viridian" }, + ], + [ + "partial answer", + { questions: [...questionInput.questions, { question: "Ship it?", header: "Ship" }] }, + { Ship: "Yes" }, + { "Ship it?": "Yes" }, + ], + ])("delivers answers keyed by %s", async (_label, input, answers, expected) => { + const sdk = createSdkFake(); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdk.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + try { + await session.startTurn("Ask which colour"); + const answer = sdk.ask(input); + void answer.catch(() => undefined); + const request = session.getPendingPermissions()[0]!; + await session.respondToPermission(request.id, { + behavior: "allow", + updatedInput: { answers }, + }); + await expect(answer).resolves.toMatchObject({ + behavior: "allow", + updatedInput: { answers: expected }, + }); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); + + test("allows denying a question without answers", async () => { + const sdk = createSdkFake(); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdk.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + try { + await session.startTurn("Ask which colour"); + const answer = sdk.ask(questionInput); + void answer.catch(() => undefined); + const request = session.getPendingPermissions()[0]!; + await session.respondToPermission(request.id, { behavior: "deny", message: "No answer" }); + await expect(answer).resolves.toMatchObject({ behavior: "deny", message: "No answer" }); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/rewind.test.ts b/packages/server/src/server/agent/providers/claude/rewind.test.ts index 99243bb9c4..2d6d3853c2 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.test.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, test } from "vitest"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { revertClaudeConversation, revertClaudeConversationAndFiles, revertClaudeFiles, + realClaudeRewindSdk, } from "./rewind.js"; import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js"; @@ -16,6 +21,7 @@ describe("Claude rewind", () => { await revertClaudeConversation({ sdk: claude, sessionId, + transcriptPath: "/claude/projects/repo/original-session.jsonl", messageId: "user-message-1", setSessionId: (nextSessionId) => { sessionId = nextSessionId; @@ -33,6 +39,7 @@ describe("Claude rewind", () => { await revertClaudeConversation({ sdk: claude, sessionId, + transcriptPath: "/claude/projects/repo/original-session.jsonl", messageId: "timeline-message-1", resolveMessageId: () => "claude-jsonl-message-1", setSessionId: (nextSessionId) => { @@ -76,6 +83,7 @@ describe("Claude rewind", () => { sdk: claude, query: claude.createQuery() as Query, sessionId, + transcriptPath: "/claude/projects/repo/original-session.jsonl", messageId: "user-message-1", setSessionId: (nextSessionId) => { sessionId = nextSessionId; @@ -87,3 +95,126 @@ describe("Claude rewind", () => { expect(sessionId).toBe("forked-before-rehydrate"); }); }); + +interface TranscriptLine { + type: "user" | "assistant"; + uuid: string; + parentUuid: string | null; + text: string; +} + +function writeTranscript( + transcriptPath: string, + sessionId: string, + lines: Array<TranscriptLine | string>, +): void { + const entries = lines.map((line) => { + if (typeof line === "string") return line; + const { type, uuid, parentUuid, text } = line; + return JSON.stringify({ + type, + uuid, + parentUuid, + sessionId, + cwd: "/repo", + timestamp: new Date().toISOString(), + message: { role: type, content: text }, + }); + }); + writeFileSync(transcriptPath, `${entries.join("\n")}\n`, "utf8"); +} + +function readMessageTexts(transcriptPath: string): string[] { + const entries = readFileSync(transcriptPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { message?: { content: string } }); + return entries.flatMap((entry) => (entry.message ? [entry.message.content] : [])); +} + +describe("Claude rewind against the Claude SDK", () => { + let tempRoot: string | null = null; + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + + afterEach(() => { + if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = null; + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + }); + + test("forks a session that lives in a provider's own CLAUDE_CONFIG_DIR", async () => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-")); + // The provider sets CLAUDE_CONFIG_DIR for Claude Code; the daemon's own environment does not. + delete process.env.CLAUDE_CONFIG_DIR; + const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo"); + mkdirSync(projectDir, { recursive: true }); + const sessionId = randomUUID(); + const firstUser = randomUUID(); + const firstReply = randomUUID(); + const secondUser = randomUUID(); + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`); + writeTranscript(transcriptPath, sessionId, [ + { type: "user", uuid: firstUser, parentUuid: null, text: "first" }, + { type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" }, + { type: "user", uuid: secondUser, parentUuid: firstReply, text: "second" }, + { type: "assistant", uuid: randomUUID(), parentUuid: secondUser, text: "second reply" }, + ]); + let currentSessionId = sessionId; + + await revertClaudeConversation({ + sdk: realClaudeRewindSdk, + sessionId, + transcriptPath, + messageId: firstReply, + setSessionId: (nextSessionId) => { + currentSessionId = nextSessionId; + }, + }); + + expect(currentSessionId).not.toBe(sessionId); + expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([ + "first", + "first reply", + ]); + }); + + test("skips transcript lines that are not entries when forking", async () => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-")); + delete process.env.CLAUDE_CONFIG_DIR; + const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo"); + mkdirSync(projectDir, { recursive: true }); + const sessionId = randomUUID(); + const firstUser = randomUUID(); + const firstReply = randomUUID(); + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`); + writeTranscript(transcriptPath, sessionId, [ + "null", + { type: "user", uuid: firstUser, parentUuid: null, text: "first" }, + "[1, 2]", + '"text"', + "{}", + { type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" }, + { type: "user", uuid: randomUUID(), parentUuid: firstReply, text: "second" }, + ]); + let currentSessionId = sessionId; + + await revertClaudeConversation({ + sdk: realClaudeRewindSdk, + sessionId, + transcriptPath, + messageId: firstReply, + setSessionId: (nextSessionId) => { + currentSessionId = nextSessionId; + }, + }); + + expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([ + "first", + "first reply", + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/rewind.ts b/packages/server/src/server/agent/providers/claude/rewind.ts index 9c3e3d708d..3b228aacc6 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -1,29 +1,90 @@ -import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk"; +import { appendFile, readFile } from "node:fs/promises"; +import path from "node:path"; +import { + forkSession as claudeForkSession, + type Query, + type SessionStore, + type SessionStoreEntry, +} from "@anthropic-ai/claude-agent-sdk"; export interface ClaudeRewindSdk { forkSession( sessionId: string, - options: { upToMessageId: string }, + options: { upToMessageId: string; transcriptPath: string }, ): Promise<{ sessionId: string }>; } +// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a +// provider can point Claude Code at another one. Forking through a store rooted at the +// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it. export const realClaudeRewindSdk: ClaudeRewindSdk = { - forkSession: claudeForkSession, + forkSession: (sessionId, options) => + claudeForkSession(sessionId, { + upToMessageId: options.upToMessageId, + sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)), + }), }; +function projectFolderSessionStore(projectFolder: string): SessionStore { + const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`); + return { + async load({ sessionId }) { + let text: string; + try { + text = await readFile(transcriptPath(sessionId), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + return parseTranscriptEntries(text); + }, + async append({ sessionId }, entries) { + const lines = entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""); + await appendFile(transcriptPath(sessionId), lines, "utf8"); + }, + }; +} + +function parseTranscriptEntries(text: string): SessionStoreEntry[] { + const entries: SessionStoreEntry[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // Skip a line that is not JSON, as the SDK's own transcript reader does. + continue; + } + if (isSessionStoreEntry(parsed)) entries.push(parsed); + } + return entries; +} + +function isSessionStoreEntry(value: unknown): value is SessionStoreEntry { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof (value as { type?: unknown }).type === "string" + ); +} + export async function revertClaudeConversation(input: { sdk: ClaudeRewindSdk; sessionId: string | null; + transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise<string>; setSessionId: (sessionId: string) => void; }): Promise<void> { - if (!input.sessionId) { + if (!input.sessionId || !input.transcriptPath) { throw new Error("Claude session is not ready for rewind"); } const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId; const fork = await input.sdk.forkSession(input.sessionId, { upToMessageId: messageId, + transcriptPath: input.transcriptPath, }); input.setSessionId(fork.sessionId); } @@ -44,6 +105,7 @@ export async function revertClaudeConversationAndFiles(input: { sdk: ClaudeRewindSdk; query: Query; sessionId: string | null; + transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise<string>; setSessionId: (sessionId: string) => void; diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index 615ca81f3a..f762557879 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => { }, }); }); + + it("shows a SubagentHandback report as the subagent's final message", () => { + const tracker = new ClaudeSidechainTracker({ getToolInput: () => null }); + const report = " indented code\n\n## Verdict\n\n- **Coherent**"; + const frames = [ + { + type: "stream_event", + parent_tool_use_id: "task-1", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: {}, + }, + }, + }, + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { + id: "msg-1", + content: [ + { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "task-1", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "handback-1", + tool_name: "SubagentHandback", + content: [{ type: "text", text: '{"success":true}' }], + }, + ], + }, + }, + ]; + + const childItems = frames + .flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1")) + .flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" + ? [event.event.item] + : [], + ); + + expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 6973d1db5d..195e21f857 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -6,6 +6,10 @@ import { mapClaudeRunningToolCall, } from "./tool-call-mapper.js"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; +import { + isClaudeSubagentHandbackToolName, + readClaudeSubagentHandback, +} from "./subagent-handback.js"; import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; @@ -91,7 +95,7 @@ export class ClaudeSidechainTracker { const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); const actionCandidates = this.extractSubAgentActionCandidates(message); const childTimelineItems = [ - ...this.extractSubAgentTimelineItems(message), + ...this.extractSubAgentTimelineItems(message, state), ...this.extractSubAgentToolResults(message, state), ]; let actionUpdated = false; @@ -233,7 +237,10 @@ export class ClaudeSidechainTracker { this.activeSidechains.clear(); } - private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] { + private extractSubAgentTimelineItems( + message: SDKMessage, + state: SubAgentActivityState, + ): AgentTimelineItem[] { if (message.type !== "assistant" || !Array.isArray(message.message?.content)) { return []; } @@ -253,6 +260,18 @@ export class ClaudeSidechainTracker { } else if (block.type === "thinking") { const text = readTrimmedString(block.thinking); if (text) items.push({ type: "reasoning", text }); + } else { + const handback = readClaudeSubagentHandback(block); + if (!handback) continue; + // The handback's result is only an acknowledgement, never a tool card. + state.completedActionKeys.add(handback.callId); + if (handback.report) { + items.push({ + type: "assistant_message", + text: handback.report, + ...(messageId ? { messageId } : {}), + }); + } } } return items; @@ -391,6 +410,12 @@ export class ClaudeSidechainTracker { } private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { + return this.extractToolActionCandidates(message).filter( + (action) => !isClaudeSubagentHandbackToolName(action.toolName), + ); + } + + private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { if (message.type === "assistant") { return this.extractAssistantMessageActions(message); } diff --git a/packages/server/src/server/agent/providers/claude/subagent-handback.ts b/packages/server/src/server/agent/providers/claude/subagent-handback.ts new file mode 100644 index 0000000000..2a8613ed31 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagent-handback.ts @@ -0,0 +1,27 @@ +/** + * Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a + * `SubagentHandback` tool call instead of its final text. The report is the subagent's final + * message, so Paseo shows it as one and hides the call and its acknowledgement. + */ +const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback"; + +export interface ClaudeSubagentHandback { + callId: string; + /** Null while the call is still streaming its input. */ + report: string | null; +} + +export function isClaudeSubagentHandbackToolName(name: unknown): boolean { + return name === SUBAGENT_HANDBACK_TOOL_NAME; +} + +export function readClaudeSubagentHandback(block: { + type: string; + [key: string]: unknown; +}): ClaudeSubagentHandback | null { + if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null; + if (typeof block.id !== "string" || block.id.length === 0) return null; + const input = block.input as { message?: unknown } | null | undefined; + const report = typeof input?.message === "string" ? input.message : ""; + return { callId: block.id, report: report.trim().length > 0 ? report : null }; +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts index 1eca4cedc2..b0bfc3d755 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts @@ -511,6 +511,33 @@ describe("ClaudeTaskProtocolSource", () => { expect(source.cancelRunningForegroundTasks()).toEqual([]); }); + it("does not cancel a subagent announced as backgrounded at spawn", () => { + // Claude sets is_backgrounded on task_started for a child spawned in the background, and + // sends no task_updated patch for it. + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("does not cancel a resumed subagent, which Claude always runs in the background", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ tool_use_id: "toolu_original", is_backgrounded: false })); + source.observe(taskUpdated("completed")); + source.observe(taskStarted({ tool_use_id: "toolu_resumed", is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("still cancels a subagent announced in the foreground", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: false })); + + expect(source.cancelRunningForegroundTasks()).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "canceled" }, + ]); + }); + it("still routes a backgrounded subagent that settles after the interrupt", () => { // The headline case: interrupt, continue, and the child that was told to outlive the turn // reports completion later. Wiping the routing table on cancel drops this on the floor and diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index 1a44520366..2149fae139 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -16,7 +16,8 @@ import { * * Verified on the wire (Claude Code 2.1.220, Paseo's own query options): * - * task_started task_id, tool_use_id, description, subagent_type, task_type + * task_started task_id, tool_use_id, description, subagent_type, task_type, + * is_backgrounded * task_updated task_id, patch.status, patch.is_backgrounded * task_notification task_id, tool_use_id, status * @@ -39,6 +40,11 @@ interface TaskStartedMessage { task_type?: string; prompt?: string; skip_transcript?: boolean; + /** + * Set at spawn: true for a task registered in the background. A resumed subagent is always + * registered in the background. + */ + is_backgrounded?: boolean; } /** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */ @@ -71,8 +77,8 @@ interface TaskUpdatedMessage { task_id: string; /** * `is_backgrounded` is declared on `SDKTaskUpdatedMessage["patch"]`: it flips when a foreground - * task is backgrounded, which is the only signal that separates a child that dies with its turn - * from one that was explicitly told to outlive it. + * task is moved to the background after `task_started`. Together with `task_started`'s own + * flag it separates a child that dies with its turn from one told to outlive it. */ patch?: { status?: string; is_backgrounded?: boolean }; } @@ -171,8 +177,8 @@ export class ClaudeTaskProtocolSource { /** Workflow invocations already own a real Workflow card in the parent timeline. */ private readonly idsWithExistingParentToolCard = new Set<string>(); /** - * Declared subagents that were moved to the background. They outlive the turn that spawned - * them, so a turn ending is not evidence that they stopped. + * Declared subagents running in the background, whether spawned there or moved there later. + * They outlive the turn that spawned them, so a turn ending is not evidence that they stopped. */ private readonly backgroundedIds = new Set<string>(); /** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */ @@ -331,6 +337,7 @@ export class ClaudeTaskProtocolSource { this.sawTaskStarted = true; const existingId = this.subagentIdByTaskId.get(message.task_id); + this.recordBackgrounded(existingId ?? id, message.is_backgrounded); if (existingId) { return this.observeExistingTaskStart(message, id, existingId); } @@ -414,14 +421,15 @@ export class ClaudeTaskProtocolSource { private observeTaskUpdated(message: TaskUpdatedMessage): SubagentObservation[] { const id = this.subagentIdByTaskId.get(message.task_id); - const backgrounded = message.patch?.is_backgrounded; - if (id && typeof backgrounded === "boolean") { - if (backgrounded) this.backgroundedIds.add(id); - else this.backgroundedIds.delete(id); - } + if (id) this.recordBackgrounded(id, message.patch?.is_backgrounded); return this.observeStatus(message.task_id, message.patch?.status); } + private recordBackgrounded(id: string, backgrounded: boolean | undefined): void { + if (backgrounded === true) this.backgroundedIds.add(id); + else if (backgrounded === false) this.backgroundedIds.delete(id); + } + private observeTaskNotification(message: TaskNotificationMessage): SubagentObservation[] { const observations = this.observeWorkflowResult(message); observations.push(...this.observeUsage(message.task_id, message.usage)); diff --git a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts index 496d3bc209..d607b5c063 100644 --- a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts +++ b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts @@ -12,7 +12,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk { async forkSession( _sessionId: string, - options: { upToMessageId: string }, + options: { upToMessageId: string; transcriptPath: string }, ): Promise<{ sessionId: string }> { this.recordedForks.push({ upToMessageId: options.upToMessageId }); return { sessionId: this.nextSessionId }; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 032eeb65cb..c8269722a7 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -698,6 +698,11 @@ process.stdin.on("data", (chunk) => { }); try { + expect(session.usageSession?.()).toMatchObject({ + provider: "codex", + env: { CODEX_HOME: providerCodexHome }, + }); + expect(session.usageSession?.()?.sessionKey).toBe(session.usageSession?.()?.sessionKey); return await run({ session, readCaptured: () => @@ -708,6 +713,7 @@ process.stdin.on("data", (chunk) => { }); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); vi.unstubAllEnvs(); rmSync(tempDir, { recursive: true, force: true }); } @@ -1792,6 +1798,28 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("resumed session exposes usage before connecting and not after close", async () => { + let spawns = 0; + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + { sessionId: "saved-thread" }, + createTestLogger(), + async () => { + spawns++; + throw new Error("unexpected spawn"); + }, + { environment: { HOME: "/fixture/codex", CODEX_HOME: "/fixture/profile" } }, + ); + expect(session.usageSession()).toMatchObject({ + provider: "codex", + env: { CODEX_HOME: "/fixture/profile" }, + sessionKey: expect.any(String), + }); + expect(spawns).toBe(0); + await session.close(); + expect(session.usageSession()).toBeNull(); + }); + test("loads archived Codex history without resuming the native thread", async () => { const threadRequests: string[] = []; const appServer = createFakeCodexAppServer({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 019a03bc59..36267592f8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -63,7 +63,6 @@ import { import { checkProviderLaunchAvailable, createProviderEnv, - createProviderEnvSpec, resolveProviderLaunch, type ProviderRuntimeSettings, type ResolvedProviderLaunch, @@ -272,6 +271,7 @@ interface CodexAppServerClientLike { } interface CodexAppServerAgentDeps { + environment?: Record<string, string>; workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">; customProvider?: { id: string; @@ -3323,7 +3323,7 @@ function toCodexTextInput(text: string): Extract<CodexAppServerUserInput, { type export function buildCodexAppServerEnv( runtimeSettings?: ProviderRuntimeSettings, launchEnv?: Record<string, string>, -): NodeJS.ProcessEnv { +) { return createProviderEnv({ runtimeSettings, overlays: [launchEnv], @@ -3521,6 +3521,19 @@ export class CodexAppServerAgentSession implements AgentSession { } | null = null; private cachedSkills: Array<{ name: string; description: string; path: string }> | null = null; + private readonly usageSessionKey = randomUUID(); + private readonly harnessEnvironment: Record<string, string>; + + usageSession() { + if (this.closed) return null; + return { + provider: "codex", + model: this.config.model, + env: this.harnessEnvironment, + sessionKey: this.usageSessionKey, + }; + } + constructor( config: AgentSessionConfig, private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null, @@ -3546,6 +3559,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.providerOptions = validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions) ?? {}; this.config = config; + this.harnessEnvironment = deps.environment ?? buildCodexAppServerEnv(); this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); @@ -7211,7 +7225,7 @@ export class CodexAppServerAgentClient implements AgentClient { private async spawnAppServer( launchEnv?: Record<string, string>, - options?: { goalsEnabled?: boolean; agentId?: string }, + options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record<string, string> }, ): Promise<ChildProcessWithoutNullStreams> { const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); const args = [...launchPrefix.args, "app-server"]; @@ -7230,10 +7244,7 @@ export class CodexAppServerAgentClient implements AgentClient { const child = spawnProcess(launchPrefix.command, args, { detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], - ...createProviderEnvSpec({ - runtimeSettings: this.runtimeSettings, - overlays: [launchEnv], - }), + baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv), }); assertChildWithPipes(child); return child; @@ -7254,13 +7265,18 @@ export class CodexAppServerAgentClient implements AgentClient { const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); const session = new CodexAppServerAgentSession( sessionConfig, null, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), - this.sessionDeps(launchContext?.env), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + environment, + }), + { ...this.sessionDeps(launchContext?.env), environment }, options?.persistSession === false, goalsEnabled, autoReviewEnabled, @@ -7286,13 +7302,18 @@ export class CodexAppServerAgentClient implements AgentClient { }; const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); const session = new CodexAppServerAgentSession( merged, handle, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), - this.sessionDeps(launchContext?.env), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + environment, + }), + { ...this.sessionDeps(launchContext?.env), environment }, false, goalsEnabled, autoReviewEnabled, 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.commands.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts index 2411773a7e..3655cccb5b 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts @@ -8,7 +8,7 @@ import { createTestLogger } from "../../../test-utils/test-logger.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js"; describe("GenericACPAgentClient slash commands", () => { - test("lists commands an agent advertises right after session/new", async () => { + test("preserves argument hints in commands advertised right after session/new", async () => { await withFakeACPAgent("commands-after-session-new", async (command, cwd) => { const client = new GenericACPAgentClient({ logger: createTestLogger(), @@ -17,9 +17,17 @@ describe("GenericACPAgentClient slash commands", () => { }); const session = await client.createSession({ provider: "acp", cwd }); try { - await expect(session.listCommands?.()).resolves.toEqual([ - { name: "review", description: "Review the diff", argumentHint: "", kind: "command" }, - ]); + const commands = [ + { + name: "review", + description: "Review the diff", + argumentHint: "query", + kind: "command", + }, + { name: "status", description: "Show status", argumentHint: "", kind: "command" }, + ]; + await expect(session.listCommands?.()).resolves.toEqual(commands); + await expect(session.listCommands?.()).resolves.toEqual(commands); } finally { await session.close(); } @@ -91,7 +99,10 @@ rl.on("line", (line) => { sessionId: "session-1", update: { sessionUpdate: "available_commands_update", - availableCommands: [{ name: "review", description: "Review the diff" }], + availableCommands: [ + { name: "review", description: "Review the diff", input: { hint: "query" } }, + { name: "status", description: "Show status" }, + ], }, }, }, diff --git a/packages/server/src/server/agent/providers/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.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 27afed7b9a..c9c0f07161 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -449,6 +449,18 @@ describe("MockLoadTestAgentClient", () => { unsubscribe(); }); + test("emits distinct configured responses across consecutive fixture turns", async () => { + const session = await new MockLoadTestAgentClient().createSession({ + provider: "mock", + cwd: process.cwd(), + model: "e2e-fast-stream", + featureValues: { mockAssistantResponses: ["First response", "![Second](second.png)"] }, + }); + await session.run("Emit a synthetic turn failure."); + expect((await session.run("First prompt")).finalText).toBe("First response"); + expect((await session.run("Second prompt")).finalText).toBe("![Second](second.png)"); + }); + test("emits a settled assistant Markdown image path selected by prompt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); 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 2ebcc0b23e..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, }, { @@ -726,11 +740,19 @@ export class MockLoadTestAgentClient implements AgentClient { } } +function getConfiguredAssistantResponses(value: unknown): string[] { + return Array.isArray(value) && value.every((response) => typeof response === "string") + ? [...value] + : []; +} + export class MockLoadTestAgentSession implements AgentSession { readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID; 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; @@ -739,6 +761,7 @@ export class MockLoadTestAgentSession implements AgentSession { private modeId: string | null; private modelId: string | null; private readonly assistantResponse: string | null; + private readonly assistantResponses: string[]; private readonly streamingAssistantResponse: string | null; private readonly streamingAssistantIntervalMs: number; private readonly rewindError: string | null; @@ -754,6 +777,9 @@ export class MockLoadTestAgentSession implements AgentSession { typeof options.config.featureValues?.mockAssistantResponse === "string" ? options.config.featureValues.mockAssistantResponse : null; + this.assistantResponses = getConfiguredAssistantResponses( + options.config.featureValues?.mockAssistantResponses, + ); this.streamingAssistantResponse = typeof options.config.featureValues?.mockStreamingAssistantResponse === "string" ? options.config.featureValues.mockStreamingAssistantResponse @@ -782,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; @@ -841,8 +876,11 @@ export class MockLoadTestAgentSession implements AgentSession { this.scheduleSteeringReplayTurn(turn, steeringReplayShape); } else if (this.streamingAssistantResponse !== null) { this.scheduleStreamingAssistantTurn(turn, this.streamingAssistantResponse); - } else if (this.assistantResponse !== null) { - this.scheduleSettledAssistantTurn(turn, this.assistantResponse); + } else if (this.assistantResponses.length > 0 || this.assistantResponse !== null) { + this.scheduleSettledAssistantTurn( + turn, + (this.assistantResponses.shift() ?? this.assistantResponse)!, + ); } else if (structuredBranchName) { this.scheduleSettledAssistantTurn(turn, JSON.stringify(structuredBranchName)); } else if (settledAssistantImageMarkdown) { 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 3841f8bd85..a1dc9130a8 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, onTestFinished, test } from "vitest"; import { setImmediate as waitForImmediate } from "node:timers/promises"; import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -69,6 +69,51 @@ test("OMP import uses the runtime's custom agent directory without a configured expect.objectContaining({ providerHandleId: sessionFile }), ]); }); +test("OMP resumes a session whose model was removed on the model OMP falls back to", async () => { + const runtime = new FakeOmp(); + runtime.removeModel("9router/deepseek-v4-flash"); + runtime.queueSessionSetup((session) => { + session.state = { ...session.state, model: { provider: "openrouter", id: "fallback" } }; + }); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + + const session = await client.resumeSession({ + provider: "omp", + sessionId: "omp-session-1", + nativeHandle: "/tmp/omp-session.jsonl", + metadata: { cwd: "/workspace/project", model: "9router/deepseek-v4-flash" }, + }); + onTestFinished(() => session.close()); + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/fallback" }); + expect(session.describePersistence()?.metadata?.model).toBe("openrouter/fallback"); +}); + +test("OMP resumes a session on the requested model when it differs from the session's", async () => { + const runtime = new FakeOmp(); + const requestedModel = { provider: "openrouter", id: "requested" }; + runtime.queueSessionSetup((session) => { + session.state = { ...session.state, model: { provider: "openrouter", id: "recorded" } }; + session.models = [requestedModel]; + session.setModelResult = requestedModel; + }); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + + const session = await client.resumeSession({ + provider: "omp", + sessionId: "omp-session-1", + nativeHandle: "/tmp/omp-session.jsonl", + metadata: { cwd: "/workspace/project", model: "openrouter/requested" }, + }); + onTestFinished(() => session.close()); + + expect(runtime.latestSession().setModelRequests).toEqual([ + { provider: "openrouter", modelId: "requested" }, + ]); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/requested" }); + expect(session.describePersistence()?.metadata?.model).toBe("openrouter/requested"); +}); + class ManualIdleScheduler implements OmpProviderIdleScheduler { private readonly retries: Array<() => void> = []; private readonly waiters: Array<{ count: number; resolve: () => void }> = []; @@ -170,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()); @@ -414,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, @@ -428,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([]); @@ -617,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(); @@ -660,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, + }, ]); }); @@ -834,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 }); @@ -958,6 +1120,9 @@ describe("OMP agent client and session", () => { messageId: "assistant-history", }, ]); + expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) }); + await omp.close(); + expect(omp.usageSession()).toBeNull(); }); test("maps permissions and sends the selected OMP response", async () => { @@ -1122,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 089376c8ca..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; } @@ -445,7 +457,6 @@ function buildResumeStartInput(input: { protocolMode: "rpc-ui", env: input.launchContext?.env, session: input.sessionFile, - model: input.resumeConfig.model, thinkingOptionId: normalizeOmpThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, ...(input.launchMode.modeId ? { modeId: input.launchMode.modeId } : {}), ...(input.launchMode.extraArgs ? { extraArgs: input.launchMode.extraArgs } : {}), @@ -699,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; @@ -721,7 +731,19 @@ export class OmpAgentSession implements AgentSession { private closed = false; private live: boolean; private readonly emittedUserMessageIds = new Set<string>(); - private customMessageIndex = 0; + + private readonly usageSessionKey = randomUUID(); + + usageSession() { + const env = this.runtimeSession.environment; + if (this.closed) return null; + return { + provider: "omp", + model: modelToId(this.state.model) ?? undefined, + env, + sessionKey: this.usageSessionKey, + }; + } constructor(options: OmpAgentSessionOptions) { this.runtimeSession = options.runtimeSession; @@ -857,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; } @@ -1020,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; @@ -1301,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, []); } @@ -1310,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); } @@ -1371,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, @@ -1403,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( @@ -1707,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; @@ -1815,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 ?? []; @@ -1886,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( @@ -1988,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()}`), }); } } @@ -2360,16 +2356,17 @@ export class OmpAgentClient implements AgentClient { resumeConfig.config, startInput.env, ); - const initialState = await this.restoreFastMode( - runtimeSession, - resumeConfig.config, - await runtimeSession.getState(), - ); + const resumedState = await this.applyResumeModel(runtimeSession, resumeConfig.model); + const config = { + ...resumeConfig.config, + model: modelToId(resumedState.model) ?? resumeConfig.config.model, + }; + const initialState = await this.restoreFastMode(runtimeSession, config, resumedState); return new OmpAgentSession({ runtimeSession, hostTools, - restartRuntime: this.buildRestartRuntime(startInput, resumeConfig.config, launchContext), - config: resumeConfig.config, + restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), + config, initialState, currentModeId: launchMode.modeId, logger: this.logger, @@ -2387,6 +2384,35 @@ export class OmpAgentClient implements AgentClient { } } + // OMP resumes a session on the model it recorded, or on its default when that model + // is gone. Switching afterwards keeps a removed model from blocking the resume. + private async applyResumeModel( + runtimeSession: OmpRuntimeSession, + requestedModel: string | undefined, + ): Promise<OmpSessionState> { + const state = await runtimeSession.getState(); + const reference = parseModelReference(requestedModel ?? null); + if (!reference?.provider) { + return state; + } + const { provider, id } = reference; + const isRequested = (model: OmpModel | null | undefined) => + model?.provider === provider && model.id === id; + if (isRequested(state.model)) { + return state; + } + const availableModels = await runtimeSession.getAvailableModels(); + if (!availableModels.some(isRequested)) { + this.logger.warn( + { requestedModel, sessionModel: modelToId(state.model) }, + "OMP resumed on the session's model because the requested model is unavailable", + ); + return state; + } + await runtimeSession.setModel(provider, id); + return runtimeSession.getState(); + } + private buildRestartRuntime( startInput: OmpStartSessionInput, config: AgentSessionConfig, 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 f312079491..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 @@ -127,7 +127,13 @@ describe("OMP CLI runtime", () => { onOmpCommand(child, (command) => { if (command.type === "steer") pending = command; }); - const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const launches: OmpRuntimeLaunch[] = []; + const session = await createRuntime(child, launches).startSession({ + cwd: "/workspace/project", + env: { HOME: "/fixture/omp-home" }, + }); + expect(session.environment).toBe(launches[0]?.env); + expect(session.environment?.HOME).toBe("/fixture/omp-home"); const result = session.steer("change direction"); await new Promise((resolve) => setImmediate(resolve)); expect(pending).toMatchObject({ type: "steer", message: "change direction" }); @@ -221,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[] = []; @@ -346,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>[] = []; @@ -485,7 +605,10 @@ describe("OMP CLI runtime", () => { const child = createOmpChild(); const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const environment = session.environment; child.emit("exit", 1, null); + expect(session.environment).toBe(environment); + expect(environment).toBeDefined(); await expect(session.abort()).resolves.toBeUndefined(); }); 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 61ca592993..75bd79f5f0 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -1,3 +1,4 @@ +import { createExternalProcessEnv } from "../../../paseo-env.js"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; import { z } from "zod"; @@ -26,7 +27,7 @@ import { OmpModelsResultSchema, OmpPromptAckSchema, OmpRpcCommandSchema, - OmpRuntimeEventSchema, + parseOmpRuntimeEvent, OmpSessionStateSchema, OmpSessionStatsSchema, type OmpThinkingLevel, @@ -74,6 +75,7 @@ export class OmpCliRuntime implements OmpRuntime { runtimeSettings: this.options.runtimeSettings, session: input, }); + launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {}); const [command, ...args] = launch.argv; const processLaunch: JsonlRpcLaunch = { command, @@ -98,7 +100,7 @@ export class OmpCliRuntime implements OmpRuntime { requestTimeoutMs: this.options.requestTimeoutMs, }); input.signal?.throwIfAborted(); - return new OmpCliRuntimeSession(process, this.commandsRpcName); + return new OmpCliRuntimeSession(process, this.commandsRpcName, launch.env); } catch (error) { const startupError = error instanceof Error ? error : new Error(String(error)); await process.close(startupError); @@ -116,11 +118,12 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { constructor( private readonly process: JsonlRpcProcess, private readonly commandsRpcName: "get_available_commands", + 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 }) => { @@ -128,6 +131,10 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { }); } + get environment(): Record<string, string> { + return this.launchEnvironment; + } + onEvent(callback: (event: OmpRuntimeEvent) => void): () => void { this.subscribers.add(callback); return () => { 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 9a6d9a5b82..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([ { @@ -54,6 +88,79 @@ describe("OMP history mapper", () => { }); }); + test("replays a Paseo browser tool result whose details error is an object", async () => { + const events = await collectHistory([ + { + role: "assistant", + content: [{ type: "toolCall", id: "shot-1", name: "browser_screenshot", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "shot-1", + toolName: "browser_screenshot", + content: [{ type: "text", text: "The tab has not painted yet. Retry the screenshot." }], + details: { + ok: false, + error: { + code: "screenshot_no_frame", + message: "The tab has not painted yet. Retry the screenshot.", + retryable: true, + }, + }, + isError: false, + }, + { role: "assistant", content: [{ type: "text", text: "Retrying later." }] }, + ]); + + expect(events.map((event) => event.item)).toEqual([ + expect.objectContaining({ type: "tool_call", callId: "shot-1", status: "running" }), + expect.objectContaining({ + type: "tool_call", + callId: "shot-1", + name: "browser_screenshot", + detail: expect.objectContaining({ + type: "unknown", + output: expect.objectContaining({ + details: expect.objectContaining({ + error: expect.objectContaining({ code: "screenshot_no_frame", retryable: true }), + }), + }), + }), + }), + expect.objectContaining({ type: "assistant_message", text: "Retrying later." }), + ]); + }); + + test("reports a failed tool's structured details error by its message", async () => { + const events = await collectHistory([ + { + role: "assistant", + content: [{ type: "toolCall", id: "tabs-1", name: "browser_list_tabs", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "tabs-1", + toolName: "browser_list_tabs", + content: [], + details: { + ok: false, + error: { + code: "browser_no_host", + message: "No browser automation host is connected.", + retryable: true, + }, + }, + isError: true, + }, + ]); + + expect(events.at(-1)?.item).toMatchObject({ + type: "tool_call", + status: "failed", + error: "No browser automation host is connected.", + }); + }); + test("restores blocked and abandoned todo state from a session file", async () => { const dir = mkdtempSync(join(tmpdir(), "omp-todo-state-history-")); const sessionFile = join(dir, "session.jsonl"); @@ -130,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", }, ]); }); @@ -335,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, }, }, { @@ -578,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(); @@ -608,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"); @@ -685,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, }, ]); }); @@ -748,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/question-ui.ts b/packages/server/src/server/agent/providers/omp/question-ui.ts index 4fdb37f61c..2a90d7288e 100644 --- a/packages/server/src/server/agent/providers/omp/question-ui.ts +++ b/packages/server/src/server/agent/providers/omp/question-ui.ts @@ -3,6 +3,7 @@ import type { AgentPermissionResponse, AgentProvider, } from "../../agent-sdk-types.js"; +import { splitMultiSelectAnswer } from "../question-card-answer.js"; import type { OmpAgentMessage, OmpRuntimeEvent } from "./rpc-types.js"; type UiRequest = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>; @@ -176,21 +177,7 @@ function selectedAnswer( return labels.includes(answer) ? { selected: [answer], custom: null } : { selected: [], custom: answer }; - - // The shared question UI sends a comma-joined answer in click order. Consume - // exact option labels from the front; any remaining text is the Other answer. - let remaining = answer; - const selected: string[] = []; - while (remaining.length > 0) { - const label = labels - .filter((candidate) => !selected.includes(candidate)) - .sort((left, right) => right.length - left.length) - .find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `)); - if (!label) break; - selected.push(label); - remaining = remaining === label ? "" : remaining.slice(label.length + 2); - } - return { selected, custom: remaining || null }; + return splitMultiSelectAnswer(answer, labels); } /** Owns OMP question mapping and the ask tool's multi-RPC answer sequence. */ diff --git a/packages/server/src/server/agent/providers/omp/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/runtime.ts b/packages/server/src/server/agent/providers/omp/runtime.ts index 2381610ff6..691e907139 100644 --- a/packages/server/src/server/agent/providers/omp/runtime.ts +++ b/packages/server/src/server/agent/providers/omp/runtime.ts @@ -43,6 +43,7 @@ export interface OmpStartSessionInput { } export interface OmpRuntimeSession { + readonly environment: Record<string, string>; onEvent(callback: (event: OmpRuntimeEvent) => void): () => void; prompt( message: string, 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 184a38bafa..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 @@ -54,6 +54,8 @@ export class FakeOmp implements OmpRuntime { private readonly sessions: FakeOmpSession[] = []; private readonly command: [string, ...string[]]; private readonly queuedCommands: OmpRpcSlashCommand[][] = []; + private readonly queuedSessionSetups: Array<(session: FakeOmpSession) => void> = []; + private readonly removedModels = new Set<string>(); private nextStartError: Error | null = null; private nextSessionId = 0; private readonly sessionIdsByFile = new Map<string, string>(); @@ -75,16 +77,20 @@ export class FakeOmp implements OmpRuntime { session: input, }); this.recordedLaunches.push(launch); + if (launch.model && this.removedModels.has(launch.model)) { + throw new Error(`Model "${launch.model}" not found`); + } const sessionId = (launch.session ? this.sessionIdsByFile.get(launch.session) : undefined) ?? `omp-session-${++this.nextSessionId}`; - const session = new FakeOmpSession(launch, sessionId); + const session = new FakeOmpSession(launch, sessionId, this.removedModels); if (!launch.noSession) this.sessionIdsByFile.set(session.state.sessionFile, sessionId); session.commands = this.queuedCommands.shift() ?? []; for (const [level, error] of this.queuedSubagentSubscriptionErrors) { session.subagentSubscriptionErrors.set(level, error); } this.queuedSubagentSubscriptionErrors.clear(); + this.queuedSessionSetups.shift()?.(session); this.sessions.push(session); return session; } @@ -93,6 +99,15 @@ export class FakeOmp implements OmpRuntime { this.queuedCommands.push(commands); } + queueSessionSetup(setup: (session: FakeOmpSession) => void): void { + this.queuedSessionSetups.push(setup); + } + + // OMP refuses to launch with, or switch to, a model it no longer knows. + removeModel(modelId: string): void { + this.removedModels.add(modelId); + } + failNextStart(error: Error): void { this.nextStartError = error; } @@ -115,6 +130,7 @@ export class FakeOmp implements OmpRuntime { } export class FakeOmpSession implements OmpRuntimeSession { + readonly environment: Record<string, string>; fastModeResult = { enabled: false, active: false }; readonly setFastModeRequests: boolean[] = []; readonly prompts: Array<{ message: string; imageCount: number }> = []; @@ -152,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: "" }; @@ -173,7 +188,12 @@ export class FakeOmpSession implements OmpRuntimeSession { private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null; - constructor(launch: OmpRuntimeLaunch, sessionId = "omp-session-1") { + constructor( + launch: OmpRuntimeLaunch, + sessionId = "omp-session-1", + private readonly removedModels: ReadonlySet<string> = new Set(), + ) { + this.environment = launch.env ?? {}; this.state = { model: null, thinkingLevel: "medium", @@ -245,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; } @@ -331,9 +348,13 @@ export class FakeOmpSession implements OmpRuntimeSession { async setModel(provider: string, modelId: string): Promise<OmpModel> { this.setModelRequests.push({ provider, modelId }); + if (this.removedModels.has(`${provider}/${modelId}`)) { + throw new Error(`Model not found: ${provider}/${modelId}`); + } if (!this.setModelResult) { throw new Error("FakeOmp setModel requires setModelResult to be scripted"); } + this.state = { ...this.state, model: this.setModelResult }; return this.setModelResult; } 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 b292a9b32f..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, @@ -566,6 +616,10 @@ export class OmpHarness { return this.events.filter((event) => event.type === "turn_canceled").length; } + usageSession() { + return this.requireSession().usageSession(); + } + async close(): Promise<void> { await this.requireSession().close(); await waitForImmediate(); diff --git a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts index f0e9d15532..cece7f0398 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts @@ -57,9 +57,13 @@ interface OmpToolResultObject { details?: OmpToolResultDetails; } +interface OmpToolResultStructuredError { + message: string; +} + interface OmpToolResultDetails { diff?: string; - error?: string; + error?: string | OmpToolResultStructuredError; mode?: string; xdev?: unknown; } @@ -155,9 +159,17 @@ const OmpToolResultContentSchema = z.union([ OmpToolResultUnknownContentSchema, ]); +// OMP's own tools report a string error; Paseo host tools (browser_*) report +// a structured `{ code, message, retryable }` error. +const OmpToolResultErrorSchema = z.union([ + z.string(), + z.object({ message: z.string() }).passthrough(), +]); + const OmpToolResultDetailsSchema = z .object({ diff: z.string().optional(), + error: OmpToolResultErrorSchema.optional(), }) .passthrough(); @@ -272,8 +284,10 @@ export function extractTextFromToolResult(result: OmpToolResult): string | undef } export function toolFailureMessage(result: OmpToolResult): string { - if (result && typeof result !== "string" && result.details?.error) { - return result.details.error.split("\n", 1)[0].slice(0, 240); + const error = result && typeof result !== "string" ? result.details?.error : undefined; + const errorMessage = typeof error === "string" ? error : error?.message; + if (errorMessage) { + return errorMessage.split("\n", 1)[0].slice(0, 240); } const output = extractTextFromToolResult(result); const exitMessage = output?.match(/(?:Command|Process) exited with code \d+/i)?.[0]; 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.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 15bae2f56c..d6b6c5513a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1618,6 +1618,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1728,6 +1729,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1812,6 +1814,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1853,6 +1856,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), undefined, undefined, @@ -1881,6 +1885,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); await session.close(); @@ -1905,6 +1910,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), events, ); @@ -1977,6 +1983,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2067,6 +2074,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2119,6 +2127,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2232,6 +2241,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2347,6 +2357,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const events: AgentStreamEvent[] = []; @@ -3146,6 +3157,7 @@ describe("OpenCode adapter startTurn error handling", () => { sdkClient, "ses_sync_abort_failure", createTestLogger(), + {}, ); try { @@ -3744,7 +3756,10 @@ describe("OpenCode adapter startTurn error handling", () => { try { await session.startTurn("first"); await session.interrupt(); + const descriptor = session.usageSession?.(); openCode.emitEvent({ type: "server-exited", error: new Error("OpenCode exited") }); + expect(session.usageSession?.()).toEqual(descriptor); + expect(descriptor).not.toBeNull(); await vi.advanceTimersByTimeAsync(0); openCode.sessionPromptAsyncEvents = [ @@ -3799,6 +3814,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_timeout", createTestLogger(), + {}, new Map(), { ready: () => new Promise<void>(() => undefined), @@ -3852,6 +3868,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_slow_stream", createTestLogger(), + {}, new Map(), { ready: () => streamReady.promise, @@ -3884,6 +3901,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_retry", createTestLogger(), + {}, new Map(), { ready: () => streamReady.promise, @@ -5562,6 +5580,7 @@ describe("OpenCode provider subagent contract", () => { fakeClient, "ses_parent", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -5720,6 +5739,7 @@ describe("OpenCode provider subagent contract", () => { fakeClient, "ses_parent", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -6976,9 +6996,14 @@ describe("OpenCode session permission rules", () => { metadata: { cwd }, }); try { + expect(session.usageSession?.()).toMatchObject({ + provider: "opencode", + sessionKey: expect.any(String), + }); expect(openCode.calls.sessionUpdate).toEqual([]); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); } } finally { rmSync(cwd, { recursive: true, force: true }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index ccd605ef06..7e49bc50d3 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -13,7 +13,7 @@ import { type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import fs from "node:fs/promises"; -import { randomBytes } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import pLimit from "p-limit"; @@ -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: "" }), }), @@ -1492,6 +1493,7 @@ export class OpenCodeAgentClient implements AgentClient { client, session.id, this.logger, + connection.environment, new Map(this.modelContextWindows), connection.events, connection.release, @@ -1546,6 +1548,7 @@ export class OpenCodeAgentClient implements AgentClient { client, handle.sessionId, this.logger, + connection.environment, new Map(this.modelContextWindows), connection.events, connection.release, @@ -1594,6 +1597,7 @@ export class OpenCodeAgentClient implements AgentClient { return { client: this.createOpenCodeClient({ baseUrl: acquisition.server.url, directory }), events: acquisition.events, + environment: acquisition.environment, url: acquisition.server.url, release: acquisition.release, }; @@ -3357,6 +3361,7 @@ async function listOpenCodeChildSessions( /** One OpenCode server generation a session talks to. */ interface OpenCodeServerConnection { + environment: Record<string, string>; client: OpencodeClient; events: OpenCodeEventSource; url: string; @@ -3437,11 +3442,13 @@ class OpenCodeAgentSession implements AgentSession { private closed = false; private readonly persistSession: boolean; private deletedFromProvider = false; + private readonly usageSessionKey = randomUUID(); constructor( config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string, logger: Logger, + private readonly harnessEnvironment: Record<string, string>, modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(), events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE, releaseServer: () => Promise<void> = async () => undefined, @@ -3453,7 +3460,13 @@ class OpenCodeAgentSession implements AgentSession { connectServer?: () => Promise<OpenCodeServerConnection>, ) { this.config = config; - this.server = { client, events, url: serverUrl ?? "", release: releaseServer }; + this.server = { + client, + events, + url: serverUrl ?? "", + release: releaseServer, + environment: this.harnessEnvironment, + }; this.connectServer = connectServer ?? null; this.sessionId = sessionId; this.logger = logger.child({ agentId: this.agentId }); @@ -3468,6 +3481,16 @@ class OpenCodeAgentSession implements AgentSession { this.subscribeServerEvents(); } + usageSession() { + if (this.closed) return null; + return { + provider: "opencode", + model: this.config.model, + env: this.harnessEnvironment, + sessionKey: this.usageSessionKey, + }; + } + private get client(): OpencodeClient { return this.server.client; } diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 2167913d0e..151fb57610 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -88,6 +88,7 @@ describe("OpenCodeServerManager generations", () => { const acquisition = await manager.acquireCurrent(); expect(runtime.spawnCalls[0]?.options.baseEnv).toEqual(baseEnv); + expect(acquisition.environment).toBe(runtime.spawnCalls[0]?.options.baseEnv); await acquisition.release(); }); 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 f1cd43030e..9ff5dc6c96 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -11,7 +11,7 @@ import { spawnProcess, type SpawnProcessOptions } from "../../../../utils/spawn. import { terminateWithTreeKill, type ProcessTerminator } from "../../../../utils/tree-kill.js"; import type { ManagedProcessRegistry } from "../../../managed-processes/managed-processes.js"; import { - createProviderEnvSpec, + createProviderEnv, resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../../provider-launch-config.js"; @@ -30,6 +30,7 @@ const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000; const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; export interface OpenCodeServerAcquisition { + environment: Record<string, string>; server: { port: number; url: string }; events: OpenCodeEventSource; release: () => Promise<void>; @@ -44,6 +45,7 @@ export interface OpenCodeServerManagerLike { } export interface OpenCodeServerGeneration { + environment: Record<string, string>; process: ChildProcess; port: number; url: string; @@ -211,6 +213,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { return { server: { port: server.port, url: server.url }, events: server.events, + environment: server.environment, release: async () => { if (releasePromise) { return releasePromise; @@ -332,15 +335,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { const bridgeEnv = this.decorateServerEnv?.( existingConfigContent ? { OPENCODE_CONFIG_CONTENT: existingConfigContent } : {}, ); + const environment = createProviderEnv({ + baseEnv: this.baseEnv, + runtimeSettings: this.runtimeSettings, + overlays: [launchEnv, bridgeEnv], + }); const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, { cwd: serverCwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], - ...createProviderEnvSpec({ - baseEnv: this.baseEnv, - runtimeSettings: this.runtimeSettings, - overlays: [launchEnv, bridgeEnv], - }), + baseEnv: environment, }); const managedProcessRecord = this.recordManagedServerProcess({ process: serverProcess, @@ -352,14 +356,26 @@ 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, port, url, 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( @@ -398,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) { @@ -406,7 +422,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } settled = true; clearTimeout(timeout); - reject(error); + rejectListening(error); }; timeout = setTimeout(() => { if (!started) { @@ -421,7 +437,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { started = true; settled = true; clearTimeout(timeout); - resolve(); + resolveListening(); } }); @@ -457,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/test-server-manager.ts b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts index fc1748dd28..aa8e644a10 100644 --- a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts @@ -46,6 +46,7 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { this.acquisitions.push(acquisition); return { server: this.server, + environment: input.env ?? {}, events, release: async () => { acquisition.released = true; diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 59c3f9d695..7bad72b8b1 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -74,6 +74,7 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike { return { server: this.server, events: this.events, + environment: input.env ?? {}, release: async () => { acquisition.releaseCount += 1; }, diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts new file mode 100644 index 0000000000..d9d254d9de --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts @@ -0,0 +1,48 @@ +import { TimelineProjection } from "../../../timeline-projection.js"; +import { V2Timeline } from "../v2/timeline.js"; + +// Streams one reasoning part through the timeline into the daemon's projection and +// prints the bytes still held once the part is complete. +const mode = process.argv[2]; +const chunks = Number(process.argv[3]); +if (mode !== "delta" && mode !== "snapshot") throw new Error("Mode must be delta or snapshot"); +if (!global.gc) throw new Error("Run with --expose-gc"); + +global.gc(); +const before = process.memoryUsage().heapUsed; +const timeline = new V2Timeline(); +const projection = new TimelineProjection(); +const part = { assistantMessageID: "answer", type: "reasoning" as const, ordinal: 0 }; +timeline.startPart(part); +let text = ""; +for (let index = 0; index < chunks; index++) { + const delta = `chunk-${index}:`.padEnd(64, String.fromCharCode(65 + (index % 26))); + text += delta; + const event = + mode === "delta" + ? timeline.delta({ ...part, delta }) + : timeline.messages([ + { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "reasoning", text }], + }, + ])[0]; + if (event?.type !== "timeline") throw new Error("Expected a timeline event"); + projection.append({ + seq: index + 1, + timestamp: "2026-10-01T00:00:00.000Z", + turnId: "turn", + item: event.item, + }); +} +global.gc(); +const retainedBytes = process.memoryUsage().heapUsed - before; +const [row] = projection.getRows(); +if (row?.item.type !== "reasoning" || row.item.text !== text) { + throw new Error("Projection lost the streamed text"); +} +process.stdout.write(String(retainedBytes)); diff --git a/packages/server/src/server/agent/providers/opencode/v2/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.test.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts index 50a6d98549..69da960c5c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts @@ -109,7 +109,7 @@ describe("OpenCode v2 question tool", () => { { value: "Explore", label: "Explore", description: "Map the codebase" }, { value: "Fix a bug", label: "Fix a bug", description: "Track down a bug" }, ], - multiple: false, + multiSelect: false, allowOther: true, }, ], @@ -129,6 +129,99 @@ describe("OpenCode v2 question tool", () => { await session.close(); } }); + + test("answers a multi-select question with the checked options and a typed answer", async () => { + const harness = new V2Harness(); + harness.api.session.form.list = async () => [ + { + id: "question", + sessionID: "session", + title: "Questions", + fields: [ + { + key: "q0", + title: "Fixes", + description: "Which fixes should I make?", + type: "multiselect", + options: [ + { value: "delete", label: "Delete both" }, + { value: "delete-and-fix", label: "Delete both, fix code" }, + { value: "email", label: "Email the user" }, + ], + custom: true, + }, + ], + }, + ]; + const answers: Parameters<V2Api["session"]["form"]["reply"]>[0][] = []; + harness.api.session.form.reply = async (input) => { + answers.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const [request] = session.getPendingPermissions(); + expect(request.input).toMatchObject({ questions: [{ header: "Fixes", multiSelect: true }] }); + await session.respondToPermission("question", { + behavior: "allow", + updatedInput: { + answers: { Fixes: "Delete both, fix code, Email the user, Also check the logs" }, + }, + }); + expect(answers).toEqual([ + { + sessionID: "session", + formID: "question", + answer: { q0: ["delete-and-fix", "email", "Also check the logs"] }, + }, + ]); + expect(session.getPendingPermissions()).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("rejects typed text on a multi-select question that has no typed answer", async () => { + const harness = new V2Harness(); + harness.api.session.form.list = async () => [ + { + id: "question", + sessionID: "session", + title: "Questions", + fields: [ + { + key: "q0", + title: "Fixes", + type: "multiselect", + options: [{ value: "delete", label: "Delete both" }], + }, + ], + }, + ]; + const answers: Parameters<V2Api["session"]["form"]["reply"]>[0][] = []; + harness.api.session.form.reply = async (input) => { + answers.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await expect( + session.respondToPermission("question", { + behavior: "allow", + updatedInput: { answers: { Fixes: "Delete both, Something else" } }, + }), + ).rejects.toThrow("Invalid answer for OpenCode question q0"); + expect(answers).toEqual([]); + } finally { + await session.close(); + } + }); }); describe("OpenCode v2 permission routing", () => { diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts index 176fc455be..cb33e0652e 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -7,6 +7,7 @@ import type { AgentSessionConfig, AgentStreamEvent, } from "../../../agent-sdk-types.js"; +import { splitMultiSelectAnswer } from "../../question-card-answer.js"; export class SessionPermissions { private readonly pending = new Map<string, AgentPermissionRequest>(); @@ -33,6 +34,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) { @@ -103,7 +110,7 @@ export class SessionPermissions { header: field.title ?? field.key, question: field.description ?? field.title ?? field.key, options: "options" in field ? field.options : undefined, - multiple: field.type === "multiselect", + multiSelect: field.type === "multiselect", allowOther: "custom" in field && field.custom === true, })), }, @@ -137,8 +144,8 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu ); }; if (field.type === "multiselect") { - if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string")) - return value.map(labelValue); + const selected = multiSelectAnswer(field, value); + if (selected) return selected.map(labelValue); } else if (field.type === "string" && typeof value === "string") { return labelValue(value); } else if (field.type === "boolean") { @@ -156,3 +163,17 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu } throw new Error(`Invalid answer for OpenCode question ${field.key}`); } + +type MultiSelectField = Extract<FormInfo["fields"][number], { type: "multiselect" }>; + +function multiSelectAnswer(field: MultiSelectField, value: unknown): string[] | undefined { + if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string")) + return value; + if (typeof value !== "string") return undefined; + const { selected, custom } = splitMultiSelectAnswer( + value, + field.options.map((option) => option.label), + ); + if (custom === null) return selected; + return field.custom === true ? [...selected, custom] : undefined; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/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..82236de2b8 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts @@ -1,4 +1,6 @@ import type { SessionMessageAssistant } from "@opencode/client"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; import { V2Timeline } from "./timeline.js"; @@ -54,3 +56,79 @@ test("a completed edit carries the replaced and replacement text", () => { }, ]); }); + +test("a tool's image output is shown as an image, not as base64 in the tool output", () => { + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + const screenshot = assistant([ + { + type: "tool", + id: "call-screenshot", + name: "paseo_browser_screenshot", + executed: false, + state: { + status: "completed", + input: {}, + content: [ + { type: "text", text: "Captured browser screenshot (1x1)." }, + { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }, + ], + metadata: {}, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ]); + const timeline = new V2Timeline(); + + const events = timeline.messages([screenshot]); + + expect(JSON.stringify(events)).not.toContain(png); + expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([ + { + type: "tool_call", + name: "paseo_browser_screenshot", + status: "completed", + detail: { output: "Captured browser screenshot (1x1).\n[image]" }, + }, + { + type: "assistant_message", + text: expect.stringMatching(/^!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)$/), + }, + ]); + expect(timeline.messages([screenshot])).toEqual([]); +}); + +test("a surrogate pair split across deltas streams back intact", () => { + const timeline = new V2Timeline(); + const part = { assistantMessageID: "answer", type: "text" as const, ordinal: 0 }; + timeline.startPart(part); + const chunks = ["a\uD83D", "\uDE00b"].map((delta) => { + const event = timeline.delta({ ...part, delta }); + return event?.type === "timeline" && event.item.type === "assistant_message" + ? event.item.text + : null; + }); + expect(chunks).toEqual(["a\uD83D", "\uDE00b"]); + expect(chunks.join("")).toBe("a😀b"); +}); + +function retainedBytesAfterStreaming(mode: "delta" | "snapshot", chunks: number): number { + const fixture = fileURLToPath( + new URL("../test-utils/v2-streamed-text-memory.ts", import.meta.url), + ); + const output = execFileSync( + process.execPath, + ["--expose-gc", "--import", "tsx", fixture, mode, String(chunks)], + { encoding: "utf8" }, + ); + return Number(output); +} + +test.each(["delta", "snapshot"] as const)( + "a long %s-streamed part holds memory in proportion to its text", + (mode) => { + const chunks = 2000; + const textBytes = chunks * 64; + expect(retainedBytesAfterStreaming(mode, chunks)).toBeLessThan(textBytes * 32); + }, +); diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts index 3bc0a5bf76..0fdd6f9f40 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts @@ -1,4 +1,8 @@ import { mapOpencodeToolCall } from "../tool-call-mapper.js"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../../provider-image-output.js"; import type { SessionMessageAssistantTool } from "@opencode/client"; import { STRUCTURED_OUTPUT_TOOL } from "./structured-output.js"; import type { SessionMessageInfo } from "@opencode/client"; @@ -8,6 +12,13 @@ function textPartKey(messageID: string, part: "text" | "reasoning", ordinal: num return `${messageID}:${part}:${ordinal}`; } +// A `slice` can share its source's storage, so every streamed chunk kept by the timeline +// store would hold the whole text it was cut from. Copying the UTF-16 code units makes +// the chunk independent; UTF-8 would corrupt a surrogate pair split across chunks. +function detachedSuffix(text: string, start: number): string { + return Buffer.from(text.slice(start), "utf16le").toString("utf16le"); +} + // Text and reasoning each number parts from zero, independent of snapshot position. export class V2Timeline { private readonly content = new Map<string, string>(); @@ -36,7 +47,7 @@ export class V2Timeline { if (!text.startsWith(emitted)) throw new Error("OpenCode changed previously emitted message content"); this.content.set(key, text); - const suffix = text.slice(emitted.length); + const suffix = detachedSuffix(text, emitted.length); const item: AgentTimelineItem = event.type === "text" ? { type: "assistant_message", text: suffix, messageId: event.assistantMessageID } @@ -139,7 +150,7 @@ export class V2Timeline { if (!part.text.startsWith(previous)) throw new Error("OpenCode changed previously emitted message content"); this.content.set(key, part.text); - const suffix = part.text.slice(previous.length); + const suffix = detachedSuffix(part.text, previous.length); if (part.type === "text") push({ type: "assistant_message", text: suffix, messageId: messageID }); else push({ type: "reasoning", text: suffix }); @@ -158,8 +169,21 @@ export class V2Timeline { const serialized = JSON.stringify(part); if (serialized === this.content.get(key)) return; this.content.set(key, serialized); - const item = toolFromV2(part); + const { output, images } = splitToolContent(part.state); + const item = toolFromV2(part, output); if (item) push(item); + // Images follow their tool call as markdown, as Claude and Codex emit them, + // so base64 never reaches the tool output. + images.forEach((image, index) => { + const imageKey = `${key}:image:${index}`; + if (this.content.has(imageKey)) return; + this.content.set(imageKey, ""); + const imageItem = renderProviderImageOutputAsAssistantMarkdown( + { url: image.uri, mimeType: image.mime }, + { materialize: materializeProviderImage }, + ); + if (imageItem) push(imageItem); + }); } private compactionMessage( @@ -187,12 +211,28 @@ interface TextPartIdentity { interface TextPartDelta extends TextPartIdentity { delta: string; } -function toolFromV2(tool: SessionMessageAssistantTool): AgentTimelineItem | null { +function splitToolContent(state: SessionMessageAssistantTool["state"]): { + output: string | undefined; + images: { uri: string; mime: string }[]; +} { + if (!("content" in state) || !state.content) return { output: undefined, images: [] }; + const images: { uri: string; mime: string }[] = []; + const output = state.content + .map((part) => { + if (part.type === "text") return part.text; + if (!part.uri.startsWith("data:image/")) return part.uri; + images.push(part); + return "[image]"; + }) + .join("\n"); + return { output, images }; +} + +function toolFromV2( + tool: SessionMessageAssistantTool, + output: string | undefined, +): AgentTimelineItem | null { const state = tool.state; - const output = - "content" in state - ? state.content?.map((part) => (part.type === "text" ? part.text : part.uri)).join("\n") - : undefined; return mapOpencodeToolCall({ toolName: tool.name, callId: tool.id, diff --git a/packages/server/src/server/agent/providers/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 bf04d54071..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 }]); @@ -1495,7 +1555,7 @@ describe("PiRpcAgentSession", () => { const pi = new FakePi(); const client = createClient(pi); - await client.resumeSession( + const session = await client.resumeSession( { provider: "pi", sessionId: "pi-session-1", @@ -1510,6 +1570,12 @@ describe("PiRpcAgentSession", () => { { env: { RESUME_PROBE: "expected" } }, ); + expect(session.usageSession?.()).toMatchObject({ + provider: "pi", + sessionKey: expect.any(String), + }); + await session.close(); + expect(session.usageSession?.()).toBeNull(); expect(pi.recordedLaunches).toHaveLength(1); const actualLaunch = pi.recordedLaunches[0]!; expect(actualLaunch).toMatchObject({ @@ -1522,8 +1588,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -1533,6 +1597,87 @@ describe("PiRpcAgentSession", () => { ]); }); + test("resumes a session whose model was removed on the model Pi falls back to", async () => { + const pi = new FakePi(); + pi.removeModel("9router/deepseek-v4-flash"); + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + }); + + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "9router/deepseek-v4-flash" }, + }); + onTestFinished(() => session.close()); + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ + model: "kimi-coding/kimi-k3", + }); + expect(session.describePersistence()?.metadata?.model).toBe("kimi-coding/kimi-k3"); + }); + + test("resumes a session on the requested model when it differs from the session's", async () => { + const pi = new FakePi(); + const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" }; + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + session.models = [RESTRICTED_THINKING_MODEL, requestedModel]; + session.setModelResult = requestedModel; + }); + + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "openrouter/a" }, + }); + onTestFinished(() => session.close()); + + expect(pi.latestSession().setModelRequests).toEqual([{ provider: "openrouter", modelId: "a" }]); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/a" }); + expect(session.describePersistence()?.metadata?.model).toBe("openrouter/a"); + }); + + test("resumes on a requested model written as provider:id", async () => { + const pi = new FakePi(); + const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" }; + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + session.models = [RESTRICTED_THINKING_MODEL, requestedModel]; + session.setModelResult = requestedModel; + }); + + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "openrouter:a" }, + }); + onTestFinished(() => session.close()); + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/a" }); + }); + + test("fails the resume when switching to an available model fails", async () => { + const pi = new FakePi(); + const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" }; + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + session.models = [RESTRICTED_THINKING_MODEL, requestedModel]; + }); + + await expect( + createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "openrouter/a" }, + }), + ).rejects.toThrow("FakePi setModel requires setModelResult to be scripted"); + }); + test("adopts Pi's clamped thinking level when resuming a session", async () => { const pi = new FakePi(); pi.queueSessionSetup((session) => { @@ -1709,8 +1854,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -1800,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()); @@ -2570,6 +2755,11 @@ describe("PiRpcAgentClient", () => { ); const pi = new FakePi(); pi.queueSessionSetup((session) => { + session.state.model = { + ...RESTRICTED_THINKING_MODEL, + provider: "openrouter", + id: "anthropic/claude-sonnet-4.5", + }; session.state.thinkingLevel = "high"; }); const client = new PiRpcAgentClient({ @@ -2588,8 +2778,6 @@ describe("PiRpcAgentClient", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/anthropic/claude-sonnet-4.5", "--thinking", "high", "--session", diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 6b5de0314e..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"; @@ -495,7 +496,6 @@ function buildResumeStartInput(input: { cwd: input.resumeConfig.cwd, env: input.launchContext?.env, session: input.sessionFile, - model: input.resumeConfig.model, thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, mcpConfigPath: input.mcpConfigFile?.path, extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined, @@ -1005,6 +1005,8 @@ function isProcessExitEvent( function isPiAgentSessionEvent(event: PiRuntimeEvent): event is PiAgentSessionEvent { switch (event.type) { + case "thinking_level_changed": + return typeof event.level === "string" && normalizePiThinkingOption(event.level) !== null; case "agent_start": case "turn_start": case "message_start": @@ -1202,6 +1204,19 @@ export class PiRpcAgentSession implements AgentSession { // turn ID; retain their errors too until the cancellation request settles. private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null; + private readonly usageSessionKey = randomUUID(); + + usageSession() { + const env = this.runtimeSession.environment; + if (this.closed) return null; + return { + provider: "pi", + model: modelToId(this.state.model) ?? undefined, + env, + sessionKey: this.usageSessionKey, + }; + } + constructor(options: PiRpcAgentSessionOptions) { this.runtimeSession = options.runtimeSession; this.config = options.config; @@ -2123,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(); @@ -2356,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) { @@ -2577,10 +2605,14 @@ export class PiRpcAgentClient implements AgentClient { throw error; } try { + const initialState = await this.applyResumeModel(runtimeSession, resumeConfig.model); return new PiRpcAgentSession({ runtimeSession, - config: resumeConfig.config, - initialState: await runtimeSession.getState(), + config: { + ...resumeConfig.config, + model: modelToId(initialState.model) ?? resumeConfig.config.model, + }, + initialState, capabilities: capabilitiesForSession(mcp !== null), cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), extensionTimeoutMs: providerOptions.extensionTimeoutMs, @@ -2595,6 +2627,35 @@ export class PiRpcAgentClient implements AgentClient { } } + // Pi resumes a session on the model it recorded, or on its default when that model + // is gone. Switching afterwards keeps a removed model from blocking the resume. + private async applyResumeModel( + runtimeSession: PiRuntimeSession, + requestedModel: string | undefined, + ): Promise<PiSessionState> { + const state = await runtimeSession.getState(); + const reference = parseModelReference(requestedModel ?? null); + if (!reference?.provider) { + return state; + } + const { provider, id } = reference; + const isRequested = (model: PiModel | null | undefined) => + model?.provider === provider && model.id === id; + if (isRequested(state.model)) { + return state; + } + const availableModels = await runtimeSession.getAvailableModels(); + if (!availableModels.some(isRequested)) { + this.logger.warn( + { requestedModel, sessionModel: modelToId(state.model) }, + "Pi resumed on the session's model because the requested model is unavailable", + ); + return state; + } + await runtimeSession.setModel(provider, id); + return runtimeSession.getState(); + } + async fetchCatalog( options: FetchCatalogOptions, context?: ProviderRefreshContext, diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts index c178bb5405..463870760f 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts @@ -146,7 +146,12 @@ describe("PiCliRuntime", () => { const launches: PiRuntimeLaunch[] = []; const runtime = createRuntime(child, launches); - const session = await runtime.startSession({ cwd: "/workspace/project" }); + const session = await runtime.startSession({ + cwd: "/workspace/project", + env: { HOME: "/fixture/pi-home" }, + }); + expect(session.environment).toBe(launches[0]?.env); + expect(session.environment?.HOME).toBe("/fixture/pi-home"); await expect(session.getState()).resolves.toMatchObject({ sessionId: "pi-session-1", @@ -287,7 +292,10 @@ describe("PiCliRuntime", () => { const state = session.getState(); child.stderr.write("boom"); + const environment = session.environment; child.emit("exit", 1, null); + expect(session.environment).toBe(environment); + expect(environment).toBeDefined(); await expect(state).rejects.toThrow("boom"); }); diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 0262ac13a2..62ee01c503 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -1,3 +1,4 @@ +import { createExternalProcessEnv } from "../../../paseo-env.js"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; @@ -56,6 +57,7 @@ export class PiCliRuntime implements PiRuntime { runtimeSettings: this.options.runtimeSettings, session: input, }); + launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {}); const [command, ...args] = launch.argv; const processLaunch: JsonlRpcLaunch = { command, @@ -76,7 +78,7 @@ export class PiCliRuntime implements PiRuntime { await process.close(input.signal.reason); input.signal.throwIfAborted(); } - return new PiCliRuntimeSession(process, this.commandsRpcName); + return new PiCliRuntimeSession(process, this.commandsRpcName, launch.env); } } @@ -86,6 +88,7 @@ class PiCliRuntimeSession implements PiRuntimeSession { constructor( private readonly process: JsonlRpcProcess, private readonly commandsRpcName: string, + private readonly launchEnvironment: Record<string, string>, ) { process.onMessage((message) => { this.emit(message as PiRuntimeEvent); @@ -95,6 +98,10 @@ class PiCliRuntimeSession implements PiRuntimeSession { }); } + get environment(): Record<string, string> { + return this.launchEnvironment; + } + onEvent(callback: (event: PiRuntimeEvent) => void): () => void { this.subscribers.add(callback); return () => { 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/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 3582a902bd..1064952e6d 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -40,6 +40,7 @@ export interface PiStartSessionInput { } export interface PiRuntimeSession { + readonly environment: Record<string, string>; onEvent(callback: (event: PiRuntimeEvent) => void): () => void; prompt( message: string, diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 50a0bf9532..b8c7930bc4 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -57,6 +57,7 @@ export class FakePi implements PiRuntime { private readonly command: [string, ...string[]]; private readonly queuedCommands: PiRpcSlashCommand[][] = []; private readonly queuedSessionSetups: Array<(session: FakePiSession) => void> = []; + private readonly removedModels = new Set<string>(); constructor(command: [string, ...string[]] = ["pi"]) { this.command = command; @@ -68,7 +69,10 @@ export class FakePi implements PiRuntime { session: input, }); this.recordedLaunches.push(launch); - const session = new FakePiSession(launch); + if (launch.model && this.removedModels.has(launch.model)) { + throw new Error(`Model "${launch.model}" not found`); + } + const session = new FakePiSession(launch, this.removedModels); session.commands = this.queuedCommands.shift() ?? []; this.queuedSessionSetups.shift()?.(session); this.sessions.push(session); @@ -83,6 +87,11 @@ export class FakePi implements PiRuntime { this.queuedSessionSetups.push(setup); } + // Pi refuses to launch with, or switch to, a model it no longer knows. + removeModel(modelId: string): void { + this.removedModels.add(modelId); + } + latestSession(): FakePiSession { const session = this.sessions.at(-1); if (!session) { @@ -93,6 +102,7 @@ export class FakePi implements PiRuntime { } export class FakePiSession implements PiRuntimeSession { + readonly environment: Record<string, string>; readonly prompts: Array<{ message: string; imageCount: number }> = []; readonly steerCalls: Array<{ message: string; imageCount: number }> = []; steerError: Error | null = null; @@ -144,7 +154,11 @@ export class FakePiSession implements PiRuntimeSession { private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null; - constructor(launch: PiRuntimeLaunch) { + constructor( + launch: PiRuntimeLaunch, + private readonly removedModels: ReadonlySet<string> = new Set(), + ) { + this.environment = launch.env ?? {}; this.state = { model: null, thinkingLevel: "medium", @@ -265,6 +279,9 @@ export class FakePiSession implements PiRuntimeSession { async setModel(provider: string, modelId: string): Promise<PiModel> { this.setModelRequests.push({ provider, modelId }); + if (this.removedModels.has(`${provider}/${modelId}`)) { + throw new Error(`Model not found: ${provider}/${modelId}`); + } if (!this.setModelResult) { throw new Error("FakePi setModel requires setModelResult to be scripted"); } diff --git a/packages/server/src/server/agent/providers/question-card-answer.test.ts b/packages/server/src/server/agent/providers/question-card-answer.test.ts new file mode 100644 index 0000000000..c7049fa7dc --- /dev/null +++ b/packages/server/src/server/agent/providers/question-card-answer.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; + +import { splitMultiSelectAnswer } from "./question-card-answer.js"; + +describe("splitMultiSelectAnswer", () => { + const labels = ["Delete both", "Delete both, fix code", "Email the user"]; + + test("splits checked labels in click order", () => { + expect(splitMultiSelectAnswer("Email the user, Delete both", labels)).toEqual({ + selected: ["Email the user", "Delete both"], + custom: null, + }); + }); + + test("keeps a label that contains a comma as one selection", () => { + expect(splitMultiSelectAnswer("Delete both, fix code, Email the user", labels)).toEqual({ + selected: ["Delete both, fix code", "Email the user"], + custom: null, + }); + }); + + test("returns trailing text as the typed answer", () => { + expect(splitMultiSelectAnswer("Delete both, Also check the logs", labels)).toEqual({ + selected: ["Delete both"], + custom: "Also check the logs", + }); + expect(splitMultiSelectAnswer("Only typed text", labels)).toEqual({ + selected: [], + custom: "Only typed text", + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/question-card-answer.ts b/packages/server/src/server/agent/providers/question-card-answer.ts new file mode 100644 index 0000000000..01513869c9 --- /dev/null +++ b/packages/server/src/server/agent/providers/question-card-answer.ts @@ -0,0 +1,23 @@ +/** + * The shared question card sends a multi-select answer as one string: the checked option + * labels in click order, then any typed answer, joined with ", ". Consume exact option + * labels from the front, longest first so a label containing ", " wins over its prefix; + * any remaining text is the typed answer. + */ +export function splitMultiSelectAnswer( + answer: string, + labels: string[], +): { selected: string[]; custom: string | null } { + let remaining = answer; + const selected: string[] = []; + while (remaining.length > 0) { + const label = labels + .filter((candidate) => !selected.includes(candidate)) + .sort((left, right) => right.length - left.length) + .find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `)); + if (!label) break; + selected.push(label); + remaining = remaining === label ? "" : remaining.slice(label.length + 2); + } + return { selected, custom: remaining || null }; +} diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index d8128a6a2b..1f72cdc498 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -87,7 +87,8 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail) return incoming; } -function mergeToolCallItems( +/** Fold a later update for the same tool call into the one it follows. */ +export function mergeToolCallItems( existing: Extract<AgentTimelineItem, { type: "tool_call" }>, incoming: Extract<AgentTimelineItem, { type: "tool_call" }>, ): Extract<AgentTimelineItem, { type: "tool_call" }> { diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 07f9f21098..dae9c16f4f 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,4 @@ +import { AgentDirectory } from "../directory.js"; import { stat } from "node:fs/promises"; import { z } from "zod"; import { ensureValidJson } from "../../json-utils.js"; @@ -59,9 +60,9 @@ import { sanitizePermissionRequest, serializeSnapshotWithMetadata, toScheduleSummary, - waitForAgentWithTimeout, } from "../mcp-shared.js"; import { + type PromptDispatchDisposition, sendPromptToAgent, setupFinishNotification, waitForAgentRunStartWithTimeout, @@ -111,7 +112,7 @@ export interface PaseoToolHostDependencies { github?: ForgeService; workspaceGitService?: Pick< WorkspaceGitService, - "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" | "invalidateWorktrees" >; findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; @@ -123,6 +124,7 @@ export interface PaseoToolHostDependencies { cwd: string, title?: string | null, projectId?: string, + context?: { background?: boolean; callerWorkspaceId?: string }, ) => Promise<PersistedWorkspaceRecord>; workspaceScripts?: Pick<WorkspaceScriptsService, "list" | "launch" | "stop">; markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; @@ -132,6 +134,7 @@ export interface PaseoToolHostDependencies { ensureWorkspaceForCreate?: ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ) => Promise<string>; browserToolsEnabled?: boolean; browserToolsBroker?: BrowserToolsBroker | null; @@ -189,6 +192,7 @@ const WorkspaceAutomationSummarySchema = z.object({ isolation: z.enum(["local", "worktree"]), kind: z.enum(["directory", "local_checkout", "worktree"]), title: z.string().nullable(), + background: z.boolean().optional(), }); function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { @@ -199,6 +203,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { isolation: workspace.kind === "worktree" ? ("worktree" as const) : ("local" as const), kind: workspace.kind, title: workspace.title, + background: workspace.background, }; } @@ -1047,13 +1052,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const canonicalTopLevelInputSchema = { ...canonicalCreateAgentFields, - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), notifyOnFinish: z .boolean() .optional() @@ -1071,7 +1069,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...commonCreateAgentFields, relationship: legacyCreateAgentPlacementFields.relationship.optional(), workspace: legacyCreateAgentPlacementFields.workspace.optional(), - background: canonicalTopLevelInputSchema.background, notifyOnFinish: canonicalTopLevelInputSchema.notifyOnFinish, cwd: z .string() @@ -1129,13 +1126,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const agentToAgentSendAgentPromptInputSchema = { ...commonSendAgentPromptInputSchema, - background: z - .boolean() - .optional() - .default(true) - .describe( - "Run agent in background. Agent-scoped default is true so you can continue until the finish notification arrives. Set false only when you need a blocking response.", - ), notifyOnFinish: z .boolean() .optional() @@ -1146,13 +1136,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const topLevelSendAgentPromptInputSchema = { ...commonSendAgentPromptInputSchema, - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), notifyOnFinish: z .boolean() .optional() @@ -1249,6 +1232,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ), projectId: z.string().optional().describe("Existing project id to own the workspace."), title: z.string().trim().min(1).optional(), + background: z + .boolean() + .optional() + .describe( + "True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks.", + ), mode: z .enum(["branch-off", "checkout-branch", "checkout-pr"]) .optional() @@ -1287,6 +1276,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase path, projectId, title, + background, mode, worktreeSlug, branchName, @@ -1316,7 +1306,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (!options.createDirectoryWorkspace) { throw new Error("Workspace provisioning is not configured"); } - workspace = await options.createDirectoryWorkspace(cwd, title, projectId); + workspace = await options.createDirectoryWorkspace(cwd, title, projectId, { + background, + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }); } else { let cwd = path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null; @@ -1347,6 +1340,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(worktreeSlug ? { worktreeSlug } : {}), ...worktreeTarget, ...(title ? { title } : {}), + background, + callerWorkspaceId: resolveCallerAgent()?.workspaceId, }, ); if (!result.ok) { @@ -1367,15 +1362,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { title: "List workspaces", description: "List active workspaces.", - inputSchema: {}, + inputSchema: { includeBackground: z.boolean().optional().default(false) }, outputSchema: { workspaces: z.array(WorkspaceAutomationSummarySchema) }, }, - async () => { + async ({ includeBackground = false }) => { if (!options.workspaceRegistry) { throw new Error("Workspace registry is not configured"); } const workspaces = (await options.workspaceRegistry.list()) - .filter((workspace) => !workspace.archivedAt) + .filter( + (workspace) => !workspace.archivedAt && (includeBackground || !workspace.background), + ) .map(toWorkspaceAutomationSummary); return { content: [], @@ -1442,30 +1439,21 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase workspaceId: z.string().optional(), currentModeId: z.string().nullable(), availableModes: z.array(ProviderModeSchema), - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), guidance: z.string().optional(), }, }, async (args: unknown) => { const resolvedArgs = await resolveCreateAgentToolArgs(args); const { parsedArgs, worktree } = resolvedArgs; - let requestedBackground: boolean; let notifyOnFinish: boolean; if (resolvedArgs.kind === "agent-scoped") { - requestedBackground = true; notifyOnFinish = parsedArgs.notifyOnFinish; } else { - requestedBackground = resolvedArgs.parsedArgs.background; notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; } const selectedProvider = resolveRequiredProviderModel(parsedArgs.provider).provider; const inheritedConfig = resolveInheritedProviderConfig(selectedProvider); - const { - snapshot, - background: createdInBackground, - initialPromptStarted, - } = await createAgentCommand( + const { snapshot, initialPromptStarted } = await createAgentCommand( { agentManager, agentStorage, @@ -1491,7 +1479,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase features: parsedArgs.settings?.features, labels: parsedArgs.labels, mode: parsedArgs.settings?.modeId, - background: requestedBackground, + promptFailure: "throw", notifyOnFinish, detached: resolvedArgs.detached, callerAgentId, @@ -1500,43 +1488,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); - try { - if (!createdInBackground && initialPromptStarted) { - const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { - waitForActive: true, - }); - - const liveSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; - const responseData = { - agentId: snapshot.id, - type: snapshot.provider, - status: result.status, - cwd: liveSnapshot.cwd, - ...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}), - currentModeId: liveSnapshot.currentModeId, - availableModes: liveSnapshot.availableModes, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } - } catch (error) { - childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); - throw error; - } - // Return immediately for async creation. const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; const guidance = callerAgentId && notifyOnFinish && initialPromptStarted ? "You will get notified when the created agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives." - : undefined; + : "Creation acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs."; const response = { content: [], structuredContent: ensureValidJson({ @@ -1547,8 +1504,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}), currentModeId: currentSnapshot.currentModeId, availableModes: currentSnapshot.availableModes, - lastMessage: null, - permission: null, ...(guidance ? { guidance } : {}), }), }; @@ -1682,7 +1637,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const cwd = process.cwd(); return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }), }; } const caller = resolveCallerAgent(); @@ -1850,7 +1807,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }), worktree: undefined, }; } @@ -1897,88 +1856,62 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { title: "Send agent prompt", description: - "Send a task to a running agent. Agent-scoped callers run in background by default; top-level callers wait by default.", + "Send a task and return after dispatch acknowledgement. Calling agents receive finish notifications by default.", inputSchema: sendAgentPromptInputSchema, outputSchema: { success: z.boolean(), + disposition: z.enum(["turn_started", "out_of_band"]).optional(), status: AgentStatusEnum, - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), guidance: z.string().optional(), }, }, - async ({ - agentId, - prompt, - sessionMode, - background = Boolean(callerAgentId), - notifyOnFinish = Boolean(callerAgentId), - }) => { - function armFinishNotification(): boolean { + async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => { + let stopNotification: (() => void) | undefined; + function armFinishNotification(disposition: PromptDispatchDisposition): void { if (!callerAgentId || !notifyOnFinish) { - return false; + return; } - setupFinishNotification({ + stopNotification = setupFinishNotification({ agentManager, agentStorage, childAgentId: agentId, callerAgentId, + waitForTurnStart: disposition === "turn_started", logger: childLogger, }); - return true; } + const notifying = Boolean(callerAgentId && notifyOnFinish); const { disposition } = await sendPromptToAgent({ agentManager, agentStorage, agentId, prompt, + source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined, sessionMode, + onDispatch: armFinishNotification, logger: childLogger, + }).catch((error) => { + stopNotification?.(); + throw error; }); - // If not running in background, wait for completion - if (!background) { - const result = await waitForAgentWithTimeout(agentManager, agentId, { - waitForActive: true, - }); - // The wait ran out while the agent keeps working, so its result arrives as a - // finish notification instead of in this response. - const notifying = - result.timedOut && - agentManager.getAgent(agentId)?.lifecycle === "running" && - armFinishNotification(); - - const responseData = { - success: true, - status: result.status, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - ...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } - - const notifying = armFinishNotification(); - // Return once the provider has accepted the turn, so the status reports it running. if (disposition === "turn_started") { - await waitForAgentRunStartWithTimeout(agentManager, agentId); + await waitForAgentRunStartWithTimeout(agentManager, agentId).catch((error) => { + stopNotification?.(); + throw error; + }); } const currentSnapshot = agentManager.getAgent(agentId); const responseData = { success: true, + disposition, status: currentSnapshot?.lifecycle ?? "idle", - lastMessage: null, - permission: null, - ...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}), + guidance: notifying + ? PROMPTED_AGENT_NOTIFICATION_GUIDANCE + : "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.", }; const validJson = ensureValidJson(responseData); @@ -2046,6 +1979,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase title: "List agents", description: "List recent agents as compact metadata.", inputSchema: { + includeBackground: z.boolean().optional().default(false), includeArchived: z.boolean().optional().default(false), cwd: z.string().optional(), sinceHours: z @@ -2062,29 +1996,38 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase agents: z.array(AgentListItemPayloadSchema), }, }, - async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => { + async ({ + includeBackground = false, + includeArchived = false, + cwd, + sinceHours = 48, + statuses, + limit = 50, + }) => { const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined; const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd; const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000; - const liveSnapshots = agentManager.listAgents(); - const liveAgents = await Promise.all( - liveSnapshots.map((snapshot) => + if (!options.workspaceRegistry) throw new Error("Workspace registry is not configured"); + const directory = new AgentDirectory({ + manager: agentManager, + storage: agentStorage, + workspaces: options.workspaceRegistry, + projectLive: (snapshot) => serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), - ), - ); - const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); - const storedRecords = await agentStorage.list(); - const registeredProviderIds = new Set(providerSnapshotManager.listRegisteredProviderIds()); - const storedAgents = storedRecords - .filter((record) => !record.internal && !liveIds.has(record.id)) - .filter((record) => includeArchived || !record.archivedAt) - .filter( - (record) => - includeArchived || isStoredAgentProviderAvailable(record, registeredProviderIds), - ) - .map((record) => buildStoredAgentPayload(record, registeredProviderIds)); - const agents = [...liveAgents, ...storedAgents] + projectStored: (record) => + buildStoredAgentPayload( + record, + new Set(providerSnapshotManager.listRegisteredProviderIds()), + ), + isProviderVisible: () => true, + isStoredProviderAvailable: (record) => + isStoredAgentProviderAvailable( + record, + new Set(providerSnapshotManager.listRegisteredProviderIds()), + ), + }); + const agents = (await directory.list({ includeBackground, includeArchived })) .map(toAgentListItemPayload) .filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd)) .filter((agent) => !statusFilter || statusFilter.has(agent.status)) diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 214544c39c..e7e77bc0d3 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -374,6 +374,7 @@ const OUTBOUND_PERMISSION = { "project.update": "workspace.read", project_icon_response: "workspace.read", "provider.usage.list.response": "daemon.read", + "usage.list_reports.update": "daemon.read", "usage.list_reports.response": "daemon.read", provider_diagnostic_response: "daemon.read", providers_snapshot_update: ["daemon.read", "hub.execute"], 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 659ac54d02..727c7b7899 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,4 @@ +import type { PluginRegistries } from "@getpaseo/protocol/plugin-registry"; import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; import express from "express"; import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http"; @@ -125,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"; @@ -411,6 +413,8 @@ export interface PaseoDaemonConfig { skillSelection?: AgentSkillSelection; pluginsEnabled?: boolean; plugins?: Record<string, PluginSource>; + pluginRegistries?: PluginRegistries; + pluginRegistryUrl?: string; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -615,8 +619,15 @@ export async function createPaseoDaemon( }); const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsBroker = new BrowserToolsBroker({}); - const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, { - managedSources: new ManagedPluginSources(config.paseoHome), + const pluginRuntime: PluginService = new PluginService(logger, daemonConfigStore, daemonVersion, { + usageAgents: { + hasAgent: (id) => agentManager.getAgent(id) !== null, + usageSession: (id) => agentManager.usageSession(id), + }, + managedSources: new ManagedPluginSources(config.paseoHome, { + registries: config.pluginRegistries, + defaultUrl: config.pluginRegistryUrl, + }), builtinPlugins: resolveBuiltinPluginLoader(dependencies), settingsDirectory: path.join(config.paseoHome, "plugin-settings"), }); @@ -821,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) { @@ -1021,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({ @@ -1071,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; }; @@ -1160,7 +1178,6 @@ export async function createPaseoDaemon( emit: emitExternalSessionMessage, sessionLogger: logger, terminalManager, - archiveWorkspaceRecord: archiveWorkspaceRecordExternal, serviceProxy, scriptRuntimeStore, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), @@ -1390,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; @@ -1411,9 +1429,8 @@ export async function createPaseoDaemon( serviceProxyPublicBaseUrl, resolveScriptHealth: (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), logger, - // MCP operations do not belong to one WebSocket session, so lifecycle - // status updates fan out to every connected client. emit: (message) => wsServer?.broadcast(wrapSessionMessage(message)), + publishStatusUpdate: (message) => wsServer?.publishScriptStatusUpdate(message), spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId), @@ -1807,6 +1824,11 @@ export async function createPaseoDaemon( wsServer?.prepareForShutdown(); agentManager.prepareForShutdown(); await closeAllAgents(logger, agentManager); + await withTimeout({ + promise: pluginRuntime.drainEvents(), + timeoutMs: AGENT_CLOSE_TIMEOUT_MS, + label: "drain plugin lifecycle events", + }).catch((error) => logger.warn({ err: error }, "Plugin lifecycle events did not finish")); await agentManager.flushForShutdown().catch(() => undefined); detachAgentStoragePersistence(); await agentStorage.flush().catch(() => undefined); @@ -1861,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 92bf9183ee..24134035de 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -29,6 +29,7 @@ export const DAEMON_SETTING_ENV_KEYS = [ "PASEO_LOG_ROTATE_COUNT", "PASEO_LOG_ROTATE_SIZE", "PASEO_PASSWORD", + "PASEO_PLUGIN_REGISTRY", "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 80045a4454..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -200,3 +200,18 @@ describe("server config", () => { ).toBe(path.join(packageRoot, "app-dist")); }); }); + +test("loads private plugin registry settings through the configuration boundary", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-")); + try { + const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } }; + await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries })); + const config = loadConfig(home, { + env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" }, + }); + expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); + expect(config.pluginRegistries).toEqual(pluginRegistries); + } 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 a683ec69fb..7b2d06560e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -626,6 +626,8 @@ export function resolveConfigFromPersisted( skillSelection: persisted.agents?.skills?.selection, pluginsEnabled: persisted.pluginsEnabled ?? false, plugins: persisted.plugins, + pluginRegistries: persisted.pluginRegistries, + pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/creation/creation.e2e.test.ts b/packages/server/src/server/creation/creation.e2e.test.ts index f0d21be18f..f4d35bd3f3 100644 --- a/packages/server/src/server/creation/creation.e2e.test.ts +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import type { z } from "zod"; +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import { WebSocket } from "ws"; import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; import { randomUUID } from "node:crypto"; @@ -9,10 +10,23 @@ import { join } from "node:path"; import { expect, test, vi } from "vitest"; import pino from "pino"; import type { CreationSnapshot, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +function createCreationDaemon(options: Parameters<typeof createTestPaseoDaemon>[0] = {}) { + const agentClients = options.agentClients ?? createTestAgentClients(); + return createTestPaseoDaemon({ + ...options, + agentClients, + // Installed provider CLIs must not change when background naming starts. + providerOverrides: Object.fromEntries( + BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: id in agentClients }]), + ), + }); +} + function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { @@ -29,7 +43,7 @@ test("creation progresses before agent readiness and continues after the disconn const directory = await mkdtemp(join(tmpdir(), "creation-wire-")); let agents = 0; let prompts = 0; - const daemon = await createTestPaseoDaemon({ + const daemon = await createCreationDaemon({ logger: pino( { level: "trace" }, { @@ -41,11 +55,13 @@ test("creation progresses before agent readiness and continues after the disconn }, ), agentClients: createTestAgentClients({ - beforeCreateSession: async () => { + beforeCreateSession: async (config) => { + if (config.internal) return; agents++; await provider.promise; }, - onStartTurn: () => { + onStartTurn: (_prompt, config) => { + if (config.internal) return; prompts++; }, }), @@ -122,11 +138,12 @@ test("creation progresses before agent readiness and continues after the disconn await client.close(); await observer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000); async function connectCreationPeer(port: number) { + const timeout = 30_000; const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); const frames: SessionOutboundMessage[] = []; socket.on("message", (data) => { @@ -147,7 +164,9 @@ async function connectCreationPeer(port: number) { }), ); await expect - .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info")) + .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info"), { + timeout, + }) .toBe(true); return { close: () => socket.close(), @@ -160,7 +179,7 @@ async function connectCreationPeer(port: number) { (m) => "payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId, ); - await expect.poll(response).toBeDefined(); + await expect.poll(response, { timeout }).toBeDefined(); return response()!; }, }; @@ -170,7 +189,7 @@ test.each([false, true])( "workspace identity does not depend on subscribing (first subscribe=%s)", async (subscribe) => { const directory = await mkdtemp(join(tmpdir(), "creation-identity-")); - const daemon = await createTestPaseoDaemon(); + const daemon = await createCreationDaemon(); const peer = await connectCreationPeer(daemon.port); try { const request = { @@ -188,7 +207,7 @@ test.each([false, true])( } finally { peer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000, @@ -199,10 +218,13 @@ test.each(["create_agent_request", "agent.create.request"] as const)( async (type) => { const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-")); let creations = 0; - const daemon = await createTestPaseoDaemon({ + const daemon = await createCreationDaemon({ agentClients: createTestAgentClients({ - beforeCreateSession: async () => { + beforeCreateSession: async (config) => { + if (config.internal) return; creations++; + // A real provider or Git checkout may take longer than Vitest's default 1s poll budget. + await new Promise((resolve) => setTimeout(resolve, 1_200)); }, }), }); @@ -241,7 +263,7 @@ test.each(["create_agent_request", "agent.create.request"] as const)( } finally { peer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000, @@ -264,7 +286,7 @@ test("legacy keyed creation preserves checkout error codes", async () => { ], { cwd: directory, stdio: "pipe" }, ); - const daemon = await createTestPaseoDaemon({ agentClients: createTestAgentClients() }); + const daemon = await createCreationDaemon({ agentClients: createTestAgentClients() }); const peer = await connectCreationPeer(daemon.port); try { const result = await peer.request({ @@ -281,6 +303,95 @@ test("legacy keyed creation preserves checkout error codes", async () => { } finally { peer.close(); await daemon.close(); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } }, 60000); + +test.each(["agent", "workspace"] as const)( + "%s creation succeeds when the provider rejects its initial prompt", + async (kind) => { + const directory = await mkdtemp(join(tmpdir(), "creation-prompt-rejected-")); + const rejection = "Input exceeds the maximum length of 1048576 characters."; + let sessions = 0; + let prompts = 0; + const daemon = await createCreationDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async (config) => { + if (config.internal) return; + sessions++; + }, + onStartTurn: (_prompt, config) => { + if (config.internal) return; + prompts++; + throw new Error(rejection); + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.1", + }); + const snapshots: CreationSnapshot[] = []; + const agent = { + config: { provider: "codex", cwd: directory }, + initialPrompt: "Continue the attached conversation.", + clientMessageId: "draft:initial-message", + }; + const streamed: AgentTimelineItem[] = []; + const unsubscribe = daemon.daemon.agentManager.subscribe((event) => { + if (event.type === "agent_stream" && event.event.type === "timeline") { + streamed.push(event.event.item); + } + }); + try { + await client.connect(); + const submit = async () => { + const common = { + idempotencyKey: "rejected-first-prompt", + onEvent: (snapshot: CreationSnapshot) => snapshots.push(snapshot), + }; + if (kind === "agent") return client.createAgent({ ...common, ...agent }); + const result = await client.createWorkspace({ + ...common, + source: { kind: "directory", path: directory }, + agent, + }); + expect(result.error).toBeNull(); + return result.agent!; + }; + const created = await submit(); + expect(created).toMatchObject({ status: "error", lastError: rejection }); + expect(snapshots.map((snapshot) => snapshot.phase)).not.toContain("prompt_started"); + expect(snapshots.at(-1)).toMatchObject({ + phase: "completed", + error: null, + agent: { id: created.id }, + }); + const expectedTimeline = [ + { + type: "user_message", + text: agent.initialPrompt, + clientMessageId: agent.clientMessageId, + messageId: agent.clientMessageId, + }, + { type: "assistant_message", text: `[System Error] ${rejection}` }, + ]; + expect( + (await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item), + ).toEqual(expectedTimeline); + expect(streamed).toEqual(expectedTimeline); + expect((await submit()).id).toBe(created.id); + expect( + (await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item), + ).toEqual(expectedTimeline); + expect((await client.fetchAgents()).entries).toHaveLength(1); + expect({ sessions, prompts }).toEqual({ sessions: 1, prompts: 1 }); + } finally { + unsubscribe(); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + }, + 60000, +); diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index 1eaf4a7fd9..a165110ef8 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -93,7 +93,7 @@ async function fixture() { calls.push("agent"); await onReady(agent); calls.push("prompt"); - return agent; + return { agent, initialPromptStarted: true }; }, }; const updates: CreationSnapshot[] = []; @@ -129,7 +129,7 @@ test("a failed agent startup retries only that stage with the reserved IDs", asy expect(readyWorkspace).toEqual(workspace); if (attempts === 1) throw new Error("provider unavailable"); await onReady(agent); - return agent; + return { agent, initialPromptStarted: true }; }; expect(await f.service.create(f.input)).toMatchObject({ phase: "failed", @@ -210,7 +210,7 @@ test("omitted resource IDs are generated once and preserved on replay", async () f.input.createAgent = async (id, createdWorkspace, onReady) => { const created = { ...agent, id, workspaceId: createdWorkspace!.id }; await onReady(created); - return created; + return { agent: created, initialPromptStarted: true }; }; const result = await f.service.create(f.input); expect(result.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/); @@ -231,7 +231,7 @@ test("restart after a committed agent without a prompt completes without recreat await onReady(agent); // Capture the durable state at the exact milestone a process could stop after. await cp(f.directory, recoveredDirectory, { recursive: true }); - return agent; + return { agent, initialPromptStarted: false }; }; await f.service.create(f.input); const recovered = await new CreationService(recoveredDirectory, silentLogger).create(f.input); diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts index 3d7a42efdb..e0b28b8069 100644 --- a/packages/server/src/server/creation/index.ts +++ b/packages/server/src/server/creation/index.ts @@ -13,6 +13,10 @@ import { writeFileAtomic } from "../atomic-file.js"; import { generateWorkspaceId } from "../workspace-registry-model.js"; type Observer = (snapshot: CreationSnapshot) => void; +export interface CreatedAgent { + agent: AgentSnapshotPayload; + initialPromptStarted: boolean; +} interface CreationRequest { key: string; request: { [key: string]: unknown }; @@ -28,7 +32,7 @@ interface CreationRequest { agentId: string, workspace: WorkspaceDescriptorPayload | undefined, onReady: (agent: AgentSnapshotPayload) => Promise<void>, - ) => Promise<AgentSnapshotPayload>; + ) => Promise<CreatedAgent>; } export type CreationInput = CreationRequest & ( @@ -167,7 +171,7 @@ export class CreationService { if (input.hasAgent && input.createAgent && !record.snapshot.agent) { record.inFlight = "agent"; await this.write(identity, record); - const agent = await input.createAgent( + const { agent, initialPromptStarted } = await input.createAgent( record.snapshot.agentId!, record.snapshot.workspace, async (readyAgent) => { @@ -180,7 +184,7 @@ export class CreationService { }, ); record.inFlight = null; - if (input.hasPrompt) + if (initialPromptStarted) await this.publish(identity, record, { phase: "prompt_started", agent }); else record.snapshot = { ...record.snapshot, agent }; } diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index f429cb672c..b6518d773d 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -157,15 +157,19 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => { chunkSize: 5, }); + const uploadId = result.file?.id ?? ""; + expect(uploadId).toMatch( + /^upload_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); expect(result).toEqual({ requestId: "req-upload-e2e", file: { type: "uploaded_file", - id: "upload_req-upload-e2e", + id: uploadId, fileName: "notes.txt", mimeType: "text/plain", size: 11, - path: path.join(daemon.paseoHome, "uploads", "upload_req-upload-e2e", "notes.txt"), + path: path.join(daemon.paseoHome, "uploads", uploadId, "notes.txt"), }, error: null, }); @@ -831,6 +835,29 @@ test("send_agent_message auto-unarchives archived agents", async () => { } }, 180000); +test("send_agent_message leaves an archived agent archived when its directory is gone", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + const archived = await ctx.client.archiveAgent(created.id); + rmSync(cwd, { recursive: true, force: true }); + + await expect(ctx.client.sendMessage(created.id, "hello")).rejects.toThrow( + "Working directory does not exist", + ); + + const afterSend = await ctx.client.fetchAgent({ agentId: created.id }); + expect(afterSend?.agent.archivedAt).toBe(archived.archivedAt); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("refresh_agent auto-unarchives archived agents", async () => { const cwd = tmpCwd(); try { diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts new file mode 100644 index 0000000000..75302d4168 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -0,0 +1,160 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); +} + +test.each(["uncommitted", "base", "commit"] as const)( + "%s diff preserves removed SQL comments and their deletion counts", + async (mode) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-content-")); + + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "query.sql"), "-- old comment\nSELECT 1;\n"); + writeFileSync(join(cwd, "control.sql"), "SELECT 1;\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + writeFileSync(join(cwd, "query.sql"), "-- new comment\nSELECT 1;\n"); + writeFileSync(join(cwd, "control.sql"), "SELECT 2;\n"); + + expect(git(cwd, "diff", "--numstat", "HEAD", "--", "query.sql").trim()).toBe( + "1\t1\tquery.sql", + ); + + if (mode !== "uncommitted") { + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + } + + const files = + mode === "commit" + ? [ + ( + await ctx.client.getCommitFileDiff( + cwd, + git(cwd, "rev-parse", "HEAD").trim(), + "query.sql", + ) + ).file, + ( + await ctx.client.getCommitFileDiff( + cwd, + git(cwd, "rev-parse", "HEAD").trim(), + "control.sql", + ) + ).file, + ] + : (await ctx.client.getCheckoutDiff(cwd, { mode, baseRef: "main" })).files; + + const control = files.find((file) => file?.path === "control.sql"); + expect(control).toMatchObject({ additions: 1, deletions: 1 }); + expect(control?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "SELECT 1;" }), + expect.objectContaining({ type: "add", content: "SELECT 2;" }), + ]), + ); + + const query = files.find((file) => file?.path === "query.sql"); + expect + .soft(query) + .toMatchObject({ additions: 1, deletions: 1, isNew: false, isDeleted: false }); + expect + .soft(query?.hunks.flatMap((hunk) => hunk.lines)) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "-- old comment" }), + expect.objectContaining({ type: "add", content: "-- new comment" }), + expect.objectContaining({ type: "context", content: "SELECT 1;" }), + ]), + ); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +); + +test.each( + (["uncommitted", "base"] as const).flatMap((mode) => + (["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })), + ), +)( + "$mode $shape diff includes hunks from a repository subdirectory", + async ({ mode, shape }) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-")); + + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "core.autocrlf", "false"); + const subdirectory = join(cwd, "sub"); + mkdirSync(subdirectory); + const unchangedLines = "unchanged\n".repeat(10); + writeFileSync(join(subdirectory, "normal.txt"), `before\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "old sibling\n"); + } + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + if (mode === "base") { + git(cwd, "checkout", "-b", "feature"); + } + const filename = shape === "rename" ? "renamed.txt" : "normal.txt"; + if (shape === "rename") { + git(cwd, "mv", "sub/normal.txt", `sub/${filename}`); + } + writeFileSync(join(subdirectory, filename), `after\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "new sibling\n"); + } + if (mode === "base") { + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + } + + const compare = { mode, baseRef: "main" }; + const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); + const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); + const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); + const liveDiff = await subscription.ready; + await subscription.release(); + + expect(rootDiff.error).toBeNull(); + expect(subdirectoryDiff.error).toBeNull(); + expect(liveDiff.error).toBeNull(); + expect(liveDiff.files).toEqual(rootDiff.files); + expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1); + const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`); + expect(file).toMatchObject({ + additions: 1, + deletions: 1, + ...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}), + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + expect(subdirectoryDiff.files).toEqual(rootDiff.files); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts new file mode 100644 index 0000000000..ad703a3885 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +// Claude Code resumes a session by id from any working directory and keeps writing to the +// transcript under the project folder it started in. The agent's cwd can name a different +// folder: a project that moved, a daemon migrated between machines or containers, or Claude +// Code running under WSL interop and seeing the cwd as a Windows path. +// The test daemon fakes the built-in providers; a provider that extends `claude` runs the real one. +const PROVIDER_ID = "claude-real"; +const SESSION_ID = "6f0c5d8e-4c3a-4f7b-9a51-2d8e1b7c9f10"; + +function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string { + return entries + .filter( + ( + entry, + ): entry is { + item: { type: "user_message" | "assistant_message"; text: string }; + } => entry.item.type === "user_message" || entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); +} + +describe("daemon E2E - Claude history stored under another project folder", () => { + let tempRoot: string; + let paseoHomeRoot: string; + let configDir: string; + let transcriptCwd: string; + let agentCwd: string; + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-history-other-project-")); + paseoHomeRoot = path.join(tempRoot, "paseo-home"); + configDir = path.join(tempRoot, "claude-config"); + transcriptCwd = path.join(tempRoot, "where-claude-started"); + agentCwd = path.join(tempRoot, "agent-cwd"); + mkdirSync(paseoHomeRoot, { recursive: true }); + mkdirSync(agentCwd, { recursive: true }); + + const projectDir = claudeProjectDirSync(transcriptCwd, { configDir }); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + path.join(projectDir, `${SESSION_ID}.jsonl`), + [ + { + type: "user", + uuid: "user-uuid-1", + sessionId: SESSION_ID, + cwd: transcriptCwd, + message: { role: "user", content: "hello from the original folder" }, + }, + { + type: "assistant", + sessionId: SESSION_ID, + cwd: transcriptCwd, + message: { + role: "assistant", + content: "reply from the original folder", + }, + }, + { type: "custom-title", customTitle: "Renamed moved session", sessionId: SESSION_ID }, + ] + .map((entry) => `${JSON.stringify(entry)}\n`) + .join(""), + "utf8", + ); + }); + + afterEach(async () => { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + client = undefined; + daemon = undefined; + rmSync(tempRoot, { recursive: true, force: true }); + }, 60_000); + + async function startDaemon(): Promise<DaemonClient> { + daemon = await createTestPaseoDaemon({ + paseoHomeRoot, + cleanup: false, + providerOverrides: { + [PROVIDER_ID]: { + extends: "claude", + label: "Claude", + env: { CLAUDE_CONFIG_DIR: configDir }, + }, + }, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return client; + } + + async function stopDaemon(): Promise<void> { + await client?.close(); + await daemon?.close(); + client = undefined; + daemon = undefined; + } + + test("an agent's timeline survives a daemon restart", async () => { + const firstClient = await startDaemon(); + const agent = await firstClient.importAgent({ + provider: PROVIDER_ID, + sessionId: SESSION_ID, + cwd: agentCwd, + }); + expect(agent.title).toBe("Renamed moved session"); + await stopDaemon(); + + const restartedClient = await startDaemon(); + const timeline = await restartedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + + const text = timelineText(timeline.entries); + expect(text).toContain("hello from the original folder"); + expect(text).toContain("reply from the original folder"); + }, 60_000); +}); diff --git a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts index 29779fd1ef..1715fabec9 100644 --- a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts @@ -55,6 +55,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR" cwd, message: { role: "assistant", content: "reply from the custom config dir" }, }, + { type: "custom-title", customTitle: "Renamed provider session", sessionId: SESSION_ID }, ] .map((entry) => `${JSON.stringify(entry)}\n`) .join(""), @@ -111,6 +112,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR" sessionId: SESSION_ID, cwd, }); + expect(agent.title).toBe("Renamed provider session"); await stopDaemon(); const restartedClient = await startDaemon(); diff --git a/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts new file mode 100644 index 0000000000..d8388f9dd2 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts @@ -0,0 +1,89 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +// A real daemon and CLI with a deterministic provider emitting two text chunks. +// The live Claude reproduction is recorded in the PR; this locks its CLI behavior. +test("logs --follow preserves the same assistant text as one-shot logs", async () => { + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + let follow: ReturnType<typeof spawn> | undefined; + let stdout = ""; + let stderr = ""; + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ + provider: "claude", + cwd: daemon.paseoHome, + title: "Follow transcript regression", + modeId: "bypassPermissions", + }); + follow = spawn( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${daemon.port}`, + "logs", + "--follow", + agent.id, + "--tail", + "0", + "--filter", + "assistant_message", + ], + { + env: { ...process.env, PASEO_HOME: daemon.paseoHome }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const exited = once(follow, "exit"); + follow.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + follow.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + await expect + .poll( + () => { + if (follow?.exitCode !== null) { + throw new Error(`CLI exited before following logs: ${stderr}`); + } + return stdout; + }, + { timeout: 20_000 }, + ) + .toContain("Following logs"); + await client.sendMessage(agent.id, "respond with exactly: paseo-pr-cycle 한국어 보고서"); + await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle", 20_000); + const transcript = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 100, + projection: "projected", + }); + const assistantText = transcript.entries + .filter((row) => row.item.type === "assistant_message") + .map((row) => (row.item.type === "assistant_message" ? row.item.text : "")) + .join(""); + expect(assistantText).not.toBe(""); + // Completion closes the message while follow mode is still running. + // Then stop through the CLI's normal signal path. + await expect.poll(() => stdout, { timeout: 10_000 }).toContain(`${assistantText.trim()}\n`); + follow.kill("SIGINT"); + await exited; + const output = stdout.slice(stdout.indexOf("---\n") + 4).trim(); + expect(output, stderr).toBe(assistantText.trim()); + } finally { + follow?.kill("SIGTERM"); + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index f9d7fce7b4..862923dec6 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -1,5 +1,15 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + rmSync, + readdirSync, + readlinkSync, + realpathSync, + symlinkSync, +} from "fs"; +import { once } from "node:events"; +import { get } from "node:http"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -8,6 +18,41 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } +// Linux exposes the real open descriptors without replacing file or HTTP methods. +function openDownloadDescriptors(filePath: string): string[] { + const realFilePath = realpathSync(filePath); + return readdirSync("/proc/self/fd").filter((fd) => { + try { + return readlinkSync(`/proc/self/fd/${fd}`) === realFilePath; + } catch (error) { + // A descriptor can close between listing it and reading its link. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }); +} + +async function expectDownloadClosed(filePath: string): Promise<void> { + await expect.poll(() => openDownloadDescriptors(filePath)).toEqual([]); +} + +async function cancelDownloadAfterFirstChunk(url: string): Promise<void> { + const request = get(url); + try { + const [response] = await once(request, "response"); + expect(response.statusCode).toBe(200); + const [chunk] = await once(response, "data"); + expect(chunk.length).toBeGreaterThan(0); + expect(chunk.length).toBeLessThan(64 * 1024 * 1024); + const closed = once(response, "close"); + response.destroy(); + request.destroy(); + await closed; + } finally { + request.destroy(); + } +} + // Use gpt-5.4-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; @@ -24,42 +69,110 @@ describe("daemon E2E", () => { }, 60000); describe("file download tokens", () => { - test("issues token over WS and downloads via HTTP", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "download.txt"); - const fileContents = "download test payload"; - writeFileSync(filePath, fileContents, "utf-8"); + test.skipIf(process.platform !== "linux")( + "releases file descriptors after repeated HTTP download cancellations", + async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "linked-files"); + symlinkSync(mkdtempSync(path.join(directory, "files-")), cwd, "dir"); + const filePath = path.join(cwd, "large.bin"); + const fileSize = 64 * 1024 * 1024; + writeFileSync(filePath, Buffer.alloc(fileSize)); + try { + const completedToken = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(completedToken.error).toBeNull(); + const completed = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${completedToken.token}`, + ); + expect(completed.status).toBe(200); + expect((await completed.arrayBuffer()).byteLength).toBe(fileSize); + await expectDownloadClosed(filePath); - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Download Token Test Agent", - }); + for (let attempt = 0; attempt < 3; attempt++) { + const token = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(token.error).toBeNull(); + await cancelDownloadAfterFirstChunk( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${token.token}`, + ); + await expectDownloadClosed(filePath); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); - expect(agent.id).toBeTruthy(); + test.each([ + { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "報告書.md", + disposition: + "attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md", + }, + { + fileName: "보고서.txt", + disposition: + "attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + ])( + "issues token over WS and downloads $fileName via HTTP", + async ({ fileName, disposition }) => { + const cwd = tmpCwd(); + try { + const filePath = path.join(cwd, fileName); + const fileContents = "download test payload"; + writeFileSync(filePath, fileContents, "utf-8"); - const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt"); + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Download Token Test Agent", + }); - expect(tokenResponse.error).toBeNull(); - expect(tokenResponse.token).toBeTruthy(); - expect(tokenResponse.fileName).toBe("download.txt"); + expect(agent.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); - const response = await fetch( - `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, - ); + const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); - const disposition = response.headers.get("content-disposition") ?? ""; - expect(disposition).toContain("download.txt"); + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(tokenResponse.fileName).toBe(fileName); - const body = await response.text(); - expect(body).toBe(fileContents); + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, + ); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); + expect(response.headers.get("content-disposition")).toBe(disposition); + + const body = await response.text(); + expect(body).toBe(fileContents); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60000, + ); test("rejects invalid token", async () => { const response = await fetch( diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 4bb4736133..da52c4dac4 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -1,7 +1,17 @@ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs"; +import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest"; +import { + mkdtempSync, + writeFileSync, + rmSync, + mkdirSync, + symlinkSync, + readdirSync, + readlinkSync, +} from "fs"; +import { rm } from "node:fs/promises"; import { tmpdir } from "os"; import path from "path"; +import { execFileSync } from "node:child_process"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; function tmpCwd(): string { @@ -22,7 +32,526 @@ afterEach(async () => { await ctx.cleanup(); }, 60000); +describe("checkout discard", () => { + test.each([".", "apps/web"])( + "discards selected Changes from the %s workspace without changing siblings", + async (workspacePath) => { + const cwd = tmpCwd(); + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + git(["init", "-b", "main"]); + git(["config", "user.name", "Paseo Test"]); + git(["config", "user.email", "test@getpaseo.local"]); + git(["config", "core.autocrlf", "false"]); + const nested = path.join(cwd, "apps", "web"); + mkdirSync(nested, { recursive: true }); + writeFileSync(path.join(nested, "target[ab].txt"), "target base\n"); + writeFileSync(path.join(nested, "targeta.txt"), "sibling base\n"); + writeFileSync(path.join(nested, "deleted.txt"), "deleted base\n"); + git(["add", "."]); + git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]); + const creation = await ctx.client.createWorkspace({ + source: { kind: "directory", path: path.join(cwd, workspacePath) }, + title: "Discard regression", + }); + const workspaceCwd = creation.workspace?.workspaceDirectory; + if (!workspaceCwd) { + throw new Error(creation.error ?? "Workspace creation failed"); + } + + writeFileSync(path.join(nested, "target[ab].txt"), "target edited\n"); + writeFileSync(path.join(nested, "targeta.txt"), "sibling edited\n"); + writeFileSync(path.join(nested, "staged.txt"), "staged new file\n"); + writeFileSync(path.join(nested, "scratch.txt"), "untracked file\n"); + git(["--literal-pathspecs", "add", "--", "apps/web/target[ab].txt", "apps/web/staged.txt"]); + git(["rm", "apps/web/deleted.txt"]); + + const subscription = ctx.client.observeCheckoutDiff(workspaceCwd, { mode: "uncommitted" }); + try { + const changes = await subscription.ready; + expect(changes.error).toBeNull(); + expect(changes.files.map((file) => file.path).sort()).toEqual([ + "apps/web/deleted.txt", + "apps/web/scratch.txt", + "apps/web/staged.txt", + "apps/web/target[ab].txt", + "apps/web/targeta.txt", + ]); + const selectedPaths = changes.files + .filter((file) => file.path !== "apps/web/targeta.txt") + .map((file) => file.path); + const discarded = await ctx.client.checkoutDiscardChanges(workspaceCwd, { + paths: selectedPaths, + }); + expect(discarded).toMatchObject({ success: true, error: null }); + + const target = await ctx.client.readFile(cwd, "apps/web/target[ab].txt"); + expect(new TextDecoder().decode(target.bytes)).toBe("target base\n"); + const restored = await ctx.client.readFile(cwd, "apps/web/deleted.txt"); + expect(new TextDecoder().decode(restored.bytes)).toBe("deleted base\n"); + const sibling = await ctx.client.readFile(cwd, "apps/web/targeta.txt"); + expect(new TextDecoder().decode(sibling.bytes)).toBe("sibling edited\n"); + await expect(ctx.client.readFile(cwd, "apps/web/staged.txt")).rejects.toThrow(/ENOENT/); + await expect(ctx.client.readFile(cwd, "apps/web/scratch.txt")).rejects.toThrow(/ENOENT/); + expect(git(["status", "--porcelain=v1", "-z"])).toBe(" M apps/web/targeta.txt\0"); + } finally { + await subscription.release(); + } + } finally { + // afterEach shuts down the daemon and its repository observers first. + onTestFinished(() => + rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }), + ); + } + }, + ); +}); + describe("file explorer", () => { + test.each(["..", "..."])( + "workspace suggestions resolve exact paths starting with %s", + async (prefix) => { + const cwd = tmpCwd(); + const directoryName = `${prefix}docs`; + const nestedPath = `${directoryName}/notes.md`; + const fileName = `${prefix}notes.md`; + try { + mkdirSync(path.join(cwd, directoryName)); + writeFileSync(path.join(cwd, nestedPath), "nested payload\n"); + writeFileSync(path.join(cwd, fileName), "direct payload\n"); + const file = await ctx.client.readFile(cwd, nestedPath); + expect(new TextDecoder().decode(file.bytes)).toBe("nested payload\n"); + + const nestedEntry = { path: nestedPath, kind: "file" }; + const entries = [ + nestedEntry, + { path: directoryName, kind: "directory" }, + { path: fileName, kind: "file" }, + ]; + const cases = entries.flatMap((entry) => [ + { query: `./${entry.path}`, entry }, + { query: path.resolve(cwd, entry.path), entry }, + ]); + cases.unshift({ query: nestedPath, entry: nestedEntry }); + for (const { query, entry } of cases) { + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 1, + includeFiles: true, + includeDirectories: true, + matchMode: "suffix", + }); + expect(response.error).toBeNull(); + expect(response.entries).toEqual([entry]); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test("workspace suggestions exclude exact parent and sibling paths", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const sibling = path.join(directory, "workspace-other"); + try { + mkdirSync(cwd); + mkdirSync(sibling); + writeFileSync(path.join(cwd, "visible.txt"), "inside payload\n"); + writeFileSync(path.join(sibling, "outside.txt"), "outside payload\n"); + for (const query of [ + "./..", + "../workspace-other/outside.txt", + path.join(sibling, "outside.txt"), + ]) { + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 1, + includeFiles: true, + includeDirectories: true, + matchMode: "suffix", + }); + expect(response.error).toBeNull(); + expect(response.entries).toEqual([]); + } + const control = await ctx.client.getDirectorySuggestions({ + cwd, + query: "./visible.txt", + limit: 1, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + }); + expect(control.error).toBeNull(); + expect(control.entries).toEqual([{ path: "visible.txt", kind: "file" }]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test.each([ + { description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` }, + { description: "an ordinary name", fileName: "notes.txt" }, + { description: "a long Unicode name", fileName: `${"文".repeat(76)}.txt` }, + ])("saves text files with $description", async ({ fileName }) => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createFileEntry({ + cwd, + parentPath: ".", + name: fileName, + kind: "file", + }); + expect(created).toMatchObject({ path: fileName, success: true, error: null }); + writeFileSync(path.join(cwd, fileName), "original payload\n"); + const initial = await ctx.client.readFile(cwd, fileName); + + const saved = await ctx.client.writeFile({ + cwd, + path: fileName, + content: "saved payload\n", + expectedModifiedAt: initial.modifiedAt, + expectedRevision: initial.revision, + }); + expect(saved).toMatchObject({ status: "written", size: 14 }); + const after = await ctx.client.readFile(cwd, fileName); + expect(after.path).toBe(fileName); + expect(new TextDecoder().decode(after.bytes)).toBe("saved payload\n"); + + const staleSave = await ctx.client.writeFile({ + cwd, + path: fileName, + content: "stale payload\n", + expectedModifiedAt: initial.modifiedAt, + expectedRevision: initial.revision, + }); + expect(staleSave).toMatchObject({ + status: "conflict", + version: { + status: "ready", + path: fileName, + size: after.size, + modifiedAt: after.modifiedAt, + revision: after.revision, + }, + }); + const afterConflict = await ctx.client.readFile(cwd, fileName); + expect(new TextDecoder().decode(afterConflict.bytes)).toBe("saved payload\n"); + + const secondSave = await ctx.client.writeFile({ + cwd, + path: fileName, + content: "second payload\n", + expectedModifiedAt: afterConflict.modifiedAt, + expectedRevision: afterConflict.revision, + }); + expect(secondSave).toMatchObject({ status: "written", size: 15 }); + const final = await ctx.client.readFile(cwd, fileName); + expect(new TextDecoder().decode(final.bytes)).toBe("second payload\n"); + expect(readdirSync(cwd)).toEqual([fileName]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test.each([ + { + query: "implementation", + expectedPaths: [ + ".cursor/plans/implementation-plan.md", + ".codex/plans/implementation-plan.md", + ".claude/plans/implementation-plan.md", + ], + }, + { + query: ".cursor/plans/implementation", + expectedPaths: [".cursor/plans/implementation-plan.md"], + }, + ])( + "workspace suggestions find hidden agent plans for '$query'", + async ({ query, expectedPaths }) => { + const cwd = tmpCwd(); + try { + for (const directory of [".cursor", ".codex", ".claude", ".hidden"]) { + mkdirSync(path.join(cwd, directory, "plans"), { recursive: true }); + writeFileSync(path.join(cwd, directory, "plans", "implementation-plan.md"), "plan\n"); + } + + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 50, + includeFiles: true, + includeDirectories: true, + }); + expect(response.error).toBeNull(); + expect(response.entries).toHaveLength(expectedPaths.length); + expect(response.entries).toEqual( + expect.arrayContaining( + expectedPaths.map((entryPath) => ({ path: entryPath, kind: "file" })), + ), + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test.each(["..", "..."])("supports file operations on names starting with %s", async (prefix) => { + const cwd = tmpCwd(); + const fileName = `${prefix}notes.txt`; + const directoryName = `${prefix}drafts`; + try { + const createdFile = await ctx.client.createFileEntry({ + cwd, + parentPath: ".", + name: fileName, + kind: "file", + }); + expect(createdFile).toMatchObject({ path: fileName, success: true, error: null }); + writeFileSync(path.join(cwd, fileName), "notes payload\n"); + writeFileSync(path.join(cwd, ".notes.txt"), "dotfile control\n"); + writeFileSync(path.join(cwd, "notes.txt"), "ordinary control\n"); + + const createdDirectory = await ctx.client.createFileEntry({ + cwd, + parentPath: ".", + name: directoryName, + kind: "directory", + }); + expect(createdDirectory).toMatchObject({ path: directoryName, success: true, error: null }); + const createdChild = await ctx.client.createFileEntry({ + cwd, + parentPath: directoryName, + name: "inside.txt", + kind: "file", + }); + expect(createdChild).toMatchObject({ + path: `${directoryName}/inside.txt`, + success: true, + error: null, + }); + writeFileSync(path.join(cwd, directoryName, "inside.txt"), "nested payload\n"); + + const directory = await ctx.client.listDirectory(cwd, "."); + expect( + Object.fromEntries(directory.entries.map((entry) => [entry.path, entry.kind])), + ).toEqual({ + [directoryName]: "directory", + [fileName]: "file", + ".notes.txt": "file", + "notes.txt": "file", + }); + const children = await ctx.client.listDirectory(cwd, directoryName); + expect(children.entries.map((entry) => entry.path)).toEqual([`${directoryName}/inside.txt`]); + const file = await ctx.client.readFile(cwd, fileName); + expect(file.path).toBe(fileName); + expect(new TextDecoder().decode(file.bytes)).toBe("notes payload\n"); + const nestedFile = await ctx.client.readFile(cwd, `${directoryName}/inside.txt`); + expect(nestedFile.path).toBe(`${directoryName}/inside.txt`); + expect(new TextDecoder().decode(nestedFile.bytes)).toBe("nested payload\n"); + + const dotfile = await ctx.client.readFile(cwd, ".notes.txt"); + expect(new TextDecoder().decode(dotfile.bytes)).toBe("dotfile control\n"); + const ordinaryFile = await ctx.client.readFile(cwd, "notes.txt"); + expect(new TextDecoder().decode(ordinaryFile.bytes)).toBe("ordinary control\n"); + + const download = await ctx.client.requestDownloadToken(cwd, fileName); + expect(download.error).toBeNull(); + expect(download.fileName).toBe(fileName); + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${download.token}`, + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("notes payload\n"); + + const renamedFileName = `${prefix}renamed.txt`; + const renamed = await ctx.client.renameFileEntry({ + cwd, + path: fileName, + name: renamedFileName, + }); + expect(renamed).toMatchObject({ renamedPath: renamedFileName, success: true, error: null }); + const renamedFile = await ctx.client.readFile(cwd, renamedFileName); + expect(new TextDecoder().decode(renamedFile.bytes)).toBe("notes payload\n"); + + const copiedDirectoryName = `${directoryName} copy`; + const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: directoryName }); + expect(duplicated).toMatchObject({ + duplicatedPath: copiedDirectoryName, + success: true, + error: null, + }); + const copiedFile = await ctx.client.readFile(cwd, `${copiedDirectoryName}/inside.txt`); + expect(new TextDecoder().decode(copiedFile.bytes)).toBe("nested payload\n"); + + const deletedDirectory = await ctx.client.deleteFileEntry({ cwd, path: directoryName }); + expect(deletedDirectory).toMatchObject({ success: true, error: null }); + const deletedFile = await ctx.client.deleteFileEntry({ cwd, path: renamedFileName }); + expect(deletedFile).toMatchObject({ success: true, error: null }); + const remaining = await ctx.client.listDirectory(cwd, "."); + expect(remaining.entries.map((entry) => entry.path).sort()).toEqual( + [".notes.txt", "notes.txt", copiedDirectoryName].sort(), + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("rejects parent traversal and absolute paths to a sibling workspace", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const outside = path.join(directory, "workspace-sibling"); + try { + mkdirSync(cwd); + mkdirSync(outside); + writeFileSync(path.join(outside, "secret.txt"), "outside payload\n"); + for (const requestedPath of ["..", "../workspace-sibling", outside]) { + await expect(ctx.client.listDirectory(cwd, requestedPath)).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + } + for (const requestedPath of [ + "../workspace-sibling/secret.txt", + path.join(outside, "secret.txt"), + ]) { + await expect(ctx.client.readFile(cwd, requestedPath)).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + const download = await ctx.client.requestDownloadToken(cwd, requestedPath); + expect(download).toMatchObject({ + token: null, + error: "Access outside of workspace is not allowed", + }); + const deleted = await ctx.client.deleteFileEntry({ cwd, path: requestedPath }); + expect(deleted).toMatchObject({ + success: false, + error: "Access outside of workspace is not allowed", + }); + } + expect(readdirSync(outside)).toEqual(["secret.txt"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("accepts symlinks to double-dot names inside the workspace and rejects outside targets", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const outside = path.join(directory, "workspace-sibling"); + try { + mkdirSync(path.join(cwd, "..drafts"), { recursive: true }); + mkdirSync(outside); + writeFileSync(path.join(cwd, "..drafts", "notes.txt"), "inside payload\n"); + writeFileSync(path.join(outside, "secret.txt"), "outside payload\n"); + // Junctions also work on Windows without requiring symlink privileges. + symlinkSync(path.join(cwd, "..drafts"), path.join(cwd, "inside-link"), "junction"); + symlinkSync(outside, path.join(cwd, "outside-link"), "junction"); + + const listing = await ctx.client.listDirectory(cwd, "."); + expect(listing.entries.map((entry) => entry.path).sort()).toEqual([ + "..drafts", + "inside-link", + ]); + const file = await ctx.client.readFile(cwd, "inside-link/notes.txt"); + expect(file.path).toBe("inside-link/notes.txt"); + expect(new TextDecoder().decode(file.bytes)).toBe("inside payload\n"); + await expect(ctx.client.readFile(cwd, "outside-link/secret.txt")).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + const download = await ctx.client.requestDownloadToken(cwd, "outside-link/secret.txt"); + expect(download).toMatchObject({ + token: null, + error: "Access outside of workspace is not allowed", + }); + expect(readdirSync(outside)).toEqual(["secret.txt"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === "win32")( + "duplicating a folder keeps relative symlinks inside the copy", + async () => { + const cwd = tmpCwd(); + try { + mkdirSync(path.join(cwd, "template")); + writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n"); + symlinkSync("data.txt", path.join(cwd, "template", "alias.txt")); + + const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" }); + expect(duplicated).toMatchObject({ + duplicatedPath: "template copy", + success: true, + error: null, + }); + expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt"); + + const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt"); + const targetWrite = await ctx.client.writeFile({ + cwd, + path: "template copy/data.txt", + content: "copy payload\n", + expectedModifiedAt: copiedTarget.modifiedAt, + expectedRevision: copiedTarget.revision, + }); + expect(targetWrite.status).toBe("written"); + const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt"); + expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n"); + + const aliasWrite = await ctx.client.writeFile({ + cwd, + path: "template copy/alias.txt", + content: "edited through copied alias\n", + expectedModifiedAt: copiedAlias.modifiedAt, + expectedRevision: copiedAlias.revision, + }); + expect(aliasWrite.status).toBe("written"); + const original = await ctx.client.readFile(cwd, "template/data.txt"); + expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n"); + const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt"); + expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n"); + + const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" }); + expect(deleted).toMatchObject({ success: true, error: null }); + const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt"); + expect(new TextDecoder().decode(survivingAlias.bytes)).toBe( + "edited through copied alias\n", + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(process.platform === "win32")( + "duplicating a file symlink preserves an explicit absolute target", + async () => { + const cwd = tmpCwd(); + const targetPath = path.join(cwd, "data.txt"); + try { + writeFileSync(targetPath, "absolute target payload\n"); + symlinkSync(targetPath, path.join(cwd, "alias.txt")); + + const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" }); + expect(duplicated).toMatchObject({ + duplicatedPath: "alias copy.txt", + success: true, + error: null, + }); + expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath); + const copy = await ctx.client.readFile(cwd, "alias copy.txt"); + expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + test("lists directory contents", async () => { const cwd = tmpCwd(); @@ -125,3 +654,63 @@ describe("file explorer", () => { rmSync(cwd, { recursive: true, force: true }); }, 60000); // 1 minute timeout }); + +describe("commit file diffs", () => { + test.each([".", "apps/web"])("opens committed files from %s workspaces", async (relativePath) => { + const cwd = tmpCwd(); + onTestFinished(() => + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), + ); + const workspace = path.join(cwd, relativePath); + const filePath = "apps/web/notes.txt"; + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); + } + git(["init", "-b", "main"]); + git(["config", "user.name", "Test"]); + git(["config", "user.email", "test@example.com"]); + git(["config", "core.autocrlf", "false"]); + mkdirSync(path.join(cwd, "apps", "web"), { recursive: true }); + writeFileSync(path.join(cwd, filePath), "before\n"); + writeFileSync(path.join(cwd, "apps/web/untouched.txt"), "unchanged\n"); + git(["add", "."]); + git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]); + git(["checkout", "-b", "feature"]); + writeFileSync(path.join(cwd, filePath), "after\n"); + git(["add", "."]); + git(["-c", "commit.gpgsign=false", "commit", "-m", "edit"]); + const sha = git(["rev-parse", "HEAD"]).trim(); + + const history = await ctx.client.listCheckoutCommits(workspace); + const commit = history.commits.find((entry) => entry.sha === sha); + expect(commit?.files).toEqual([ + { path: filePath, additions: 1, deletions: 1, status: "modified" }, + ]); + const result = await ctx.client.getCommitFileDiff(workspace, sha, filePath); + expect(result).toEqual({ + file: { + path: filePath, + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], + }, + }); + expect(await ctx.client.getCommitFileDiff(workspace, sha, "apps/web/untouched.txt")).toEqual({ + file: null, + }); + }); +}); diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 64ba85eb48..be225f171d 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -1,5 +1,14 @@ import { test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, existsSync, rmSync, readFileSync, realpathSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + existsSync, + rmSync, + readFileSync, + realpathSync, + mkdirSync, +} from "fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -152,6 +161,78 @@ afterEach(async () => { await ctx.cleanup(); }, 60000); +interface GitCommandOptions { + cwd: string; + args: string[]; +} + +async function mergeFeatureIntoBaseWorktree(baseName: string): Promise<void> { + const directory = realpathSync(tmpCwd()); + const base = path.join(directory, baseName); + const feature = path.join(directory, "feature"); + function git({ cwd, args }: GitCommandOptions): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + mkdirSync(base); + git({ cwd: base, args: ["init", "-b", "main"] }); + git({ cwd: base, args: ["config", "user.name", "Paseo Test"] }); + git({ cwd: base, args: ["config", "user.email", "test@paseo.local"] }); + git({ cwd: base, args: ["config", "commit.gpgsign", "false"] }); + git({ cwd: base, args: ["config", "core.autocrlf", "false"] }); + git({ cwd: base, args: ["commit", "--allow-empty", "-m", "base"] }); + git({ cwd: base, args: ["worktree", "add", "-b", "feature", feature] }); + writeFileSync(path.join(feature, "feature.txt"), "feature payload\n"); + git({ cwd: feature, args: ["add", "feature.txt"] }); + git({ cwd: feature, args: ["commit", "-m", "feature"] }); + const featureCommit = git({ cwd: feature, args: ["rev-parse", "HEAD"] }).trim(); + + const merged = await ctx.client.checkoutMerge(feature, { baseRef: "main" }); + expect(merged).toMatchObject({ success: true, error: null }); + expect(git({ cwd: base, args: ["rev-parse", "HEAD"] }).trim()).toBe(featureCommit); + expect(readFileSync(path.join(base, "feature.txt"), "utf8")).toBe("feature payload\n"); + expect(git({ cwd: feature, args: ["symbolic-ref", "--short", "HEAD"] }).trim()).toBe("feature"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test.skipIf(isPlatform("win32")).each(["base\ncheckout", "base "])( + "merges into a base worktree whose path preserves %j", + mergeFeatureIntoBaseWorktree, +); + +test.each(["base checkout", "中文 checkout"])( + "merges into a base worktree named %j", + mergeFeatureIntoBaseWorktree, +); + +test("merges into a local base branch without another worktree", async () => { + const cwd = realpathSync(tmpCwd()); + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + git(["init", "-b", "main"]); + git(["config", "user.name", "Paseo Test"]); + git(["config", "user.email", "test@paseo.local"]); + git(["config", "commit.gpgsign", "false"]); + git(["commit", "--allow-empty", "-m", "base"]); + git(["checkout", "-b", "feature"]); + writeFileSync(path.join(cwd, "feature.txt"), "feature payload\n"); + git(["add", "feature.txt"]); + git(["commit", "-m", "feature"]); + const featureCommit = git(["rev-parse", "HEAD"]).trim(); + + const merged = await ctx.client.checkoutMerge(cwd, { baseRef: "main" }); + expect(merged).toMatchObject({ success: true, error: null }); + expect(git(["rev-parse", "main"]).trim()).toBe(featureCommit); + expect(git(["symbolic-ref", "--short", "HEAD"]).trim()).toBe("feature"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + test("returns diff for modified file in git repo", async () => { const cwd = tmpCwd(); diff --git a/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs new file mode 100644 index 0000000000..542c2cf2ba --- /dev/null +++ b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs @@ -0,0 +1,44 @@ +// Local ACP peer for the bundled Hoplite adapter; no Hoplite service or credentials. +const fs = require("node:fs"); +const readline = require("node:readline"); +fs.appendFileSync(process.env.HOPLITE_TEST_RECORD, "launch\n"); +const configOptions = [ + { + id: "model", + category: "model", + type: "select", + name: "Model", + currentValue: "fixture-model", + options: [{ value: "fixture-model", name: "Fixture model" }], + }, +]; +const send = (message) => + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n"); +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + let result = {}; + if (request.method === "initialize") + result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }; + if (request.method === "session/new" && request.params.cwd !== process.env.HOPLITE_TEST_CWD) { + send({ + id: request.id, + error: { code: -32602, message: "No Hoplite project for this directory" }, + }); + return; + } + if (request.method === "session/new") + result = { + sessionId: "hoplite-fixture", + configOptions, + }; + if (request.method === "session/set_config_option") result = { configOptions }; + if (request.method === "session/prompt") { + send({ + method: "_hoplite/run_phase", + params: { runId: "fixture-run", phase: "provisioning", label: "Preparing fixture workspace" }, + }); + result = { stopReason: "end_turn" }; + } + send({ id: request.id, result }); +}); diff --git a/packages/server/src/server/daemon-e2e/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts index 1f2086c879..cfa420654b 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -1,7 +1,8 @@ import { describe, test, expect } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { loadConfig } from "../config.js"; import { findExecutable } from "../../executable-resolution/executable-resolution.js"; import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -124,15 +125,12 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({ try { await client.connect(); await client.fetchAgents({ subscribe: {} }); - await expect - .poll( - async () => { - const snapshot = await client.getProvidersSnapshot({ cwd }); - return snapshot.entries.find((entry) => entry.provider === "antigravity")?.status; - }, - { timeout: 30000 }, - ) - .toBe("ready"); + // Snapshot reads start discovery again; await one refresh before checking readiness. + await client.refreshProvidersSnapshot({ cwd, providers: ["antigravity"] }); + const snapshot = await client.getProvidersSnapshot({ cwd }); + expect(snapshot.entries.find((entry) => entry.provider === "antigravity")?.status).toBe( + "ready", + ); const agent = await client.createAgent({ provider: "antigravity", cwd, @@ -165,3 +163,133 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({ await rm(cwd, { recursive: true, force: true }); } }, 120000); + +test("bundled Hoplite requires a config entry and removal survives restart", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-hoplite-optin-")); + const record = path.join(root, "launches.txt"); + await writeFile(record, ""); + const options = { + paseoHomeRoot: root, + cleanup: false, + mcpEnabled: false, + builtinPlugins: new BuiltinPluginLoader(undefined, [ + "hoplite-provider", + "antigravity-provider", + ]), + }; + let daemon = await createTestPaseoDaemon(options); + const connect = async () => { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.2", + }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return client; + }; + let client = await connect(); + try { + const before = await client.getProvidersSnapshot(); + expect(before.entries.some((entry) => entry.provider === "hoplite")).toBe(false); + expect(before.entries.some((entry) => entry.provider === "antigravity")).toBe(true); + expect(await readFile(record, "utf8")).toBe(""); + + await client.patchDaemonConfig({ + providers: { + hoplite: { + enabled: true, + command: [process.execPath, path.resolve(import.meta.dirname, "hoplite-fixture.cjs")], + env: { HOPLITE_TEST_RECORD: record, HOPLITE_TEST_CWD: root }, + }, + }, + }); + const globalModels = await client.listProviderModels("hoplite"); + expect(globalModels.error).toBeNull(); + expect(globalModels.models).toEqual([]); + await expect + .poll( + async () => + (await client.getProvidersSnapshot()).entries.find( + (entry) => entry.provider === "hoplite", + )?.status, + ) + .toBe("ready"); + const workspaceModels = await client.listProviderModels("hoplite", { cwd: root }); + expect(workspaceModels.error).toBeNull(); + expect(workspaceModels.models.map((model) => model.id)).toEqual(["fixture-model"]); + await client.refreshProvidersSnapshot({ providers: ["hoplite"] }); + expect((await client.listProviderModels("hoplite")).models).toEqual([]); + expect( + (await client.listProviderModels("hoplite", { cwd: root })).models.map((model) => model.id), + ).toEqual(["fixture-model"]); + const agent = await client.createAgent({ + provider: "hoplite", + cwd: root, + model: "fixture-model", + }); + await client.sendMessage(agent.id, "Exercise the contributor transformer"); + await client.waitForFinish(agent.id, 10000); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + expect(timeline.entries.map((entry) => entry.item)).toContainEqual( + expect.objectContaining({ + type: "tool_call", + name: "Hoplite workspace", + status: "completed", + }), + ); + await client.archiveAgent(agent.id); + expect(await readFile(record, "utf8")).toContain("launch"); + + await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } }); + expect( + (await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite") + ?.enabled, + ).toBe(false); + await client.close(); + await daemon.close(); + daemon = await createTestPaseoDaemon({ + ...options, + staticDir: daemon.staticDir, + providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides, + }); + client = await connect(); + expect( + (await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite") + ?.enabled, + ).toBe(false); + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }); + expect( + (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"), + ).toBe(false); + expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined(); + const persisted = JSON.parse( + await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"), + ); + expect(persisted.agents?.providers?.hoplite).toBeUndefined(); + const launchesAfterRemoval = await readFile(record, "utf8"); + await client.close(); + await daemon.close(); + daemon = await createTestPaseoDaemon({ + ...options, + staticDir: daemon.staticDir, + providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides, + }); + client = await connect(); + const restarted = await client.getProvidersSnapshot(); + expect(restarted.entries.some((entry) => entry.provider === "hoplite")).toBe(false); + expect(restarted.entries.map((entry) => entry.provider)).toEqual( + before.entries.map((entry) => entry.provider), + ); + expect(await readFile(record, "utf8")).toBe(launchesAfterRemoval); + await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } }); + expect( + (await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite") + ?.enabled, + ).toBe(false); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + await rm(daemon.staticDir, { recursive: true, force: true }); + } +}, 60000); diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index 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 9c27d2039c..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 @@ -192,3 +192,80 @@ test("listing by workspace ID keeps terminals in a shared directory separate", a expect.objectContaining({ id: created.terminal?.id, workspaceId: second, cwd }), ]); }); + +test("script status started and stopped by one client reaches every subscribed client", async () => { + await writeFile( + path.join(cwd, "paseo.json"), + JSON.stringify({ + scripts: { sleeper: { type: "script", command: 'node -e "setInterval(() => {}, 60000)"' } }, + }), + ); + const observer = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const bystander = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await observer.connect(); + await bystander.connect(); + const bystanderEvents: string[] = []; + bystander.subscribeRawMessages((message) => bystanderEvents.push(message.type)); + const opened = await client.openProject(cwd); + expect(opened.error).toBeNull(); + const workspaceId = opened.workspace!.id; + const actorSeen = await observeScriptLifecycles(client, "sleeper"); + const observerSeen = await observeScriptLifecycles(observer, "sleeper"); + + expect((await client.startWorkspaceScript(workspaceId, "sleeper")).error).toBeNull(); + await expect.poll(() => actorSeen.at(-1)).toBe("running"); + await expect.poll(() => observerSeen.at(-1)).toBe("running"); + + expect((await client.stopWorkspaceScript(workspaceId, "sleeper")).error).toBeNull(); + await expect.poll(() => actorSeen.at(-1)).toBe("stopped"); + await expect.poll(() => observerSeen.at(-1)).toBe("stopped"); + expect(bystanderEvents).not.toContain("script_status_update"); + } finally { + await observer.close(); + await bystander.close(); + } +}, 60_000); + +async function observeScriptLifecycles( + daemonClient: DaemonClient, + scriptName: string, +): Promise<string[]> { + const lifecycles: string[] = []; + const subscription = daemonClient.observeEvents(["script_status_update"]); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "script_status_update") return; + const script = message.payload.scripts.find((entry) => entry.scriptName === scriptName); + if (script) lifecycles.push(script.lifecycle); + }, + }); + await subscription.ready; + return lifecycles; +} + +test.runIf(process.platform !== "win32")( + "a missing command fails creation without leaving a terminal listed", + async () => { + const workspaceId = await createWorkspace("Missing command"); + await expect( + sdk.terminals.create({ + workspaceId, + name: "Missing command", + command: "paseo-test-absent-terminal-command", + }), + ).rejects.toThrow("paseo-test-absent-terminal-command: command not found"); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); + const control = await sdk.terminals.create({ + workspaceId, + command: process.execPath, + args: ["-e", "process.stdin.resume()"], + }); + expect((await sdk.terminals.list({ workspaceId })).entries.map((entry) => entry.id)).toEqual([ + control.id, + ]); + await control.kill(); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); + }, +); diff --git a/packages/server/src/server/daemon-instance.test.ts b/packages/server/src/server/daemon-instance.test.ts index 9d7ea66c5a..ee428b2e58 100644 --- a/packages/server/src/server/daemon-instance.test.ts +++ b/packages/server/src/server/daemon-instance.test.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir, uptime } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js"; import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js"; @@ -94,3 +94,42 @@ describe("daemon instance identity across a reboot", () => { await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/); }); }); + +// Linux keeps a boot's id while a VM is paused for a host sleep, though the VM's wall clock +// runs ahead of its uptime once it resumes. A sleep longer than the time between boot and +// the supervisor's start puts the wall-clock boot instant after the lock's startedAt. +describe.runIf(process.platform === "linux")("daemon instance identity on Linux", () => { + let paseoHome: string; + + beforeEach(async () => { + paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-linux-")); + }); + + afterEach(async () => { + vi.useRealTimers(); + await rm(paseoHome, { recursive: true, force: true }); + }); + + test("a supervisor still holds the lock after its paused VM resumes", async () => { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid }); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + uptime() * 1000 + 12 * 60 * 60_000); + + expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid }); + expect(await isLocked(paseoHome)).toMatchObject({ locked: true }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + }); + + test("a lock written during another boot has no running owner", async () => { + await writeLock(paseoHome, { + ...lockFor(process.pid, new Date()), + bootId: "00000000-0000-0000-0000-000000000000", + }); + + expect(await readDaemonInstance(paseoHome)).toBeNull(); + expect(await isLocked(paseoHome)).toMatchObject({ locked: false }); + }); +}); diff --git a/packages/server/src/server/directory-sync/index.ts b/packages/server/src/server/directory-sync/index.ts index 1b08360cb7..e16e20302f 100644 --- a/packages/server/src/server/directory-sync/index.ts +++ b/packages/server/src/server/directory-sync/index.ts @@ -69,27 +69,29 @@ export class DirectorySyncService { synchronizeWorkspaces( snapshot: Iterable<WorkspaceDescriptorPayload>, cursor: DirectorySyncCursor, + includeBackground = false, ): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> { this.workspaces.replaceAll(snapshot); - const read = this.read(this.workspaces, cursor); + const read = this.read(this.workspaces, cursor, includeBackground); return { entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), emptyProjects: [], pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - sync: this.metadata(read), + sync: this.metadata(read, includeBackground), }; } synchronizeAgents( snapshot: Iterable<AgentDirectoryEntry>, cursor: DirectorySyncCursor, + includeBackground = false, ): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> { this.agents.replaceAll(snapshot); - const read = this.read(this.agents, cursor); + const read = this.read(this.agents, cursor, includeBackground); return { entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - sync: this.metadata(read), + sync: this.metadata(read, includeBackground), }; } @@ -111,13 +113,19 @@ export class DirectorySyncService { value: WorkspaceDescriptorPayload | null, id: string, includeSequence: boolean, + includeBackground = false, ): T & Partial<DirectoryVersion> { if (value) { this.workspaces.replace(value); } else { this.workspaces.remove(id); } - return this.withVersion(payload, includeSequence, this.version(this.workspaces, id)); + return this.withVersion( + payload, + includeSequence, + this.version(this.workspaces, id), + includeBackground, + ); } sequenceAgentUpdate<T extends object>( @@ -125,18 +133,24 @@ export class DirectorySyncService { value: AgentDirectoryEntry | null, id: string, includeSequence: boolean, + includeBackground = false, ): T & Partial<DirectoryVersion> { if (value) { this.agents.replace(value); } else { this.agents.remove(id); } - return this.withVersion(payload, includeSequence, this.version(this.agents, id)); + return this.withVersion( + payload, + includeSequence, + this.version(this.agents, id), + includeBackground, + ); } - private metadata(read: CollectionRead<unknown>) { + private metadata(read: CollectionRead<unknown>, includeBackground = false) { return { - generation: this.generation, + generation: this.generationFor(includeBackground), headSeq: read.headSeq, mode: read.mode, ...(read.reason ? { reason: read.reason } : {}), @@ -148,15 +162,19 @@ export class DirectorySyncService { payload: T, includeSequence: boolean, version: DirectoryVersion | null, + includeBackground = false, ): T & Partial<DirectoryVersion> { - return version && includeSequence ? { ...payload, ...version } : payload; + return version && includeSequence + ? { ...payload, ...version, generation: this.generationFor(includeBackground) } + : payload; } private read<T>( collection: VersionedCollection<T>, cursor: DirectorySyncCursor, + includeBackground = false, ): CollectionRead<T> { - if (cursor.generation !== this.generation) { + if (cursor.generation !== this.generationFor(includeBackground)) { return collection.readSnapshot( cursor.generation === undefined ? "no_cursor" : "generation_changed", ); @@ -167,6 +185,10 @@ export class DirectorySyncService { return collection.readAfter(cursor.afterSeq); } + private generationFor(includeBackground: boolean): string { + return includeBackground ? `${this.generation}:background` : this.generation; + } + private version<T>(collection: VersionedCollection<T>, id: string): DirectoryVersion | null { const seq = collection.sequenceFor(id); return seq === null ? null : { generation: this.generation, seq }; diff --git a/packages/server/src/server/file-download/content-disposition.test.ts b/packages/server/src/server/file-download/content-disposition.test.ts new file mode 100644 index 0000000000..46748c21ef --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { formatAttachmentContentDisposition } from "./content-disposition.js"; + +describe("formatAttachmentContentDisposition", () => { + test.each([ + { fileName: "report.txt", disposition: 'attachment; filename="report.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "Résumé Ångström.pdf", + disposition: + "attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + { + fileName: 'say "hi" \\ bye.txt', + disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"', + }, + { + fileName: "100%25 done.txt", + disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt", + }, + { + fileName: "l'été (1)*.txt", + disposition: + "attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt", + }, + ])("$fileName", ({ fileName, disposition }) => { + expect(formatAttachmentContentDisposition(fileName)).toBe(disposition); + }); +}); diff --git a/packages/server/src/server/file-download/content-disposition.ts b/packages/server/src/server/file-download/content-disposition.ts new file mode 100644 index 0000000000..1ad7997b7c --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.ts @@ -0,0 +1,32 @@ +const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g; +const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/; +const QUOTED_STRING_SPECIALS = /[\\"]/g; +const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g; + +/** + * Content-Disposition for a file download (RFC 6266). + * + * Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because + * browsers do not read raw Latin-1 bytes in `filename` and fall back to a name + * taken from the URL. A name with a percent escape gets it too, so browsers do + * not decode the escape in `filename`. + */ +export function formatAttachmentContentDisposition(fileName: string): string { + const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?"); + const disposition = `attachment; filename=${quote(asciiFileName)}`; + if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) { + return disposition; + } + return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`; +} + +function quote(value: string): string { + return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`; +} + +function encodeExtendedValue(value: string): string { + return encodeURIComponent(value).replace( + NON_ATTR_CHARS_LEFT_BY_URI_ENCODING, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index ea22ac24b9..a82e1a8f80 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -12,8 +12,8 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { runGitCommand } from "../../utils/run-git-command.js"; +import { describe, expect, it, vi } from "vitest"; +import { runGitCommand, snapshotGitCommandRuntimeMetrics } from "../../utils/run-git-command.js"; import { createExplorerEntry, deleteExplorerEntry, @@ -517,6 +517,129 @@ describe("file explorer service", () => { } }); + it("renames an untracked bracketed filename without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-entry-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "i.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i.tsx"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "untracked\n", "utf8"); + + snapshotGitCommandRuntimeMetrics(); + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(snapshotGitCommandRuntimeMetrics().operationsTop).toEqual([ + ["ls-files", 1], + ["rev-parse", 1], + ]); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("renames an untracked bracketed folder without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-folder-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.join(root, "i")); + await writeFile(path.join(root, "i", "route.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i"], { cwd: root }); + await mkdir(path.join(root, "[id]")); + await writeFile(path.join(root, "[id]", "route.tsx"), "untracked\n", "utf8"); + + const result = await renameExplorerEntry({ root, relativePath: "[id]", name: "route" }); + + expect(result).toEqual({ status: "ok", path: "route" }); + expect(await readFile(path.join(root, "route", "route.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id]"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i", "route.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i/route.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("uses Git to rename a tracked bracketed filename and preserves its tracked sibling", async () => { + const root = await createTempDir("paseo-tracked-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "--", "[id].tsx", "i.tsx"], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + "i.tsx\0renamed.tsx\0", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it.each([ + { + sourcePath: "[id].tsx", + sourceFile: "[id].tsx", + targetName: "renamed.tsx", + targetFile: "renamed.tsx", + }, + { + sourcePath: "[id]", + sourceFile: "[id]/route.tsx", + targetName: "route", + targetFile: "route/route.tsx", + }, + ])( + "keeps tracked renames in the index with inherited literal pathspecs ($sourcePath)", + async ({ sourcePath, sourceFile, targetName, targetFile }) => { + const root = await createTempDir("paseo-inherited-literal-rename-"); + try { + vi.stubEnv("GIT_LITERAL_PATHSPECS", "1"); + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.dirname(path.join(root, sourceFile)), { recursive: true }); + await writeFile(path.join(root, sourceFile), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "."], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: sourcePath, + name: targetName, + }); + + expect(result).toEqual({ status: "ok", path: targetName }); + expect(await readFile(path.join(root, targetFile), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, sourcePath))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + `i.tsx\0${targetFile}\0`, + ); + expect(process.env.GIT_LITERAL_PATHSPECS).toBe("1"); + } finally { + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + } + }, + ); + it("supports case-only renames for tracked and untracked files", async () => { const root = await createTempDir("paseo-entry-case-rename-"); try { diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 5aa4efe974..d99c97616b 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -489,7 +489,7 @@ export async function writeExplorerFile({ const temporaryPath = path.join( path.dirname(filePath.resolvedPath), - `.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`, + `.paseo-${randomUUID()}.tmp`, ); let temporaryHandle: FileHandle | null = null; try { @@ -669,6 +669,7 @@ export async function duplicateExplorerEntry({ errorOnExist: true, force: false, preserveTimestamps: true, + verbatimSymlinks: true, }); return { status: "ok", path: normalizeRelativePath({ root, targetPath }) }; } catch (error) { @@ -724,7 +725,11 @@ export async function renameExplorerEntry({ }); const tracked = repository.exitCode === 0 - ? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot }) + ? await runGitCommand(["ls-files", "-z", "--", `:(literal)${sourcePath}`], { + cwd: realRoot, + // Inherited literal mode disables the :(literal) pathspec magic. + envOverlay: { GIT_LITERAL_PATHSPECS: undefined }, + }) : null; if (tracked?.stdout) { await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot }); @@ -820,8 +825,9 @@ async function resolveScopedPath({ function assertWithinWorkspace(root: string, candidate: string): void { const relative = path.relative(root, candidate); - if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return; - throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); + const isOutsideWorkspace = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (isOutsideWorkspace) throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); } async function openFileForRead(filePath: string): Promise<FileHandle> { diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 711b826348..95fe416c54 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -236,7 +236,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and const deletions = expectFileChanges(removedPaths, "delete"); const movedFiles = expectFileChanges(movedPaths); const recovery = expectFileChanges([sentinel]); - const subscription = await subscribeToFileChanges(root, (error, events) => { + const observer = createObserver(); + const subscription = await observer.subscribe(root, (error, events) => { expect(error).toBeNull(); for (const event of events) { population.record(event); @@ -254,9 +255,18 @@ test("observes a thousand concurrent writes and remains healthy after delete and await Promise.all(paths.map((path, index) => writeFile(path, `${index}`))); await writes.complete; - // A native watcher may recover coalesced paths through its safety audit. - // Completion comes from the delivered deletions, not an idle diagnostic or - // a deadline shorter than that recovery cycle. + // The first notice for each write can arrive before its remaining + // classifications. Drain those deliveries before starting a separate deletion + // phase, so a late create cannot coalesce with the expected delete as update. + await expect + .poll(() => observer.getDiagnostics(), { timeout: 10_000 }) + .toMatchObject({ + pendingEventCount: 0, + pendingClassificationCount: 0, + reconciliationInFlightCount: 0, + }); + + // Completion still requires the delivered deletion notices. await Promise.all(removedPaths.map((path) => rm(path))); await deletions.complete; diff --git a/packages/server/src/server/git-filename-diffs.e2e.test.ts b/packages/server/src/server/git-filename-diffs.e2e.test.ts new file mode 100644 index 0000000000..c32ee040b8 --- /dev/null +++ b/packages/server/src/server/git-filename-diffs.e2e.test.ts @@ -0,0 +1,100 @@ +import { test, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js"; + +test("a rename-only commit returns zero added or deleted lines through the client", async () => { + const cwd = mkdtempSync(join(tmpdir(), "commit-rename-diff-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "old.txt"), "same\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "initial"); + git("mv", "old.txt", "new.txt"); + git("-c", "commit.gpgsign=false", "commit", "-m", "rename"); + const sha = git("rev-parse", "HEAD"); + + await client.connect(); + const history = await client.listCheckoutCommits(cwd); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: "new.txt", additions: 0, deletions: 0, status: "renamed" }, + ]); + expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({ + file: { + path: "new.txt", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }, + }); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test.skipIf(process.platform === "win32")( + "checkout and commit diffs preserve filenames that Git quotes", + async () => { + const cwd = mkdtempSync(join(tmpdir(), "git-filename-diffs-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const names = [ + 'report "draft".txt', + "back\\slash.txt", + "tab\tname.txt", + "normal.txt", + "with spaces.txt", + ]; + const editLines = expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "core.quotepath", "false"); + for (const name of names) writeFileSync(join(cwd, name), "before\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "initial"); + for (const name of names) writeFileSync(join(cwd, name), "after\n"); + + await client.connect(); + const checkout = await client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + expect(checkout.error).toBeNull(); + expect(checkout.files.map((file) => file.path).sort()).toEqual([...names].sort()); + for (const file of checkout.files) { + expect(file).toMatchObject({ status: "ok", additions: 1, deletions: 1 }); + expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines); + } + + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const sha = git("rev-parse", "HEAD"); + for (const name of names) { + const commit = await client.getCommitFileDiff(cwd, sha, name); + expect(commit.file).toMatchObject({ path: name, additions: 1, deletions: 1 }); + expect(commit.file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines); + const file = await client.readFile(cwd, name); + expect(file.kind).toBe("text"); + expect(new TextDecoder().decode(file.bytes)).toBe("after\n"); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/hub/daemon-executions.ts b/packages/server/src/server/hub/daemon-executions.ts index a619be18a8..ec629c3fc9 100644 --- a/packages/server/src/server/hub/daemon-executions.ts +++ b/packages/server/src/server/hub/daemon-executions.ts @@ -205,7 +205,6 @@ export class DaemonExecutions implements HubExecutionAgents { } : {}), worktree: toCreateAgentWorktree(input.worktree), - background: true, notifyOnFinish: false, owner, onWorktreeCreated: (worktree) => { diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index 51596b2384..d994a7280e 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -773,8 +773,12 @@ describe("Hub relationship", () => { const second = await relationship.ownedCreateResult("second-create"); expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId); - expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } }); - expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } }); + expect(first).toMatchObject({ + payload: { success: true, executionId: "first-execution", error: null }, + }); + expect(second).toMatchObject({ + payload: { success: true, executionId: "second-execution", error: null }, + }); expect(await relationship.durableOwnedAgentIds()).toHaveLength(2); }); diff --git a/packages/server/src/server/local-service-connection.ts b/packages/server/src/server/local-service-connection.ts new file mode 100644 index 0000000000..a96a0a7094 --- /dev/null +++ b/packages/server/src/server/local-service-connection.ts @@ -0,0 +1,27 @@ +import http from "node:http"; +import net from "node:net"; + +// A host can support IPv6 loopback without listing ::1 for localhost in DNS. +const lookupLoopback: net.LookupFunction = (_hostname, _options, callback) => { + callback(null, [ + { address: "127.0.0.1", family: 4 }, + { address: "::1", family: 6 }, + ]); +}; + +export function connectToLocalService(port: number): net.Socket { + return net.connect({ host: "localhost", port, autoSelectFamily: true, lookup: lookupLoopback }); +} + +export function requestLocalService( + options: Pick<http.RequestOptions, "port" | "path" | "method" | "headers">, + onResponse: (response: http.IncomingMessage) => void, +): http.ClientRequest { + const requestOptions = { + ...options, + hostname: "localhost", + autoSelectFamily: true, + lookup: lookupLoopback, + }; + return http.request(requestOptions, onResponse); +} diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index cbcef548e4..a5120aa5ff 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -791,13 +791,14 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename createDeps(), ); + // baseRefName must be the repo's default branch, not the checked-out branch + // itself — otherwise the diff panel compares the branch against itself and + // always shows no changes. expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, - baseRefName: "dev", + baseRefName: "main", }); - // A checkout-branch worktree has no distinct base, so the workspace records a - // null baseBranch even though worktree.json's baseRefName is the branch itself. - expect(created.workspace.baseBranch).toBe(null); + expect(created.workspace.baseBranch).toBe("main"); await expect( attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 247fe46c18..94e82d5e95 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -33,6 +33,8 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { workspaceId?: string; projectId?: string; title?: string; + background?: boolean; + callerWorkspaceId?: string; } export interface CreatePaseoWorktreeResult { @@ -101,6 +103,8 @@ async function createPaseoWorktreeWithPriority( baseBranch: createdWorktree.worktree.comparisonBaseRef, title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext), expectsInitialAgent: Boolean(input.firstAgentContext), + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, ...(createdWorktree.intent.kind === "checkout-change-request" && createdWorktree.intent.headRepository ? { diff --git a/packages/server/src/server/persisted-config-json-schema.test.ts b/packages/server/src/server/persisted-config-json-schema.test.ts new file mode 100644 index 0000000000..f943f859ec --- /dev/null +++ b/packages/server/src/server/persisted-config-json-schema.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import Ajv from "ajv"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { PersistedConfigSchema } from "./persisted-config.js"; + +const publishedSchema = JSON.parse( + readFileSync( + new URL("../../../website/public/schemas/paseo.config.v1.json", import.meta.url), + "utf8", + ), +); + +test("editors accept the daemon's browser, profile, plugin and metadata configuration", () => { + const config = { + $schema: "https://paseo.sh/schemas/paseo.config.v1.json", + version: 1, + daemon: { + browserTools: { enabled: true }, + terminalProfiles: [], + agentProfiles: [], + }, + pluginsEnabled: true, + plugins: {}, + agents: { + metadataGeneration: { providers: [{ provider: "claude", model: "claude-opus-5" }] }, + }, + }; + expect(PersistedConfigSchema.parse(config)).toEqual(config); + const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema); + const valid = validate(config); + expect(validate.errors).toBeNull(); + expect(valid).toBe(true); +}); + +test("the published schema stays current with the daemon's configuration schema", () => { + const generated = z.toJSONSchema(PersistedConfigSchema, { + target: "draft-07", + unrepresentable: "any", + io: "input", + }); + generated.title = "PaseoConfigV1"; + expect( + publishedSchema, + "Run npm run generate:config-schema --workspace=@getpaseo/server", + ).toEqual(generated); +}); + +test("editors retain the existing proxy configuration help", () => { + const daemon = publishedSchema.properties.daemon.properties; + expect(daemon.trustedProxies.description).toBe( + 'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.', + ); + expect(daemon.serviceProxy.properties.enabled.description).toBe( + "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + ); + expect(daemon.serviceProxy.properties.listen.description).toBe( + "Optional service-only listener address. Presence enables the standalone service proxy listener.", + ); + expect(daemon.serviceProxy.properties.publicBaseUrl.description).toBe( + "Optional public base URL for service host aliases.", + ); +}); + +test.each([ + { daemon: { browserTools: { enabled: "yes" } } }, + { daemon: { terminalProfiles: "default" } }, + { daemon: { agentProfiles: "default" } }, + { pluginsEnabled: "yes" }, + { plugins: [] }, + { agents: { metadataGeneration: { providers: [{ provider: "" }] } } }, + { unknownSetting: true }, +])("editors still reject invalid configuration: %j", (config) => { + expect(PersistedConfigSchema.safeParse(config).success).toBe(false); + const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema); + expect(validate(config)).toBe(false); +}); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index f443f84865..6b23a6f365 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -1,4 +1,14 @@ -import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; @@ -664,6 +674,19 @@ describe("PersistedConfigSchema voice mode config", () => { expect(parsed.features?.dictation?.stt?.language).toBe("fr"); expect(parsed.features?.voiceMode?.stt?.language).toBe("de"); }); + + test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => { + const parsed = PersistedConfigSchema.parse({ + features: { + voiceMode: { + tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "aiden" }, + }, + }, + }); + + expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0"); + expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden"); + }); }); describe("loadPersistedConfig", () => { @@ -843,3 +866,35 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); }); + +describe.skipIf(process.platform === "win32")( + "config.json symlinked from another directory", + () => { + test("saving writes through the symlink and leaves it in place", () => { + const parent = createTempHome(); + const home = path.join(parent, "home"); + const dotfiles = path.join(parent, "dotfiles"); + const linkedConfigPath = path.join(dotfiles, "config.json"); + const configPath = path.join(home, "config.json"); + try { + mkdirSync(home); + mkdirSync(dotfiles, { mode: 0o755 }); + chmodSync(dotfiles, 0o755); + writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); + symlinkSync(linkedConfigPath, configPath); + + savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } }); + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + expect(modeOf(dotfiles)).toBe(0o755); + expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + }, +); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 40c4150907..11d94495b1 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,3 +1,4 @@ +import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; @@ -143,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(), }) @@ -236,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(), @@ -283,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(), @@ -306,6 +326,7 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + pluginRegistries: PluginRegistriesSchema.optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index da8b5419a5..a0bbb49035 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -27,9 +27,28 @@ function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord } describe("persistence hooks", () => { + test("buildConfigOverrides prefers the last live mode over the creation-time mode", () => { + const record = createRecord({ + lastModeId: "bypass", + config: { modeId: "acceptEdits" }, + }); + + expect(buildConfigOverrides(record).modeId).toBe("bypass"); + }); + + test("buildConfigOverrides falls back to the configured mode when no live mode was recorded", () => { + const record = createRecord({ + lastModeId: null, + config: { modeId: "acceptEdits" }, + }); + + expect(buildConfigOverrides(record).modeId).toBe("acceptEdits"); + }); + test("buildConfigOverrides preserves the complete private launch config", () => { const record = createRecord({ title: "Voice agent (current)", + lastModeId: "default", config: { modeId: "default", model: "gpt-5.4-mini", @@ -79,6 +98,7 @@ describe("persistence hooks", () => { const record = createRecord({ provider: "codex", title: "Renamed title", + lastModeId: null, config: { model: "gpt-5.4-mini", systemPrompt: "Confirm and speak first.", diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 45443a7af0..e95743d6be 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -67,7 +67,10 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe return stripInternalPaseoMcpServer({ provider: record.provider, cwd: record.cwd, - modeId: record.config?.modeId ?? undefined, + // lastModeId is the last live mode — it also covers provider-side switches + // (ACP current_mode_update, in-session commands) that never reach + // record.config.modeId. + modeId: record.lastModeId ?? record.config?.modeId ?? undefined, model: record.config?.model ?? undefined, thinkingOptionId: record.config?.thinkingOptionId ?? undefined, featureValues: record.config?.featureValues ?? undefined, diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index db95d4786d..314b44ea47 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { open, readFile, stat, unlink, utimes } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { ensurePrivateDirectory } from "./private-files.js"; @@ -15,6 +16,7 @@ export const pidLockInfoSchema = z.object({ serverId: z.string().nullable().optional(), desktopManaged: z.boolean().optional(), heartbeat: z.literal(true).optional(), + bootId: z.string().optional(), }); export interface PidLockInfo extends z.infer<typeof pidLockInfoSchema> {} @@ -62,16 +64,38 @@ function precedesThisBoot(startedAt: string): boolean { return stamped < Date.now() - uptime() * 1000 - BOOT_INSTANT_TOLERANCE_MS; } +let cachedBootId: string | null | undefined; + +// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while +// its host sleeps keeps its uptime, so after it resumes the instant lands after locks its +// running supervisor wrote. +function currentBootId(): string | null { + if (cachedBootId === undefined) { + try { + cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null; + } catch { + cachedBootId = null; + } + } + return cachedBootId; +} + +function writtenDuringThisBoot(lock: PidLockInfo): boolean { + const thisBoot = currentBootId(); + if (lock.bootId && thisBoot) return lock.bootId === thisBoot; + return !precedesThisBoot(lock.startedAt); +} + /** * Whether the process that wrote this lock is still running. * * A PID alone does not identify the supervisor: the operating system hands the number to * something else once the supervisor is gone, and a reboot reassigns it freely. A process - * cannot predate the boot it runs under, so a lock stamped before this boot is abandoned + * cannot predate the boot it runs under, so a lock written during another boot is abandoned * however alive its PID looks. */ export function isPidLockOwnerRunning(lock: PidLockInfo): boolean { - if (precedesThisBoot(lock.startedAt)) return false; + if (!writtenDuringThisBoot(lock)) return false; return isPidRunning(lock.pid); } @@ -214,6 +238,7 @@ export async function acquirePidLock( } // Create new lock with exclusive flag + const bootId = currentBootId(); const lockInfo: PidLockInfo = { pid: lockOwnerPid, startedAt: new Date().toISOString(), @@ -221,6 +246,7 @@ export async function acquirePidLock( uid: process.getuid?.() ?? 0, listen, heartbeat: true, + ...(bootId ? { bootId } : {}), ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index c14c027853..cf6ffe9eee 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -9,6 +9,7 @@ export const builtinPlugins = [ "copilot-usage-source", "cursor-usage-source", "grok-usage-source", + "hoplite-provider", "kimi-usage-source", "minimax-usage-source", "muse-provider", diff --git a/packages/server/src/server/plugins/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/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index facba48fdb..fc86f85db3 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -138,6 +138,51 @@ async function createRootAlias(directory: string): Promise<string> { } describe("plugin runtime entries", () => { + it.each([ + 'import type { AgentTimelineItem, ToolCallDetail } from "@getpaseo/protocol/agent-types"; export type Detail = ToolCallDetail; export type Item = AgentTimelineItem;', + 'import { type AgentTimelineItem } from "@getpaseo/protocol/agent-types"; export type Item = AgentTimelineItem;', + 'export type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";', + 'export type Item = import("@getpaseo/protocol/agent-types").AgentTimelineItem;', + 'export type { AgentAttachment } from "@getpaseo/protocol/messages";', + ])("compiles protocol types without local dependencies: %s", async (typeSource) => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared", "labels.ts"), + `${typeSource}\nexport const clientLabel = "Client contribution"; export const serverLabel = "Server contribution";`, + ); + + const result = await compilePlugin(entries); + + expect(result.clientBundle).toContain("Client contribution"); + expect(result.serverBundle).toContain("Server contribution"); + expect(result.clientBundle).not.toContain("@getpaseo/protocol"); + expect(result.serverBundle).not.toContain("@getpaseo/protocol"); + }); + + it("still requires local dependencies for protocol runtime imports", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + 'import { TOOL_CALL_ICON_NAMES } from "@getpaseo/protocol/agent-types"; export default () => TOOL_CALL_ICON_NAMES;', + ); + + await expect(compilePlugin(entries)).rejects.toThrow( + 'Could not resolve "@getpaseo/protocol/agent-types"', + ); + }); + + it("still rejects missing third-party type dependencies", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + 'import type { Item } from "missing-types"; export default (item: Item) => item;', + ); + + await expect(compilePlugin(entries)).rejects.toThrow( + 'Could not resolve type dependency "missing-types"', + ); + }); + it.each([ "react", "react/jsx-runtime", diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 15df395d06..b48449bb50 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -245,6 +245,10 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: runtimeSpecifierError(packageSpecifier, owner, file) ?? lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); if (error) return error; + // Protocol types are shared host contracts, also exposed by the SDK's peer + // dependency during development. Erased imports need no plugin-local install; + // runtime imports still go through normal dependency resolution below. + if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue; // Host modules have separately enforced SDK boundaries and need no local installation. if ( (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 544b609df5..71fe2b96e9 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -10,6 +10,7 @@ import { stat, writeFile, } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -418,7 +419,7 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); - it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => { + it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const running = await createPlugin("running", "export default () => () => {};"); @@ -426,7 +427,13 @@ describe("PluginService", () => { const malformed = await createPlugin("malformed", "export default () => () => {};"); await writeFile( path.join(running, "paseo-plugin.json"), - JSON.stringify({ id: "running", description: "Runs checks" }), + JSON.stringify({ + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }), ); await writeFile( path.join(disabled, "paseo-plugin.json"), @@ -442,16 +449,123 @@ describe("PluginService", () => { await service.start(); expect( - (await service.listPlugins()).map(({ id, description }) => ({ id, description })), + (await service.listPlugins()).map(({ id, description, name, icon, media }) => ({ + id, + description, + name, + icon, + media, + })), ).toEqual([ { id: "disabled", description: "Waits until enabled" }, { id: "malformed", description: undefined }, - { id: "running", description: "Runs checks" }, + { + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }, ]); await service.stopAllPlugins(); }); - it("prefers an existing directory and installs its selected plugin subdirectory", async () => { + it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`); + await mkdir(owner); + roots.push(home, owner); + const source = `${path.basename(owner)}/example`; + const repository = await createPlugin("registry-example", "export default () => () => {};\n"); + await mkdir(path.join(owner, "example")); + await cp(repository, path.join(owner, "example"), { recursive: true }); + await runGitCommand(["init", "-b", "main"], { cwd: repository }); + await runGitCommand(["add", "-A"], { cwd: repository }); + await runGitCommand( + [ + "-c", + "user.name=Paseo Tests", + "-c", + "user.email=paseo@example.test", + "commit", + "-m", + "fixture", + ], + { cwd: repository }, + ); + const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const requests: Array<string | undefined> = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + id: source, + name: "Example", + description: "Test", + categories: [], + author: { github: path.basename(owner) }, + repository: { url: "https://github.com/fixture/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit: stdout.trim(), + pluginPath: ".", + }, + media: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Test", + }), + ); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const url = `http://127.0.0.1:${address.port}`; + const store = createStore(home); + store.patch({ pluginsEnabled: false }); + const service = bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.4.0", { + managedSources: new ManagedPluginSources(home, { defaultUrl: url }), + }), + ); + try { + await service.start(); + await expect(service.installSource({ source })).resolves.toMatchObject({ + id: "registry-example", + status: "disabled", + installation: { + identity: { kind: "git", registry: { url, id: source } }, + currentRevision: stdout.trim(), + }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + await expect( + service.installSource({ source: `./${source}`, id: "local-example" }), + ).resolves.toMatchObject({ + installation: { identity: { kind: "directory", path: path.join(owner, "example") } }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + } finally { + await service.stopAllPlugins(); + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }, 30_000); + + it("names a missing explicit directory instead of trying a managed source", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = path.join(home, "missing"); + const service = createService(home); + await expect(service.installSource({ source: directory })).rejects.toThrow( + `Plugin directory does not exist: ${directory}`, + ); + }); + + it("installs an explicit directory and its selected plugin subdirectory", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-")); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 167216dbc1..a5ecea8097 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -21,18 +21,24 @@ 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"; import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; import { readPluginProviderIcon } from "./provider-icon.js"; -import { UsageSourceRegistry } from "./usage-sources/index.js"; +import { + type AgentUsageLookup, + type ListUsageReportsOptions, + UsageSourceRegistry, +} from "./usage-sources/index.js"; import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { emit?: PluginLifecycle["emit"]; + drainEvents?: PluginRuntime["drainEvents"]; before?: PluginLifecycle["before"]; catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise<unknown>; @@ -55,6 +61,7 @@ interface PluginRuntimePort { } interface PluginServiceDependencies { + usageAgents?: AgentUsageLookup; settingsDirectory?: string; runtime?: PluginRuntimePort; managedSources?: ManagedPluginSources; @@ -95,7 +102,12 @@ export class PluginService { private readonly dependencies: PluginServiceDependencies = {}, ) { this.logger = logger.child({ module: "plugin-service" }); - this.usageSources = new UsageSourceRegistry(Date.now, 300_000, this.logger); + this.usageSources = new UsageSourceRegistry( + Date.now, + 300_000, + this.logger, + dependencies.usageAgents, + ); this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion, { @@ -119,6 +131,10 @@ export class PluginService { this.runtime.emit?.(name, event); }; + async drainEvents(): Promise<void> { + await this.runtime.drainEvents?.(); + } + readonly before: PluginLifecycle["before"] = async (name, request) => { if (this.runtime.before) { return this.runtime.before(name, request); @@ -144,7 +160,7 @@ export class PluginService { return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)); } - listUsageReports(options?: { forceRefresh?: boolean; reportIds?: string[] }) { + listUsageReports(options?: ListUsageReportsOptions) { return this.usageSources.listReports(options); } @@ -200,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); @@ -271,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 }); @@ -568,8 +584,8 @@ export class PluginService { id: source.id, label: source.label, icon: source.icon, - discover: async () => { - const result = await this.runtime.discoverUsage(pluginId, source.id); + discover: async (scope) => { + const result = await this.runtime.discoverUsage(pluginId, source.id, scope); if (!Array.isArray(result)) throw new Error(`Invalid usage discovery from ${source.id}`); return result; @@ -613,6 +629,7 @@ export class PluginService { label: provider.label, description: provider.description, command: provider.command, + requiresConfiguration: provider.requiresConfiguration, status: provider.hasStatus ? (request) => { if (!this.runtime.getProviderStatus) diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts index 8457c94711..6f3d8661e7 100644 --- a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -6,7 +6,7 @@ import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -test("workspace archive publishes agent archive hooks for both live and closed agents", async () => { +test("closing and archiving agents publish their distinct lifecycle hooks once", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-archive-hooks-")); const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); @@ -33,7 +33,16 @@ test("workspace archive publishes agent archive hooks for both live and closed a workspaceId: workspace.id, }); await daemon.daemon.agentManager.closeAgent(closed.id); + await daemon.daemon.agentManager.closeAgent(closed.id); expect(daemon.daemon.agentManager.getAgent(closed.id)).toBeNull(); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle-logger"); + return logs + .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .map((entry) => JSON.parse(entry.message).data.agent.id); + }) + .toEqual([closed.id]); await client.archiveWorkspace(workspace.id); await expect .poll(async () => { @@ -48,6 +57,50 @@ test("workspace archive publishes agent archive hooks for both live and closed a .sort(); }) .toEqual([live.id, closed.id].sort()); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle-logger"); + return logs + .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .map((entry) => JSON.parse(entry.message).data.agent.id) + .sort(); + }) + .toEqual([live.id, closed.id].sort()); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test("daemon shutdown lets agent close hooks finish before stopping plugins", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-close-hook-shutdown-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const completedFile = path.join(directory, "closed.txt"); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { writeFile } from "node:fs/promises"; +import { setTimeout } from "node:timers/promises"; +export default function contribute(server) { + server.on("agent.closed", async (event, context) => { + await setTimeout(200, undefined, { signal: context.signal }); + await writeFile(${JSON.stringify(completedFile)}, event.agent.id); + }); + return () => {}; +}`, + ); + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const agent = await client.createAgent({ provider: "claude", cwd: directory }); + await daemon.close(); + expect(await readFile(completedFile, "utf8")).toBe(agent.id); } finally { await client.close(); await daemon.close(); diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts index bd71f0ce7e..fc9cfbd84d 100644 --- a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -280,6 +280,7 @@ test.skipIf(process.platform !== "linux")( }) .toEqual([ "agent.archived", + "agent.closed", "agent.created", "agent.permission_requested", "agent.permission_resolved", diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index 766ccc0bce..447c325ad4 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -17,6 +17,7 @@ import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; export const lifecycleEventNames = [ "agent.created", + "agent.closed", "agent.turn_started", "agent.turn_ended", "agent.permission_requested", 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 9d9bad15f3..466780b79a 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -36,6 +36,25 @@ async function commitAll(repository: string, message: string): Promise<string> { return stdout.trim(); } +/** Points https://github.com/fixture/repository.git at a local repository for the callback. */ +async function withGitHubFixture(repository: string, run: () => Promise<void>): Promise<void> { + const overlay = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`, + GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git", + }; + const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]])); + Object.assign(process.env, overlay); + try { + await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + describe("managed Git plugin sources", () => { it("does not expose Git URL credentials when cloning fails", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-")); @@ -48,24 +67,13 @@ describe("managed Git plugin sources", () => { await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); }); - it("normalizes explicit GitHub identifiers and shorthand to the same Git source", async () => { + it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => { const repository = await createRepository(); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-")); roots.push(home); - const overlay = { - GIT_CONFIG_COUNT: "1", - GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`, - GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git", - }; - const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]])); - Object.assign(process.env, overlay); - try { + await withGitHubFixture(repository, async () => { const sources = new ManagedPluginSources(home); - for (const source of [ - "github:fixture/repository", - "fixture/repository", - "git:fixture/repository", - ]) { + for (const source of ["github:fixture/repository", "git:fixture/repository"]) { const candidate = await sources.prepareInstall({ source }); expect(candidate.record).toMatchObject({ kind: "git", @@ -73,12 +81,7 @@ describe("managed Git plugin sources", () => { }); await sources.discard(candidate); } - } finally { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - } + }); }, 30_000); it("offers current default HEAD after installing a tag", async () => { @@ -275,3 +278,286 @@ describe("managed Git plugin sources", () => { } }, 30_000); }); + +describe("registry plugin sources", () => { + 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); + const requests: Array<string | undefined> = []; + const server = createServer((req, res) => { + requests.push(req.url); + res.statusCode = 404; + res.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 { + await withGitHubFixture(repository, async () => { + const sources = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}`, + }); + 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 { + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }, 30_000); + + it("installs a bare id from a private registry and polls its pin without install intent", async () => { + const repository = await createRepository(); + await mkdir(path.join(repository, "packages/example"), { recursive: true }); + await rename( + path.join(repository, "paseo-plugin.json"), + path.join(repository, "packages/example/paseo-plugin.json"), + ); + await rename( + path.join(repository, "index.server.ts"), + path.join(repository, "packages/example/index.server.ts"), + ); + await commitAll(repository, "move plugin into monorepo"); + const revision = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const commit = revision.stdout.trim(); + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-home-")); + roots.push(home); + const requests: Array<{ + path: string | undefined; + 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({ + id: "acme/example", + name: "Example", + description: "Test", + categories: [], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit, + pluginPath: "packages/example", + }, + 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 host = `127.0.0.1:${address.port}`; + const url = `http://${host}/internal`; + try { + const sources = new ManagedPluginSources(home, { + defaultUrl: url, + registries: { [host]: { authorization: "Bearer test" } }, + }); + let candidate = await sources.prepareInstall({ source: "acme/example" }); + candidate = await sources.place("managed-example", candidate); + expect( + JSON.parse(await readFile(path.join(candidate.directory, "paseo-plugin.json"), "utf8")).id, + ).toBe("managed-example"); + await sources.verifyCandidate("managed-example", candidate); + sources.commit("managed-example", candidate.record); + 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", + remote: pathToFileURL(repository).href, + 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" }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, + ]); + } finally { + await new Promise<void>((resolve, reject) => + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + } + }); +}); + +it("installs and updates only the npm artifacts pinned by the plugin registry", async () => { + const { startNpmRegistry, npmPluginPackages } = + await import("../../../../../scripts/test-support/npm-registry.mjs"); + const { resolveNpm } = await import("./managed-source/npm.js"); + const npm = await startNpmRegistry(npmPluginPackages()); + const previous = process.env.npm_config_userconfig; + process.env.npm_config_userconfig = npm.userconfig; + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-npm-")); + roots.push(home); + const intents: Array<string | string[] | undefined> = []; + let pin = await resolveNpm("paseo-fixture-plugin", "1.0.0", home); + 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", + artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, + }), + ); + }); + 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 = { defaultUrl: `http://127.0.0.1:${address.port}` }; + try { + const sources = new ManagedPluginSources(home, options); + const candidate = await sources.place( + "example", + await sources.prepareInstall({ source: "acme/example" }), + ); + await sources.verifyCandidate("example", candidate); + sources.commit("example", candidate.record); + expect((await sources.describe("example", candidate.directory)).currentRevision).toBe("1.0.0"); + expect((await sources.preview("example", candidate.directory)).outcome).toBe("current"); + pin = await resolveNpm("paseo-fixture-plugin", "1.1.0", home); + const restarted = new ManagedPluginSources(home, options); + const preview = await restarted.preview("example", candidate.directory); + expect(preview.target).toEqual({ kind: "npm", ...pin }); + expect(preview.outcome).toBe("update"); + const updated = await restarted.place( + "example", + await restarted.prepareUpdate(preview.proposal!, candidate.directory), + ); + await restarted.verifyCandidate("example", updated); + expect(updated.record.registry).toEqual({ url: options.defaultUrl, id: "acme/example" }); + expect(intents).toEqual(["1", undefined, undefined, undefined]); + pin = { ...pin, integrity: "sha512-YWJj" }; + await expect(sources.prepareInstall({ source: "acme/example" })).rejects.toThrow( + "changed since review", + ); + } finally { + server.closeAllConnections(); + await new Promise<void>((resolve) => server.close(() => resolve())); + await npm.close(); + if (previous === undefined) delete process.env.npm_config_userconfig; + else process.env.npm_config_userconfig = previous; + } +}, 30_000); diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index a2ab4a90f8..5373b7453a 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -1,3 +1,8 @@ +import { + PluginRegistryIdentitySchema, + parsePluginRegistryReference, +} from "@getpaseo/protocol/plugin-registry"; +import { resolveRegistryPlugin, type RegistryOptions } from "./managed-source/registry.js"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, realpath, rename, rm } from "node:fs/promises"; @@ -23,8 +28,12 @@ const GIT_TIMEOUT_MS = 120_000; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const; // COMPAT(plugin-source-record): v0.8.0; accept and strip pre-correction selector/revision/root copies until daemon floor supports artifact-derived metadata. const ManagedPluginRecordSchema = z.union([ - z.object({ kind: z.literal("git").default("git"), remote: z.string().min(1) }), - z.object({ kind: z.literal("npm") }), + z.object({ + kind: z.literal("git").default("git"), + remote: z.string().min(1), + registry: PluginRegistryIdentitySchema.optional(), + }), + z.object({ kind: z.literal("npm"), registry: PluginRegistryIdentitySchema.optional() }), ]); export type ManagedPluginRecord = z.infer<typeof ManagedPluginRecordSchema>; export interface ManagedPluginCandidate { @@ -33,6 +42,7 @@ export interface ManagedPluginCandidate { directory: string; record: ManagedPluginRecord; versionRoot: string; + target?: PluginUpdateTarget; } interface InstallInput { source: string; @@ -49,7 +59,10 @@ export class ManagedPluginSources { private readonly root: string; private readonly metadataPath: string; private readonly records: Record<string, ManagedPluginRecord>; - constructor(paseoHome: string) { + constructor( + paseoHome: string, + private readonly registryOptions: RegistryOptions = {}, + ) { this.root = path.resolve(paseoHome, "plugins"); this.metadataPath = path.join(this.root, "sources.json"); this.records = existsSync(this.metadataPath) @@ -85,6 +98,7 @@ export class ManagedPluginSources { sourceRoot: path.join(versionRoot, "checkout"), identity: { kind: "git", + ...(record.registry ? { registry: record.registry } : {}), remote: redactRemoteCredentials(record.remote), pluginPath: parts.slice(2).join("/") || ".", }, @@ -100,6 +114,7 @@ export class ManagedPluginSources { sourceRoot: path.join(versionRoot, "node_modules", packageName), identity: { kind: "npm", + ...(record.registry ? { registry: record.registry } : {}), packageName, pluginPath: parts.slice(2 + packageParts).join("/") || ".", }, @@ -132,6 +147,18 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise<ManagedPluginCandidate> { + const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl); + if (registry) { + 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); + return { ...candidate, record: { ...candidate.record, registry } }; + } const pluginPath = normalizePluginPath(input.pluginPath); const versionRoot = await this.createStagingRoot(); try { @@ -163,7 +190,7 @@ export class ManagedPluginSources { assertPluginPath(sourceRoot, directory); await assertRealContainment(sourceRoot, directory); const { id: defaultId, build } = await readPluginManifest(directory); - return { build, defaultId, directory, record, versionRoot }; + return { build, defaultId, directory, record, versionRoot, ...(target ? { target } : {}) }; } catch (error) { await rm(versionRoot, { recursive: true, force: true }); throw error; @@ -202,7 +229,18 @@ export class ManagedPluginSources { const location = this.locate(pluginId, configuredPath); let target: PluginUpdateTarget; let outcome: PluginUpdatePreview["outcome"]; - if (current.identity.kind === "npm") { + if (current.identity.registry) { + if (selection) throw new Error("Registry updates use only the reviewed registry pin"); + const resolved = await resolveRegistryPlugin( + current.identity.registry, + this.registryOptions, + false, + ); + assertRegistryArtifactIdentity(current.identity, resolved.artifact); + target = resolved.target; + const revision = target.kind === "npm" ? target.version : target.commit; + outcome = revision === current.currentRevision ? "current" : "update"; + } else if (current.identity.kind === "npm") { target = { kind: "npm", ...(await resolveNpm( @@ -211,10 +249,7 @@ export class ManagedPluginSources { this.root, )), }; - const comparison = compareVersions(target.version, current.currentRevision); - if (comparison === 0) outcome = "current"; - else if (comparison < 0 && !selection) outcome = "installed-newer"; - else outcome = "update"; + outcome = npmUpdateOutcome(target.version, current.currentRevision, selection); } else { const record = this.records[pluginId]; if (record?.kind !== "git") throw new Error("Git source is no longer configured"); @@ -271,6 +306,14 @@ export class ManagedPluginSources { await this.assertCurrent(proposal, configuredPath); const identity = this.locate(proposal.id, configuredPath).identity; const record = this.records[proposal.id]; + 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)) + throw new Error("Registry pin changed since review; check again"); + const candidate = await this.prepareInstall(resolved.input, proposal.target); + return { ...candidate, record: { ...candidate.record, registry: identity.registry } }; + } if (identity.kind === "git" && record?.kind === "git" && proposal.target.kind === "git") return this.prepareInstall( { @@ -296,6 +339,7 @@ export class ManagedPluginSources { candidate: ManagedPluginCandidate, target?: PluginUpdateTarget, ): Promise<void> { + target ??= candidate.target; const location = this.locate(pluginId, candidate.directory, candidate.record); const revision = await this.revision(location, candidate.directory); if (target?.kind === "git" && revision !== target.commit) @@ -394,7 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s } return []; } +/** 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)) { @@ -405,7 +451,7 @@ function normalizeGitSource(source: string): string { source = source.slice(4); } const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source); - if (github) { + if (github && expandsShorthand) { const repository = github[2].replace(/\.git$/, ""); return `https://github.com/${github[1]}/${repository}.git`; } @@ -578,3 +624,29 @@ async function revParse(cwd: string, ref: string): Promise<string> { const { stdout } = await runGitCommand(["rev-parse", "--verify", ref], { cwd }); return stdout.trim(); } + +function assertRegistryArtifactIdentity( + identity: Exclude<PluginInstallation["identity"], { kind: "directory" }>, + artifact: Awaited<ReturnType<typeof resolveRegistryPlugin>>["artifact"], +): void { + const matches = + identity.kind === "npm" && artifact.kind === "npm" + ? identity.packageName === artifact.package && identity.pluginPath === "." + : identity.kind === "git" && + artifact.kind === "git" && + identity.remote === redactRemoteCredentials(artifact.remote) && + identity.pluginPath === (artifact.pluginPath ?? "."); + if (!matches) + throw new Error("Registry artifact source changed; reinstall to approve the new source"); +} + +function npmUpdateOutcome( + version: string, + current: string, + selection: PluginUpdateSelection | undefined, +): PluginUpdatePreview["outcome"] { + const comparison = compareVersions(version, current); + if (comparison === 0) return "current"; + if (comparison < 0 && !selection) return "installed-newer"; + return "update"; +} diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts new file mode 100644 index 0000000000..009cac5a68 --- /dev/null +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -0,0 +1,77 @@ +import { + 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 { + defaultUrl?: string; + registries?: PluginRegistries; +} +export async function resolveRegistryPlugin( + identity: PluginRegistryIdentity, + options: RegistryOptions, + install: boolean, +) { + const base = new URL(identity.url); + if (!["http:", "https:"].includes(base.protocol) || base.username || base.password) + throw new Error("Registry base must be an HTTP(S) URL without credentials"); + const authorization = options.registries?.[base.host]?.authorization; + if ( + authorization && + base.protocol !== "https:" && + !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname) + ) + throw new Error("Registry credentials require HTTPS"); + const headers: Record<string, string> = {}; + if (authorization) headers.Authorization = authorization; + if (install) headers["X-Paseo-Install"] = "1"; + 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 = 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 = + artifact.kind === "npm" + ? { + kind: "npm", + version: artifact.version, + resolved: artifact.resolved, + integrity: artifact.integrity, + } + : { kind: "git", commit: artifact.commit }; + const input = + artifact.kind === "npm" + ? { source: `npm:${artifact.package}@${artifact.version}` } + : { source: `git:${artifact.remote}`, ref: artifact.commit, pluginPath: artifact.pluginPath }; + return { input, target, artifact }; +} diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 3ff06e2c3e..399e350493 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -24,6 +24,69 @@ describe("plugin manifest", () => { }); }); + it("reads display metadata and ignores future top-level fields", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"], + }; + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ ...manifest, futureField: { enabled: true } }), + ); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + }); + + it.each([ + { name: " " }, + { name: 42 }, + { icon: "" }, + { icon: 42 }, + { icon: "icon.svg" }, + { icon: "/icon.png" }, + { icon: "../icon.png" }, + { icon: "assets/../../icon.png" }, + { icon: "https://example.com/icon.png" }, + { icon: "C:/icon.png" }, + { icon: "assets\\icon.png" }, + { icon: "icon.png?query" }, + { media: "screenshot.png" }, + { media: [42] }, + { media: [""] }, + { media: ["../demo.mp4"] }, + { media: ["/demo.mp4"] }, + { media: ["http://example.com/demo.mp4"] }, + { media: ["https://"] }, + { media: ["//example.com/demo.mp4"] }, + { media: ["file:///demo.mp4"] }, + { media: ["data:image/png;base64,abc"] }, + { media: ["."] }, + { media: ["assets/"] }, + { id: "INVALID" }, + { description: 42 }, + { requirements: { paseoo: ">=0.11.0" } }, + ])("rejects invalid known fields: %j", async (fields) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "example", futureField: true, ...fields }), + ); + await expect(readPluginManifest(directory)).rejects.toThrow(); + }); + + it("accepts omitted metadata and empty media", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) { + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest)); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + } + }); + it("reads and validates requirements before any plugin code runs", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index d2e13d614b..2312a2cb5a 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json"; const PluginBuildCommandSchema = z .array(z.string().refine((argument) => argument.trim().length > 0)) .min(1); -const PluginManifestSchema = z - .object({ - id: PluginIdSchema, - description: z.string().trim().min(1).optional(), - requirements: PluginRequirementsSchema.strict().optional(), - build: z.array(PluginBuildCommandSchema).min(1).optional(), - }) - .strict(); +// Manifest asset paths use forward slashes and stay inside the plugin package. +const PluginAssetPathSchema = z + .string() + .min(1) + .refine( + (value) => + value === value.trim() && + !value.startsWith("/") && + !/[\\:?#\p{Cc}]/u.test(value) && + !value.split("/").includes("..") && + !value.endsWith("/") && + value.split("/").at(-1) !== ".", + "Expected a relative file path inside the plugin package", + ); +const PluginManifestSchema = z.object({ + id: PluginIdSchema, + name: z.string().trim().min(1).optional(), + description: z.string().trim().min(1).optional(), + icon: PluginAssetPathSchema.refine( + (value) => /\.png$/i.test(value), + "Expected a relative path to a PNG", + ).optional(), + media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(), + // A misspelled requirement must not silently disable compatibility checks. + requirements: PluginRequirementsSchema.strict().optional(), + build: z.array(PluginBuildCommandSchema).min(1).optional(), +}); export type PluginManifest = z.infer<typeof PluginManifestSchema>; diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index d0ad016cec..5da355d5df 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -1,3 +1,7 @@ +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; import { EventEmitter, once } from "node:events"; import { resolveDaemonVersion } from "../daemon-version.js"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -15,6 +19,59 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +test.each(["React Native", "native"])( + "provider readiness works with %s AbortController", + async (runtime) => { + const require = createRequire(import.meta.url); + const reactNativeRequire = createRequire(require.resolve("react-native/package.json")); + const { AbortController: ReactNativeAbortController } = reactNativeRequire( + "abort-controller/dist/abort-controller", + ); + const bundle = await build({ + entryPoints: [fileURLToPath(new URL("../../../../client/src/index.ts", import.meta.url))], + bundle: true, + write: false, + format: "iife", + globalName: "paseoSdk", + platform: "browser", + }); + // A separate runtime starts with React Native's real abort implementation. + // Node's globals and the daemon's runtime remain unchanged. + const sdk: typeof import("@getpaseo/client") = runInNewContext( + `${bundle.outputFiles[0]!.text}\npaseoSdk`, + { + AbortController: runtime === "React Native" ? ReactNativeAbortController : AbortController, + setTimeout, + clearTimeout, + queueMicrotask, + TextEncoder, + TextDecoder, + }, + ); + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const api = sdk.createPaseoApi(client); + try { + await client.connect(); + const snapshot = await api.providers.waitForReady({ cwd: daemon.staticDir }); + expect(snapshot.entries.length).toBeGreaterThan(0); + expect(snapshot.entries.every((entry) => entry.status !== "loading")).toBe(true); + const waiting = api.providers.waitForReady({ cwd: daemon.staticDir }); + const canceled = expect(waiting).rejects.toThrow(/disposed|released/); + await api.dispose(); + await canceled; + const { diagnostic } = await client.collectDiagnostics(); + expect(diagnostic).toContain("Source registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + expect(() => api.providers.waitForReady()).toThrow("disposed"); + } finally { + await api.dispose(); + await client.close(); + await daemon.close(); + } + }, +); + test("plugin handlers create workspaces and agents through their Paseo API", async () => { const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-plugin-")); const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-workspace-")); diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 9b543b2a75..b3f363cb29 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,3 +1,4 @@ +import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage"; import type { ProviderConnectRequest, ProviderCatalogOptions, @@ -13,6 +14,7 @@ import { import { z } from "zod"; export interface PluginProviderMetadata { + requiresConfiguration?: boolean; hasCatalogCacheKey?: boolean; hasStatus?: boolean; command?: readonly [string, ...string[]]; @@ -52,7 +54,7 @@ export type PluginProcessRequest = | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } | { type: "hook.cancel"; requestId: string } | { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown } - | { type: "usage.discover"; requestId: string; sourceId: string } + | { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope } | { type: "invoke"; requestId: string; method: string; input: unknown } | { type: "provider.connect"; @@ -111,6 +113,7 @@ const providerMetadataSchema = z label: z.string().min(1), description: z.string().optional(), iconPath: z.string().optional(), + requiresConfiguration: z.boolean().optional(), hasCatalogCacheKey: z.boolean().optional(), hasStatus: z.boolean().optional(), command: z.tuple([z.string().min(1)], z.string()).optional(), @@ -199,7 +202,12 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis }) .strict(), z - .object({ type: z.literal("usage.discover"), requestId: z.string(), sourceId: z.string() }) + .object({ + type: z.literal("usage.discover"), + requestId: z.string(), + sourceId: z.string(), + scope: UsageScopeSchema, + }) .strict(), z .object({ 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/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 93c9a3d922..5e6efa1925 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -158,6 +158,7 @@ export function createPluginWorker(options: { label: provider.label, description: provider.description, iconPath: provider.icon, + requiresConfiguration: provider.requiresConfiguration, hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, hasStatus: provider.status !== undefined, command: provider.command, @@ -344,7 +345,8 @@ export function createPluginWorker(options: { void (async () => { const source = usageSources.get(message.sourceId); if (!source) throw new Error(`Unknown usage source: ${message.sourceId}`); - if (message.type === "usage.discover") return jsonTransportValue(await source.discover()); + if (message.type === "usage.discover") + return jsonTransportValue(await source.discover(message.scope)); const input = await source.input.parseAsync(message.input); return jsonTransportValue(await source.fetch(input)); })().then( diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index a26e7cbfea..9886a368e7 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,3 +1,4 @@ +import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { ProviderStatusSchema, type ProviderStatus, @@ -295,6 +296,7 @@ async function resolveEntryPaths(directory: string): Promise<{ export class PluginRuntime { private readonly plugins = new Map<string, LoadedPlugin>(); + private readonly pendingEvents = new Set<Promise<void>>(); private readonly logTails = new Map<string, PluginLogTail>(); private readonly logger: pino.Logger; private readonly spawnChild: () => PluginChild; @@ -401,10 +403,15 @@ export class PluginRuntime { return this.request(loaded, { type: "usage.fetch", requestId: randomUUID(), sourceId, input }); } - discoverUsage(pluginId: string, sourceId: string): Promise<unknown> { + discoverUsage(pluginId: string, sourceId: string, scope: UsageScope): Promise<unknown> { const loaded = this.plugins.get(pluginId); if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); - return this.request(loaded, { type: "usage.discover", requestId: randomUUID(), sourceId }); + return this.request(loaded, { + type: "usage.discover", + requestId: randomUUID(), + sourceId, + scope, + }); } async getProviderStatus( @@ -515,22 +522,30 @@ export class PluginRuntime { if (!loaded.hooks.events.includes(name)) { continue; } - void this.request(loaded, { + const request = this.request(loaded, { type: "hook", requestId: randomUUID(), kind: "event", name, input: event, - }).catch((error) => { - this.appendLog( - loaded.id, - "stderr", - `Lifecycle hook ${name} failed: ${describeError(error)}`, - ); - }); + }) + .then(() => undefined) + .catch((error) => { + this.appendLog( + loaded.id, + "stderr", + `Lifecycle hook ${name} failed: ${describeError(error)}`, + ); + }); + this.pendingEvents.add(request); + void request.finally(() => this.pendingEvents.delete(request)); } } + async drainEvents(): Promise<void> { + await Promise.all(this.pendingEvents); + } + async before<Name extends keyof PluginBeforeRequests>( name: Name, input: PluginBeforeRequests[Name], diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts index cff548adc5..037da5e0c6 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -21,7 +21,12 @@ test("lists built-in and subprocess usage; validates input and isolates fetch er const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); try { await client.connect(); - const first = await client.listUsageReports(); + const updates: string[] = []; + const first = await client.listUsageReports({}, (report) => updates.push(report.id)); + expect(updates).toEqual(first.reports.map((report) => report.id)); + await expect(client.listUsageReports({ agentId: "missing-agent" })).rejects.toThrow( + "Unknown agent", + ); expect(first.reports).toHaveLength(4); expect(first.reports[0]?.icon).toContain("<svg"); expect(first.reports[0]?.fetchedAt).toMatch(/^\d{4}-/); @@ -220,7 +225,7 @@ async function fetchApi(url, init) { } export default function contribute(server) { server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema, - discover: () => discover(lookup${source === "claude" ? ", fetchApi" : ""}), + discover: (scope) => discover(scope, lookup${source === "claude" ? ", fetchApi" : ""}), fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup), }); return () => {}; 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 17ad174a29..eeea242788 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -126,7 +126,7 @@ test("rediscovery removes an old account from targeted refreshes", async () => { expect(await registry.listReports({ reportIds: ["source:old"] })).toEqual([]); }); -test("discovery failures log once and produce no card", async () => { +test("discovery failures log once while other sources still report", async () => { const warnings: unknown[][] = []; const registry = new UsageSourceRegistry(Date.now, 300_000, { warn: (...args: unknown[]) => { @@ -142,10 +142,36 @@ test("discovery failures log once and produce no card", async () => { }, }), ); - expect(await registry.listReports()).toEqual([]); + registry.register(source({ id: "healthy", discover: async () => [{ key: "one", input: {} }] })); + const updates: string[] = []; + const reports = await registry.listReports({ onReport: (report) => updates.push(report.id) }); + expect(reports.map((report) => report.id)).toEqual(["healthy:one"]); + expect(updates).toEqual(["healthy:one"]); 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( @@ -164,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[] = []; @@ -190,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]); } }); @@ -279,3 +305,254 @@ test("failed fallbacks preserve the final problem", async () => { problem: { kind: "rejected", status: 403 }, }); }); + +test("session discovery reuses accounts, separates launches, and retains live agent reports globally", async () => { + const sessions = new Map([ + ["one", { provider: "claude", env: { HOME: "/one" }, sessionKey: "launch-1" }], + ["two", { provider: "claude", env: { HOME: "/two" }, sessionKey: "launch-2" }], + ]); + const existing = new Set(["one", "two", "closed"]); + const discoveries: string[] = []; + let fetches = 0; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => existing.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register({ + id: "claude", + label: "Claude", + discover: async (scope) => { + discoveries.push(scope.kind === "global" ? "global" : scope.env.HOME); + if (scope.kind === "global") return [{ key: "default", input: "default" }]; + return [{ key: scope.env.HOME === "/new" ? "new" : "same", input: scope.env.HOME }]; + }, + fetch: async () => { + fetches++; + return { status: "available", windows: [] }; + }, + }); + expect(await registry.listReports({ agentId: "closed" })).toEqual([]); + expect((await registry.listReports({ agentId: "one" })).map((r) => r.id)).toEqual([ + "claude:same", + ]); + await registry.listReports({ agentId: "one" }); + await registry.listReports({ agentId: "two" }); + expect(discoveries).toEqual(["/one", "/two"]); + expect(fetches).toBe(2); + expect((await registry.listReports()).map((r) => r.id)).toEqual([ + "claude:default", + "claude:same", + ]); + sessions.set("one", { provider: "claude", env: { HOME: "/new" }, sessionKey: "launch-3" }); + expect((await registry.listReports({ agentId: "one" })).map((r) => r.id)).toEqual(["claude:new"]); + existing.delete("two"); + sessions.delete("two"); + expect((await registry.listReports()).map((r) => r.id)).toEqual(["claude:default", "claude:new"]); + await expect(registry.listReports({ agentId: "missing" })).rejects.toThrow("Unknown agent"); + await expect(registry.listReports({ agentId: "one", reportIds: [] })).rejects.toThrow("agentId"); +}); + +test("reports arrive independently and a hung fetch settles as an error before completion", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, undefined, 25); + let finishSlow!: (value: unknown) => void; + const slow = new Promise<unknown>((resolve) => { + finishSlow = resolve; + }); + const reports: string[] = []; + let firstReport!: () => void; + const first = new Promise<void>((resolve) => { + firstReport = resolve; + }); + for (const id of ["slow", "fast", "also-fast"]) { + registry.register( + source({ + id, + discover: async () => [{ key: "account", input: {} }], + fetch: async () => (id === "slow" ? slow : { status: "available", windows: [] }), + }), + ); + } + let complete = false; + const request = registry + .listReports({ + onReport: (entry) => { + reports.push(entry.id); + firstReport(); + }, + }) + .then((entries) => { + complete = true; + return entries; + }); + await first; + expect(reports).toEqual(["fast:account", "also-fast:account"]); + expect(complete).toBe(false); + const entries = await request; + expect(reports).toEqual(["fast:account", "also-fast:account", "slow:account"]); + expect(entries[0]?.report).toEqual({ status: "error", error: "Usage fetch timed out" }); + finishSlow({ status: "available", windows: [] }); + expect((await registry.listReports({ reportIds: ["slow:account"] }))[0]?.report).toEqual( + 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 2d31798fbd..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,6 @@ +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"; import type { Logger } from "pino"; import { @@ -12,20 +15,46 @@ export interface UsageSource { id: string; label: string; icon?: string; - discover(): Promise<unknown>; + discover(scope: UsageScope): Promise<unknown>; fetch(input: unknown): Promise<unknown>; } +interface Login { + input: unknown; + harness: string; +} + interface KnownReport { source: UsageSource; - inputs: [unknown, ...unknown[]]; + logins: [Login, ...Login[]]; label?: string; } +export interface AgentUsageLookup { + hasAgent(id: string): boolean; + usageSession(id: string): AgentUsageSession | null; +} + +export interface ListUsageReportsOptions { + forceRefresh?: boolean; + reportIds?: string[]; + agentId?: string; + onReport?: (report: UsageReportEntry) => void; +} + +interface AgentReports { + provider: string; + model?: string; + sessionKey: string; + reports: Map<string, KnownReport>; +} + /** Owns account identity, ordered logins for each account, and the five-minute fetch cache. */ export class UsageSourceRegistry { private readonly sources = new Map<string, UsageSource>(); private readonly known = new Map<string, KnownReport>(); + private defaults = new Map<string, KnownReport>(); + private readonly byAgent = new Map<string, AgentReports>(); private readonly cache = new Map<string, { at: number; entry: UsageReportEntry }>(); private readonly pending = new Map<string, Promise<UsageReportEntry>>(); @@ -33,6 +62,8 @@ export class UsageSourceRegistry { private readonly now: () => number = Date.now, private readonly ttlMs = 300_000, private readonly logger: Pick<Logger, "warn"> = { warn: console.warn }, + private readonly agents: AgentUsageLookup = { hasAgent: () => false, usageSession: () => null }, + private readonly deadlineMs = 20_000, ) {} register(source: UsageSource): void { @@ -42,59 +73,139 @@ export class UsageSourceRegistry { unregister(id: string): void { this.sources.delete(id); + for (const key of this.defaults.keys()) if (key.startsWith(`${id}:`)) this.defaults.delete(key); + // Re-discover on the next session query when a source is replaced. + for (const [agentId, mapping] of this.byAgent) { + if ([...mapping.reports.values()].some((report) => report.source.id === id)) + this.byAgent.delete(agentId); + } 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: { forceRefresh?: boolean; reportIds?: string[] } = {}, - ): Promise<UsageReportEntry[]> { - const ids = options.reportIds ?? (await this.discoverReportIds()); + async listReports(options: ListUsageReportsOptions = {}): Promise<UsageReportEntry[]> { + if (options.agentId !== undefined && options.reportIds !== undefined) + 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 { + this.defaults = await this.discover({ kind: "global" }); + this.mergeKnown(); + ids = [...this.known.keys()]; + } return Promise.all( [...new Set(ids)].flatMap((id) => { - const known = this.known.get(id); - return known ? [this.fetchId(id, known, options.forceRefresh)] : []; + const known = reports.get(id); + if (!known) return []; + return [ + this.fetchId(id, known, options.forceRefresh).then((entry) => { + options.onReport?.(entry); + return entry; + }), + ]; }), ); } - private async discoverReportIds(): Promise<string[]> { + private pruneAgents(): void { + for (const id of this.byAgent.keys()) { + if (!this.agents.hasAgent(id)) this.byAgent.delete(id); + } + this.mergeKnown(); + } + + private async discoverAgent(agentId: string): Promise<string[]> { + if (!this.agents.hasAgent(agentId)) throw new Error(`Unknown agent: ${agentId}`); + const session = this.agents.usageSession(agentId); + if (!session) return []; + const previous = this.byAgent.get(agentId); + const sameScope = + previous?.sessionKey === session.sessionKey && + previous.provider === session.provider && + previous.model === session.model; + if (sameScope) return [...previous.reports.keys()]; + const reports = await this.discover({ + kind: "session", + provider: session.provider, + model: session.model, + env: session.env, + }); + // A query that finishes after a resume must not publish the previous launch's mapping. + if (this.agents.usageSession(agentId)?.sessionKey !== session.sessionKey) return []; + this.byAgent.set(agentId, { + sessionKey: session.sessionKey, + provider: session.provider, + model: session.model, + reports, + }); + this.mergeKnown(); + return [...reports.keys()]; + } + + private mergeKnown(): void { + this.known.clear(); + for (const reports of [ + this.defaults, + ...[...this.byAgent.values()].map((agent) => agent.reports), + ]) { + for (const [id, report] of reports) { + if (this.sources.get(report.source.id) !== report.source) continue; + const known = this.known.get(id); + 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); + } + } + } + } + + private async discover(scope: UsageScope): Promise<Map<string, KnownReport>> { const discovered = await Promise.all( [...this.sources.values()].map(async (source) => { + const reports = new Map<string, KnownReport>(); try { const accounts = z .array( 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(), }), ) - .parse(await source.discover()); - const knownReports = new Map<string, KnownReport>(); - const ids: string[] = []; + .parse(await source.discover(scope)); for (const account of accounts) { const id = `${source.id}:${account.key}`; - ids.push(id); - const known = knownReports.get(id); - if (known) known.inputs.push(account.input); - else knownReports.set(id, { source, inputs: [account.input], label: account.label }); + const known = reports.get(id); + // 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 }); } - for (const key of this.known.keys()) { - if (key.startsWith(`${source.id}:`)) this.known.delete(key); - } - for (const [id, known] of knownReports) this.known.set(id, known); - return ids; } catch (error) { - for (const key of this.known.keys()) { - if (key.startsWith(`${source.id}:`)) this.known.delete(key); - } this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); - return []; } + return reports; }), ); - return discovered.flat(); + const merged = new Map<string, KnownReport>(); + for (const reports of discovered) { + for (const [id, report] of reports) merged.set(id, report); + } + return merged; } // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. @@ -123,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 = { @@ -136,14 +251,14 @@ export class UsageSourceRegistry { icon: known.source.icon, account: { label: known.label }, fetchedAt: new Date(this.now()).toISOString(), - report: await this.fetchWithFallback(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; } @@ -156,19 +271,45 @@ export class UsageSourceRegistry { this.cache.set(id, { at, entry }); } - 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 fetchBeforeDeadline(source: UsageSource, input: unknown): Promise<UsageReport> { + let timer: ReturnType<typeof setTimeout> | undefined; + const deadline = new Promise<UsageReport>((resolve) => { + timer = setTimeout( + () => resolve({ status: "error", error: "Usage fetch timed out" }), + this.deadlineMs, + ); + }); + try { + return await Promise.race([this.fetchLogin(source, input), deadline]); + } finally { + clearTimeout(timer); } - return report; + } + + 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 }); + } + // 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) }; } } @@ -196,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/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index 82895e302b..8d44b9ba80 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import type { ForgeService } from "../services/forge-service.js"; +import { buildForkLocalBranchName } from "../utils/change-request-checkout.js"; import { CheckoutSourceForgeMismatchError, MissingCheckoutTargetError, @@ -32,14 +33,8 @@ function createResolverHarness(overrides?: { defaultCheckoutRefs: ({ changeRequestNumber }) => [ { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, ], - buildPrLocalBranchName: ({ headRef, checkoutTarget }) => { - const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? ""; - const owner = - checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized) - ? normalized - : null; - return owner ? `${owner}/${headRef}` : headRef; - }, + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), supportsCrossRepoCheckoutWithoutRefs: true, } : {}; @@ -182,6 +177,28 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("does not track origin for a same-repo head with no origin branch", async () => { + const deps = createResolverHarness(); + deps.forgeService.getPullRequestCheckoutTarget = async () => ({ + number: 42, + baseRefName: "main", + headRefName: "ford/towel", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + headRefKind: "pull-ref", + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", githubPrNumber: 42 }, + repoRoot, + deps, + ); + + expect(intent).not.toHaveProperty("trackOriginHead"); + }); + test("configures the contributor remote for fork PR targets", async () => { const deps = createResolverHarness(); deps.forgeService.getPullRequestCheckoutTarget = async () => ({ @@ -211,6 +228,29 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("prefixes the pr number when the fork was deleted", async () => { + const deps = createResolverHarness(); + deps.forgeService.getPullRequestCheckoutTarget = async () => ({ + number: 42, + baseRefName: "main", + headRefName: "towel", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", githubPrNumber: 42 }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ headRef: "towel", localBranchName: "pr-42/towel" }); + expect(intent).not.toHaveProperty("pushRemoteUrl"); + expect(intent).not.toHaveProperty("trackOriginHead"); + }); + test("uses an explicit PR head ref without calling GitHub", async () => { const deps = createResolverHarness(); @@ -293,6 +333,105 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("names the local branch pr-<number> for a gitea pull-ref-only head", async () => { + const deps = createResolverHarness({ + forge: "gitea", + forgeService: { + defaultCheckoutRefs: ({ changeRequestNumber }) => [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: `refs/pull/${number}/head`, + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + headRefKind: "pull-ref", + }), + }, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", checkoutSource: { kind: "change_request", number: 3 } }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ localBranchName: "pr-3" }); + expect(intent).not.toHaveProperty("trackOriginHead"); + }); + + test("names the local branch owner/pr-<number> for a gitea fork pull-ref-only head", async () => { + const deps = createResolverHarness({ + forge: "gitea", + forgeService: { + defaultCheckoutRefs: ({ changeRequestNumber }) => [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), + supportsCrossRepoCheckoutWithoutRefs: true, + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: `refs/pull/${number}/head`, + headOwnerLogin: "contributor", + headRepositorySshUrl: "git@gitea.test:contributor/repo.git", + headRepositoryUrl: "https://gitea.test/contributor/repo", + isCrossRepository: true, + headRefKind: "pull-ref", + }), + }, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", checkoutSource: { kind: "change_request", number: 3 } }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ localBranchName: "contributor/pr-3" }); + }); + + test("does not double the pr number for a gitea deleted fork's pull-ref-only head", async () => { + const deps = createResolverHarness({ + forge: "gitea", + forgeService: { + defaultCheckoutRefs: ({ changeRequestNumber }) => [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), + supportsCrossRepoCheckoutWithoutRefs: true, + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: `refs/pull/${number}/head`, + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + headRefKind: "pull-ref", + }), + }, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", checkoutSource: { kind: "change_request", number: 3 } }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ localBranchName: "pr-3" }); + }); + test("reports unsupported cross-repository checkout targets without a checkout ref", async () => { const deps = createResolverHarness({ forge: "gitea", diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index 73403f5908..5eddc0ef7b 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -191,7 +191,8 @@ async function resolvePrCheckoutIntent( }) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }]; const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget }); const crossRepository = resolveCrossRepositoryFields(checkoutTarget); - const trackOriginHead = !checkoutTarget.isCrossRepository; + const trackOriginHead = + !checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref"; return { kind: "checkout-change-request", diff --git a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts index a27de8825c..877ec3aced 100644 --- a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts +++ b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts @@ -235,6 +235,10 @@ test("archiveOnFinish=false local scheduled run emits upserts and remains active await waitForAgentUpsert(events.agentUpdates, agentId); expect(workspaceWasRemoved(events.workspaceUpdates, workspaceId!)).toBe(false); expect(await activeAgentIds()).toContain(agentId); + expect( + (await ctx.client.fetchWorkspaces()).entries.find((entry) => entry.id === workspaceId) + ?.background, + ).toBe(false); events.stop(); }); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 14e0221913..1a9f0f5fd4 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -44,6 +44,37 @@ interface ScheduleServiceInternals { executeSchedule(schedule: StoredSchedule, runId: string): Promise<ScheduleExecutionResult>; } +class OverlappingTickRunner { + private resolveFirstRunStarted: () => void = () => {}; + private resolveFirstRunBlocked: () => void = () => {}; + private laterScheduleRunCount = 0; + + readonly firstRunStarted = new Promise<void>((resolve) => { + this.resolveFirstRunStarted = resolve; + }); + private readonly firstRunBlocked = new Promise<void>((resolve) => { + this.resolveFirstRunBlocked = resolve; + }); + + async run(schedule: StoredSchedule): Promise<ScheduleExecutionResult> { + if (schedule.prompt === "block the first tick") { + this.resolveFirstRunStarted(); + await this.firstRunBlocked; + } else { + this.laterScheduleRunCount += 1; + } + return { agentId: null, output: "ok" }; + } + + unblockFirstRun(): void { + this.resolveFirstRunBlocked(); + } + + get laterScheduleRuns(): number { + return this.laterScheduleRunCount; + } +} + const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, @@ -345,6 +376,241 @@ describe("ScheduleService", () => { expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); }); + test.each(["new-agent", "agent", "maxRuns: 1"] as const)( + "dispatches a shared cron slot only once for %s after an earlier tick resumes", + async (targetKind) => { + const runner = new OverlappingTickRunner(); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: (schedule) => runner.run(schedule), + }); + const cadence = { type: "cron" as const, expression: "0 6 * * 1", timezone: "Europe/Prague" }; + await service.create({ + prompt: "block the first tick", + cadence, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + // Give the later schedule a distinct creation time so file ordering cannot affect the race. + now = new Date("2026-01-01T00:00:01.000Z"); + const agentId = "00000000-0000-0000-0000-000000000001"; + await agentStorage.upsert( + buildAgentRecord({ id: agentId, cwd: tempDir, iso: now.toISOString() }), + ); + const later = await service.create({ + prompt: "later due schedule", + cadence, + target: + targetKind === "agent" + ? { type: "agent", agentId } + : { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: targetKind === "maxRuns: 1" ? 1 : undefined, + }); + now = new Date("2026-01-05T05:00:00.000Z"); + const delayedTick = service.tick(); + await runner.firstRunStarted; + try { + now = new Date("2026-01-05T05:00:01.000Z"); + await service.tick(); + } finally { + runner.unblockFirstRun(); + await delayedTick; + } + const result = await service.inspect(later.id); + expect.soft(runner.laterScheduleRuns).toBe(1); + expect.soft(result.runs).toHaveLength(1); + expect.soft(result.runs.map((run) => run.scheduledFor)).toEqual(["2026-01-05T05:00:00.000Z"]); + expect(result.nextRunAt).toBe( + targetKind === "maxRuns: 1" ? null : "2026-01-12T05:00:00.000Z", + ); + expect(result.status).toBe(targetKind === "maxRuns: 1" ? "completed" : "active"); + }, + ); + + test("advances the next scheduled slot past a run that crosses a cadence boundary", async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => { + now = new Date("2026-01-01T00:02:00.000Z"); + return { agentId: null, output: "ok" }; + }, + }); + + const created = await service.create({ + prompt: "long-running task", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + expect((await service.inspect(created.id)).nextRunAt).toBe("2026-01-01T00:03:00.000Z"); + }); + + test("runs the final allowed cron slot without requiring another occurrence", async () => { + let dispatches = 0; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => { + dispatches += 1; + return { agentId: null, output: "done" }; + }, + }); + const created = await service.create({ + prompt: "Run once on January 5 when it is Monday", + cadence: { type: "cron", expression: "0 6 5 1 1" }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + now = new Date("2026-01-05T06:00:00.000Z"); + await service.tick(); + const result = await service.inspect(created.id); + expect(dispatches).toBe(1); + expect(result.status).toBe("completed"); + expect(result.nextRunAt).toBeNull(); + expect(result.runs).toHaveLength(1); + expect(result.runs[0].status).toBe("succeeded"); + }); + + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "keeps firing future slots after an outcome could not be saved", + async () => { + const schedulesDir = join(tempDir, "schedules"); + let dispatches = 0; + const failOutcomeWrite = async (): Promise<ScheduleExecutionResult> => { + dispatches += 1; + if (dispatches === 1) { + await chmod(schedulesDir, 0o555); + } + return { agentId: null, output: "done" }; + }; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: failOutcomeWrite, + }); + const created = await service.create({ + prompt: "Check status each minute", + runOnCreate: false, + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + now = new Date("2026-01-01T00:01:00.000Z"); + try { + await expect(service.tick()).rejects.toThrow(); + } finally { + await chmod(schedulesDir, 0o755); + } + expect((await service.inspect(created.id)).runs[0].status).toBe("running"); + now = new Date("2026-01-01T00:02:00.000Z"); + await service.tick(); + const result = await service.inspect(created.id); + expect(dispatches).toBe(2); + expect(result.runs.map((run) => run.scheduledFor)).toEqual([ + "2026-01-01T00:01:00.000Z", + "2026-01-01T00:02:00.000Z", + ]); + expect(result.nextRunAt).toBe("2026-01-01T00:03:00.000Z"); + }, + ); + + test("completes an interrupted final slot during restart recovery", async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "done" }), + }); + const created = await service.create({ + prompt: "Run once", + cadence: { type: "cron", expression: "0 6 5 1 1" }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + now = new Date("2026-01-05T06:00:00.000Z"); + const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger()); + await store.update(created.id, (schedule) => ({ + ...schedule, + nextRunAt: null, + runs: [ + { + id: "interrupted-final-slot", + scheduledFor: now.toISOString(), + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }, + ], + })); + now = new Date("2026-01-05T06:01:00.000Z"); + await service.start(); + try { + const result = await service.inspect(created.id); + expect(result.status).toBe("completed"); + expect(result.nextRunAt).toBeNull(); + expect(result.runs).toHaveLength(1); + expect(result.runs[0].status).toBe("failed"); + } finally { + await service.stop(); + } + }); + + test.each([2, null])( + "keeps a next slot when the final run's limit changes to %s", + async (maxRuns) => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async (schedule) => { + await service.update({ id: schedule.id, maxRuns }); + return { agentId: null, output: "done" }; + }, + }); + const created = await service.create({ + prompt: "Allow more runs during execution", + runOnCreate: false, + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + now = new Date("2026-01-01T00:01:30.000Z"); + await service.tick(); + const result = await service.inspect(created.id); + expect(result.status).toBe("active"); + expect(result.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + expect(result.runs).toHaveLength(1); + }, + ); + test("pause and resume update persisted schedule state", async () => { const service = createScheduleService({ paseoHome: tempDir, @@ -904,7 +1170,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType<ScheduleServiceOptions["createAgent"]> >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -982,7 +1247,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType<ScheduleServiceOptions["createAgent"]> >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -1052,7 +1316,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType<ScheduleServiceOptions["createAgent"]> >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -1112,7 +1375,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType<ScheduleServiceOptions["createAgent"]> >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -2867,6 +3129,44 @@ describe("ScheduleService", () => { expect(inspected.runs[0]?.error).toBe("network blip"); }); + // chmod cannot make a directory unwritable on Windows or for root. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "fires again after a tick whose run could not be recorded", + async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ran" }), + }); + + const created = await service.create({ + prompt: "ping target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "88888888-8888-4888-8888-888888888888" }, + }); + + const schedulesDir = join(tempDir, "schedules"); + now = new Date("2026-01-01T00:01:00.000Z"); + await chmod(schedulesDir, 0o555); + try { + await expect(service.tick()).rejects.toThrow(); + } finally { + await chmod(schedulesDir, 0o755); + } + + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]?.status).toBe("succeeded"); + expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + }, + ); + test("completes the schedule when a scheduled run targets an archived agent", async () => { const service = createScheduleService({ paseoHome: tempDir, diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 53d2432f88..7b5487c3f8 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -1,3 +1,4 @@ +import { formatSystemNotificationPrompt } from "../agent/agent-messages/index.js"; import { randomUUID } from "node:crypto"; import { stat } from "node:fs/promises"; import { join } from "node:path"; @@ -7,11 +8,7 @@ import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; import type { AgentStorage } from "../agent/agent-storage.js"; import { curateAgentActivity } from "../agent/activity-curator.js"; import { ensureAgentLoaded } from "../agent/agent-loading.js"; -import { - formatSystemNotificationPrompt, - startAgentRun, - type AgentRunController, -} from "../agent/agent-prompt.js"; +import { startAgentRun, type AgentRunController } from "../agent/agent-prompt.js"; import { resolveCreateAgentTitles } from "../agent/create-agent-title.js"; import { type BoundCreateAgentCommand, formatProviderModel } from "../agent/create-agent/create.js"; import type { PersistedWorkspaceRecord } from "../workspace-registry.js"; @@ -41,6 +38,18 @@ export class ScheduleTargetGoneError extends Error { } } +function advanceNextRunAtPast( + cadence: StoredSchedule["cadence"], + nextRunAt: Date, + now: Date, +): Date { + let next = nextRunAt; + while (next.getTime() <= now.getTime()) { + next = computeNextRunAt(cadence, next); + } + return next; +} + function trimOptionalName(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -624,6 +633,9 @@ export class ScheduleService { error: "Daemon restarted before the scheduled run completed", }; updated = { ...updated, runs }; + if (shouldCompleteSchedule(updated, now)) { + updated = completeSchedule(updated, now); + } dirty = true; } @@ -693,48 +705,113 @@ export class ScheduleService { options?: { manual?: boolean }, ): Promise<void> { const manual = options?.manual === true; + // Reserve before store I/O; persisted history cannot prove a run is still alive. this.runningScheduleIds.add(schedule.id); - const runId = randomUUID(); - const runningRun: ScheduleRun = { - id: runId, - scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()), - startedAt: now.toISOString(), - endedAt: null, - status: "running", - agentId: null, - output: null, - error: null, - }; - const scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun); - try { - const result = await this.runner(scheduleWithRun, runId); - await this.finishRun({ - scheduleId: schedule.id, - runId, - status: "succeeded", - agentId: result.agentId, - output: result.output, - error: null, - targetGone: false, - manual, - }); - } catch (error) { - await this.finishRun({ - scheduleId: schedule.id, - runId, - status: "failed", - agentId: null, - output: null, - error: error instanceof Error ? error.message : String(error), - targetGone: error instanceof ScheduleTargetGoneError, - manual, - }); + let runId: string; + let scheduleWithRun: StoredSchedule; + if (manual) { + runId = randomUUID(); + const runningRun: ScheduleRun = { + id: runId, + scheduledFor: now.toISOString(), + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }; + scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun); + } else { + const claimed = await this.claimDueRun(schedule.id, now); + if (!claimed) { + return; + } + ({ schedule: scheduleWithRun, runId } = claimed); + } + + try { + const result = await this.runner(scheduleWithRun, runId); + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "succeeded", + agentId: result.agentId, + output: result.output, + error: null, + targetGone: false, + manual, + }); + } catch (error) { + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "failed", + agentId: null, + output: null, + error: error instanceof Error ? error.message : String(error), + targetGone: error instanceof ScheduleTargetGoneError, + manual, + }); + } } finally { this.runningScheduleIds.delete(schedule.id); } } + private async claimDueRun( + scheduleId: string, + now: Date, + ): Promise<{ schedule: StoredSchedule; runId: string } | null> { + let claimed: { schedule: StoredSchedule; runId: string } | null = null; + const updatedSchedule = await this.store.update(scheduleId, (schedule) => { + if ( + schedule.status !== "active" || + !schedule.nextRunAt || + shouldCompleteSchedule(schedule, now) || + new Date(schedule.nextRunAt).getTime() > now.getTime() + ) { + return schedule; + } + + const scheduledFor = schedule.nextRunAt; + const isFinalRun = + schedule.maxRuns !== null && countCompletedRuns(schedule) + 1 >= schedule.maxRuns; + const nextRunAt = isFinalRun + ? null + : advanceNextRunAtPast( + schedule.cadence, + computeNextRunAt(schedule.cadence, new Date(scheduledFor)), + now, + ).toISOString(); + + // Claim the due slot and advance its cursor in the same serialized update. + // A tick can hold a stale list while another tick finishes this schedule. + const runId = randomUUID(); + const runningRun: ScheduleRun = { + id: runId, + scheduledFor, + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }; + const updated: StoredSchedule = { + ...schedule, + nextRunAt, + updatedAt: now.toISOString(), + runs: [...schedule.runs, runningRun], + }; + claimed = { schedule: updated, runId }; + return updated; + }); + requireSchedule(updatedSchedule, scheduleId); + return claimed; + } + private async appendRunningRun( scheduleId: string, runningRun: ScheduleRun, @@ -795,14 +872,16 @@ export class ScheduleService { nextRunAt: null, }; } else { - const after = new Date(schedule.nextRunAt ?? now.toISOString()); - let nextRunAt = computeNextRunAt(updated.cadence, after); - while (nextRunAt.getTime() <= now.getTime()) { - nextRunAt = computeNextRunAt(updated.cadence, nextRunAt); - } + // A final slot has no successor until its run limit is raised or cleared. + // Every runner invocation has a recorded run with this id. + const finishedRun = completedRuns.find((run) => run.id === params.runId)!; + const nextRunAt = updated.nextRunAt + ? new Date(updated.nextRunAt) + : computeNextRunAt(updated.cadence, new Date(finishedRun.scheduledFor)); + // Move an overdue cursor past a long run without advancing a future one twice. updated = { ...updated, - nextRunAt: nextRunAt.toISOString(), + nextRunAt: advanceNextRunAtPast(updated.cadence, nextRunAt, now).toISOString(), }; } @@ -910,7 +989,6 @@ export class ScheduleService { features: config.featureValues, unattended: true, promptFailure: "return-error", - background: true, notifyOnFinish: false, }); const agent = created.snapshot; diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index bbc5acf3c7..96af0133ca 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -99,14 +99,14 @@ function createStubTerminalManager( }; } -async function startTcpServer(): Promise<TcpServerHandle> { +async function startTcpServer(host = "127.0.0.1"): Promise<TcpServerHandle> { const server = net.createServer((socket) => { socket.end(); }); await new Promise<void>((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => { + server.listen(0, host, () => { server.off("error", reject); resolve(); }); @@ -153,6 +153,45 @@ describe("ScriptHealthMonitor", () => { servers.clear(); }); + it.each(["127.0.0.1", "::1"])("reports a service bound only to %s as healthy", async (host) => { + const service = await startTcpServer(host); + servers.add(service.server); + const serviceProxy = new ScriptRouteStore(); + serviceProxy.registerWorkspaceService({ + workspaceId: "workspace-a", + projectSlug: "repo", + branchName: "main", + scriptName: "api", + port: service.port, + }); + const updates: ScriptHealthEntry[][] = []; + const monitor = new ScriptHealthMonitor({ + serviceProxy, + onChange: (_workspaceId, scripts) => updates.push(scripts), + pollIntervalMs: 10, + probeTimeoutMs: 100, + graceMs: 0, + failuresBeforeStopped: 1, + }); + monitor.start(); + try { + await vi.waitFor(() => { + expect(updates).toEqual([ + [ + { + scriptName: "api", + hostname: "api--repo.localhost", + port: service.port, + health: "healthy", + }, + ], + ]); + }); + } finally { + monitor.stop(); + } + }); + it("starts new service routes in pending and transitions to healthy after the grace period", async () => { vi.useFakeTimers(); diff --git a/packages/server/src/server/script-health-monitor.ts b/packages/server/src/server/script-health-monitor.ts index 810518f999..7992608f24 100644 --- a/packages/server/src/server/script-health-monitor.ts +++ b/packages/server/src/server/script-health-monitor.ts @@ -1,4 +1,4 @@ -import net from "node:net"; +import { connectToLocalService } from "./local-service-connection.js"; import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js"; export type ScriptHealthState = "pending" | "healthy" | "unhealthy"; @@ -208,7 +208,7 @@ export class ScriptHealthMonitor { private probeRoute(port: number): Promise<boolean> { return new Promise((resolve) => { - const socket = net.connect({ host: "127.0.0.1", port }); + const socket = connectToLocalService(port); let settled = false; const finish = (healthy: boolean) => { diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts index 5313b01d2d..5764b19539 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -32,15 +32,20 @@ function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[ } interface HttpGetOptions { + hostname?: string; path?: string; headers?: Record<string, string>; } function httpGet(port: number, host: string, options: HttpGetOptions = {}) { - const { path: requestPath = "/api/health", headers: extraHeaders = {} } = options; + const { + hostname = "127.0.0.1", + path: requestPath = "/api/health", + headers: extraHeaders = {}, + } = options; return new Promise<{ status: number; body: string }>((resolve, reject) => { const req = http.get( - { hostname: "127.0.0.1", port, path: requestPath, headers: { host, ...extraHeaders } }, + { hostname, port, path: requestPath, headers: { host, ...extraHeaders } }, (res) => { let body = ""; res.on("data", (chunk: Buffer) => { @@ -53,6 +58,31 @@ function httpGet(port: number, host: string, options: HttpGetOptions = {}) { }); } +function httpPost(port: number, host: string, requestPath: string, payload: string) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = http.request( + { + hostname: "127.0.0.1", + port, + method: "POST", + path: requestPath, + headers: { host, "content-type": "application/json" }, + }, + (res) => { + let body = ""; + res.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + res.on("error", reject); + }, + ); + request.on("error", reject); + request.write(payload.slice(0, 5)); + request.end(payload.slice(5)); + }); +} + describe("service proxy subsystem shape", () => { it("keeps production imports behind the service-proxy entrypoint", () => { const offenders: string[] = []; @@ -303,6 +333,7 @@ describe("service proxy subsystem shape", () => { }); interface ForwardedFixture { + upstreamPort: number; daemonPort: number; hostname: string; close(): Promise<void>; @@ -313,11 +344,16 @@ interface ForwardedFixture { * echoes the headers it received so tests can assert what actually crossed the * proxy, not what a helper returned. */ -async function startForwardedHeadersFixture(): Promise<ForwardedFixture> { - const upstreamPort = await findFreePort(); +async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> { const upstream = http.createServer((req, res) => { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify(req.headers)); + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body })); + }); }); const upgradeSockets: net.Socket[] = []; upstream.on("upgrade", (req, socket) => { @@ -331,7 +367,11 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> { `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`, ); }); - await new Promise<void>((resolve) => upstream.listen(upstreamPort, "127.0.0.1", resolve)); + await new Promise<void>((resolve, reject) => { + upstream.once("error", reject); + upstream.listen(0, upstreamHost, resolve); + }); + const upstreamPort = (upstream.address() as net.AddressInfo).port; const serviceProxy = createServiceProxySubsystem({ logger }); const route = serviceProxy.registerWorkspaceService({ @@ -354,6 +394,7 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> { await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve)); return { + upstreamPort, daemonPort, hostname: route.hostname, async close() { @@ -416,6 +457,59 @@ function upgradeThroughProxy( }); } +describe("service proxy loopback services", () => { + it.each(["127.0.0.1", "::1"])("forwards HTTP to a service bound only to %s", async (host) => { + const fixture = await startForwardedHeadersFixture(host); + try { + const direct = await httpGet(fixture.upstreamPort, fixture.hostname, { hostname: host }); + expect(direct.status).toBe(200); + + const response = await httpGet(fixture.daemonPort, fixture.hostname); + expect(response.status).toBe(200); + expect(JSON.parse(response.body).host).toBe(fixture.hostname); + } finally { + await fixture.close(); + } + }); + + it.each(["127.0.0.1", "::1"])( + "streams POST bodies to a service bound only to %s", + async (host) => { + const fixture = await startForwardedHeadersFixture(host); + try { + const payload = JSON.stringify({ message: "hello" }); + const response = await httpPost( + fixture.daemonPort, + fixture.hostname, + "/submit?q=one%20two", + payload, + ); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toMatchObject({ + host: fixture.hostname, + "content-type": "application/json", + method: "POST", + url: "/submit?q=one%20two", + body: payload, + }); + } finally { + await fixture.close(); + } + }, + ); + + it.each(["127.0.0.1", "::1"])("forwards upgrades to a service bound only to %s", async (host) => { + const fixture = await startForwardedHeadersFixture(host); + try { + const headers = await upgradeThroughProxy(fixture.daemonPort, fixture.hostname); + expect(headers.host).toBe(fixture.hostname); + expect(headers.upgrade).toBe("websocket"); + } finally { + await fixture.close(); + } + }); +}); + describe("service proxy forwarded headers", () => { it("forwards the client authority with its port so services build reachable URLs", async () => { const fixture = await startForwardedHeadersFixture(); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index 525a95ac5d..ef93a27f9a 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -1,5 +1,6 @@ import http, { createServer as createHTTPServer } from "node:http"; import net from "node:net"; +import { connectToLocalService, requestLocalService } from "./local-service-connection.js"; import { createHash } from "node:crypto"; import { existsSync, unlinkSync } from "node:fs"; import type { IncomingMessage } from "node:http"; @@ -328,9 +329,8 @@ function proxyHttpRequest({ }): void { const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: req.protocol }); - const proxyReq = http.request( + const proxyReq = requestLocalService( { - hostname: "127.0.0.1", port: route.port, path: req.originalUrl, method: req.method, @@ -368,7 +368,8 @@ function proxyUpgradeRequest({ route: ServiceProxyRoute; logger: Logger; }): void { - const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => { + const targetSocket = connectToLocalService(route.port); + targetSocket.on("connect", () => { const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: "http" }); forwardedHeaders.connection = "Upgrade"; forwardedHeaders.upgrade = req.headers.upgrade ?? "websocket"; diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index aa6773d6df..fb23daa6f0 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -1,11 +1,16 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; +import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; -import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; +import { + createDaemonTestContext, + DaemonClient, + type DaemonTestContext, +} from "./test-utils/index.js"; import type { CreateAgentOptions } from "./test-utils/index.js"; import type { CreateAgentWorktreeTarget } from "./messages.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js"; @@ -96,6 +101,7 @@ async function expectWorktreeListEmpty(repoDir: string): Promise<void> { async function createAgentInBranchOffWorktree(options?: { autoArchive?: boolean; + initialPrompt?: string; branchName?: string; repoDir?: string; }): Promise<{ repoDir: string; agentId: string; worktreePath: string }> { @@ -112,7 +118,7 @@ async function createAgentInBranchOffWorktree(options?: { base: "main", }, ...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), - initialPrompt: "Say done.", + initialPrompt: options?.initialPrompt ?? "Say done.", }); return { repoDir, agentId: created.id, worktreePath: created.cwd }; } @@ -350,24 +356,39 @@ test("archiving a created worktree removes the directory on last reference", asy }); test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => { - const created = await createAgentInBranchOffWorktree({ autoArchive: true }); - - // Create a sibling workspace that shares the same backing directory. - const sibling = await ctx.client.createWorkspace({ - source: { kind: "directory", path: created.worktreePath }, - title: "sibling", + // Hold turn completion while establishing the sibling reference. The original + // ordering raced a fast completed turn against sibling workspace creation. + await ctx.cleanup(); + let finishTurn!: () => void; + const completion = new Promise<void>((resolve) => { + finishTurn = resolve; }); - if (!sibling.workspace) { - throw new Error(sibling.error ?? "Failed to create sibling workspace"); + ctx = await createDaemonTestContext({ + agentClients: createTestAgentClients({ + beforeTurnComplete: async (prompt) => { + if (prompt === "Hold for sibling workspace") await completion; + }, + }), + }); + try { + const created = await createAgentInBranchOffWorktree({ + autoArchive: true, + initialPrompt: "Hold for sibling workspace", + }); + const sibling = await ctx.client.createWorkspace({ + source: { kind: "directory", path: created.worktreePath }, + title: "sibling", + }); + if (!sibling.workspace) throw new Error(sibling.error ?? "Failed to create sibling workspace"); + finishTurn(); + await ctx.client.waitForFinish(created.agentId, 10000); + await expectAgentAbsentFromActiveList(created.agentId); + await expectWorktreePresentInList(created.repoDir, created.worktreePath); + expect(existsSync(created.worktreePath)).toBe(true); + await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); + } finally { + finishTurn(); } - - await ctx.client.waitForFinish(created.agentId, 10000); - - await expectAgentAbsentFromActiveList(created.agentId); - await expectWorktreePresentInList(created.repoDir, created.worktreePath); - expect(existsSync(created.worktreePath)).toBe(true); - - await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); }); test("create_agent_request rejects legacy git options before creating a worktree", async () => { @@ -416,3 +437,248 @@ test("create_agent_request fails cleanly when worktree creation cannot resolve t await expectActiveAgentListEmpty(); await expectWorktreeListEmpty(repoDir); }); + +/** Every agent id with a record under `$PASEO_HOME/agents`, whatever its cwd bucket. */ +function storedAgentIds(): string[] { + const root = path.join(ctx.daemon.paseoHome, "agents"); + if (!existsSync(root)) return []; + return readdirSync(root, { recursive: true, encoding: "utf8" }) + .filter((entry) => entry.endsWith(".json")) + .map((entry) => path.basename(entry, ".json")); +} + +test.each(["git", "worktreeName"] as const)( + "legacy %s worktree creation resolves final workspace visibility without intermediate records", + async (placement) => { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const backgroundCaller = await ctx.client.createAgent({ config, background: true }); + const visibleCaller = await ctx.client.createAgent({ config }); + const cases = [ + { name: "review", background: true, expected: true }, + { name: "review-child", callerAgentId: backgroundCaller.id, expected: true }, + { + name: "visible-child", + callerAgentId: backgroundCaller.id, + background: false, + expected: false, + }, + { name: "hidden-child", callerAgentId: visibleCaller.id, background: true, expected: true }, + { name: "human-default", expected: false }, + ]; + const orderedCases = + placement === "worktreeName" ? [cases[1]!, cases[0]!, ...cases.slice(2)] : cases; + for (const entry of orderedCases) { + const before = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); + const child = await ctx.client.createAgent({ + config, + ...(entry.callerAgentId ? { callerAgentId: entry.callerAgentId } : {}), + ...(entry.background !== undefined ? { background: entry.background } : {}), + ...(placement === "git" + ? { + git: { + createWorktree: true, + createNewBranch: true, + newBranchName: entry.name, + baseBranch: "main", + }, + } + : { worktreeName: entry.name }), + }); + const inclusive = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); + const created = inclusive.entries.filter( + (workspace) => !before.entries.some((existing) => existing.id === workspace.id), + ); + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ + id: child.workspaceId, + workspaceDirectory: child.cwd, + background: entry.expected, + workspaceKind: "worktree", + }); + expect(child.cwd).not.toBe(cwd); + expect(child.labels?.["paseo.parent-agent-id"]).toBe(entry.callerAgentId); + const defaultAgents = await ctx.client.fetchAgents(); + const allAgents = await ctx.client.fetchAgents({ filter: { includeBackground: true } }); + expect(defaultAgents.entries.some((item) => item.agent.id === child.id)).toBe( + !entry.expected, + ); + expect(allAgents.entries.some((item) => item.agent.id === child.id)).toBe(true); + const defaultWorkspaces = await ctx.client.fetchWorkspaces(); + expect( + defaultWorkspaces.entries.some((workspace) => workspace.id === child.workspaceId), + ).toBe(!entry.expected); + } + }, + 30_000, +); + +test("background workspaces keep durable agents addressable through restart and archive", async () => { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const hidden = await ctx.client.createAgent({ + config, + background: true, + idempotencyKey: "background-durable", + }); + expect(hidden.internal).not.toBe(true); + expect( + (await ctx.client.fetchWorkspaces({ filter: { query: hidden.workspaceId } })).entries.map( + (entry) => entry.id, + ), + ).toContain(hidden.workspaceId); + expect(storedAgentIds()).toContain(hidden.id); + expect((await ctx.client.fetchWorkspaces()).entries).not.toContainEqual( + expect.objectContaining({ id: hidden.workspaceId }), + ); + expect( + (await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } })).entries, + ).toContainEqual(expect.objectContaining({ id: hidden.workspaceId, background: true })); + expect((await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain( + hidden.id, + ); + expect( + (await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ), + ).toContain(hidden.id); + const child = await ctx.client.createAgent({ config, callerAgentId: hidden.id }); + expect(child.workspaceId).toBe(hidden.workspaceId); + const inherited = await ctx.client.createWorkspace({ + source: { kind: "directory", path: cwd }, + callerAgentId: hidden.id, + }); + expect(inherited.workspace?.background).toBe(true); + const visible = await ctx.client.createWorkspace({ + source: { kind: "directory", path: cwd }, + callerAgentId: hidden.id, + background: false, + }); + expect(visible.workspace?.background).toBe(false); + const inheritedWorktree = await ctx.client.createAgent({ + config, + callerAgentId: hidden.id, + worktree: { mode: "branch-off", newBranch: "background-child", base: "main" }, + }); + const overriddenWorktree = await ctx.client.createAgent({ + config, + callerAgentId: hidden.id, + background: false, + worktree: { mode: "branch-off", newBranch: "visible-child", base: "main" }, + }); + const workspaces = (await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } })) + .entries; + expect(workspaces.find((entry) => entry.id === inheritedWorktree.workspaceId)?.background).toBe( + true, + ); + expect(workspaces.find((entry) => entry.id === overriddenWorktree.workspaceId)?.background).toBe( + false, + ); + + await expect( + ctx.client.createAgent({ config, workspaceId: hidden.workspaceId, background: false }), + ).rejects.toThrow("workspace creation intent"); + await ctx.client.sendAgentMessage(hidden.id, "Say done."); + await ctx.client.waitForFinish(hidden.id, 10_000); + expect( + (await ctx.client.fetchAgentTimeline(hidden.id, { limit: 20 })).entries.length, + ).toBeGreaterThan(0); + const homeRoot = path.dirname(ctx.daemon.paseoHome); + tempRoots.push(ctx.daemon.staticDir); + await ctx.client.close(); + await ctx.daemon.daemon.stop(); + await ctx.daemon.daemon.agentManager.flush(); + ctx = await createDaemonTestContext({ paseoHomeRoot: homeRoot }); + const replay = await ctx.client.createAgent({ + config, + background: true, + idempotencyKey: "background-durable", + }); + expect(replay.id).toBe(hidden.id); + expect((await ctx.client.fetchAgent(hidden.id))?.agent.workspaceId).toBe(hidden.workspaceId); + await ctx.client.sendAgentMessage(hidden.id, "Say done again."); + await ctx.client.waitForFinish(hidden.id, 10_000); + await ctx.client.archiveAgent(hidden.id); + expect((await ctx.client.fetchAgent(hidden.id))?.agent.archivedAt).toEqual(expect.any(String)); + expect( + (await ctx.client.fetchAgentHistory()).entries.map((entry) => entry.agent.id), + ).not.toContain(hidden.id); + expect( + (await ctx.client.fetchAgentHistory({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ), + ).toContain(hidden.id); +}, 30_000); + +test("default and inclusive observers retain independent snapshots, live updates and catch-up", async () => { + const otherClient = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); + await otherClient.connect(); + try { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const hidden = await ctx.client.createAgent({ config, background: true }); + const visible = await ctx.client.createAgent({ config }); + const inclusive = ctx.client.observeAgents({ + scope: "active", + sync: {}, + includeBackground: true, + }); + const ordinary = otherClient.observeAgents({ scope: "active", sync: {} }); + const inclusiveSnapshot = await inclusive.ready; + const defaultSnapshot = await ordinary.ready; + expect(inclusiveSnapshot.entries.map((entry) => entry.agent.id)).toContain(hidden.id); + expect(defaultSnapshot.entries.map((entry) => entry.agent.id)).toEqual([visible.id]); + const inclusiveWorkspaces = ctx.client.observeWorkspaces({ sync: {}, includeBackground: true }); + const ordinaryWorkspaces = otherClient.observeWorkspaces({ sync: {} }); + const full = await inclusiveWorkspaces.ready; + const partial = await ordinaryWorkspaces.ready; + expect(full.entries.map((entry) => entry.id)).toContain(hidden.workspaceId); + expect(partial.entries.map((entry) => entry.id)).not.toContain(hidden.workspaceId); + const received: string[] = []; + const defaultReceived: string[] = []; + inclusive.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && message.payload.kind === "upsert") + received.push(message.payload.agent.id); + }, + }); + ordinary.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && message.payload.kind === "upsert") + defaultReceived.push(message.payload.agent.id); + }, + }); + await ctx.client.sendAgentMessage(hidden.id, "Say done."); + await ctx.client.waitForFinish(hidden.id, 10_000); + await expect.poll(() => received).toContain(hidden.id); + expect(defaultReceived).not.toContain(hidden.id); + const catchup = await ctx.client.fetchAgents({ + scope: "active", + includeBackground: true, + sync: { + generation: inclusiveSnapshot.sync?.generation, + afterSeq: inclusiveSnapshot.sync?.headSeq, + }, + }); + expect(catchup.sync?.mode).toBe("changes"); + expect(catchup.sync?.removals.map((item) => item.id)).not.toContain(hidden.id); + const scopeChanged = await ctx.client.fetchAgents({ + scope: "active", + includeBackground: true, + sync: { + generation: defaultSnapshot.sync?.generation, + afterSeq: defaultSnapshot.sync?.headSeq, + }, + }); + expect(scopeChanged.sync?.mode).toBe("snapshot"); + expect(scopeChanged.entries.map((entry) => entry.agent.id)).toContain(hidden.id); + await inclusive.release(); + await ordinary.release(); + await inclusiveWorkspaces.release(); + await ordinaryWorkspaces.release(); + } finally { + await otherClient.close(); + } +}, 30_000); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index e60dd95209..e114d8b26e 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -5247,6 +5247,7 @@ test("unions viewed timelines across socket sources and removes detached sources const session = createSessionForTest({ messages, agentManager: { + getAgent: (id: string) => ({ id, internal: false }), subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { agentEventListeners.push(listener); return () => {}; @@ -5354,6 +5355,7 @@ test("keeps selective delivery scoped per socket when a retained session also ha messages, targetedMessages, agentManager: { + getAgent: (id: string) => ({ id, internal: false }), subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { agentEventListeners.push(listener); return () => {}; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1307cf808f..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"; @@ -26,6 +27,7 @@ import { type FirstAgentContext, type SessionInboundMessage, type SessionOutboundMessage, + type ScriptStatusUpdateMessage, type GitSetupOptions, type StartWorkspaceScriptRequest, type WorkspaceScriptListRequest, @@ -247,6 +249,7 @@ import { import { WorkspaceAutoName } from "./workspace-auto-name.js"; import { buildAgentSessionConfig as buildWorktreeAgentSessionConfig, + normalizeAgentCreationGitOptions, createPaseoWorktreeWorkflow as createWorktreeWorkflow, type CreatePaseoWorktreeSetupContinuationInput, type CreatePaseoWorktreeWorkflowResult, @@ -450,6 +453,7 @@ export interface SessionOptions { getTransportBufferedAmount?: (source?: object) => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>; + publishScriptStatusUpdate?: (message: ScriptStatusUpdateMessage) => void; logger: pino.Logger; downloadTokenStore: DownloadTokenStore; pushNotifications: PushNotifications; @@ -722,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; @@ -810,6 +815,7 @@ export class Session { getTransportBufferedAmount, onLifecycleIntent, onWorkspaceRecovered, + publishScriptStatusUpdate, logger, downloadTokenStore, pushNotifications, @@ -1084,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), @@ -1093,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, }); @@ -1148,8 +1170,11 @@ export class Session { resolveScriptHealth: this.resolveScriptHealth, logger: this.sessionLogger, emit: (message) => this.emit(message), + publishStatusUpdate: (message) => { + if (publishScriptStatusUpdate) publishScriptStatusUpdate(message); + else this.emit(message); + }, spawnWorkspaceScript, - wantsStatusUpdates: () => this.wantsEvent("script_status_update"), assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, @@ -1158,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), @@ -1404,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: { @@ -1923,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; @@ -1968,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: { @@ -1990,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 }, ); } @@ -3225,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); @@ -4167,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", @@ -4202,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, @@ -4236,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, @@ -4249,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({ @@ -4264,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, @@ -4284,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, @@ -4293,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; @@ -4318,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, @@ -4364,6 +4433,8 @@ export class Session { createdWorktree: null, cwd: config.cwd, initialTitle: input.workspacePromptTitle, + background: request.background, + callerWorkspaceId: callerAgent?.workspaceId, }), cwd: config.cwd, }), @@ -4762,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, @@ -4781,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), @@ -5050,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", @@ -5062,6 +5142,7 @@ export class Session { : [], confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000, respectGitIgnore: searchesWorkspace, + excludedDiscoveryPaths, includeFiles, includeDirectories, matchMode, @@ -5160,60 +5241,25 @@ 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( identifier: string, - ): Promise<{ ok: true; agentId: string } | { ok: false; error: string }> { + ): Promise<{ ok: true; agentId: string } | { ok: false; notFound: boolean; error: string }> { const trimmed = identifier.trim(); if (!trimmed) { - return { ok: false, error: "Agent identifier cannot be empty" }; + 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(); @@ -5237,6 +5283,7 @@ export class Session { if (prefixMatches.length > 1) { return { ok: false, + notFound: false, error: `Agent identifier "${trimmed}" is ambiguous (${prefixMatches .slice(0, 5) .map((id) => id.slice(0, 8)) @@ -5251,6 +5298,7 @@ export class Session { if (titleMatches.length > 1) { return { ok: false, + notFound: false, error: `Agent title "${trimmed}" is ambiguous (${titleMatches .slice(0, 5) .map((r) => r.id.slice(0, 8)) @@ -5258,7 +5306,26 @@ export class Session { }; } - return { ok: false, error: `Agent not found: ${trimmed}` }; + 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> { @@ -5268,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; } @@ -5396,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 = @@ -5539,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, @@ -5624,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, @@ -5702,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); @@ -6004,6 +6082,7 @@ export class Session { workspace ?? null, workspaceId, subscription.syncEnabled === true, + subscription.filter?.includeBackground, ), ); continue; @@ -6014,6 +6093,7 @@ export class Session { workspace ?? null, workspaceId, subscription.syncEnabled === true, + subscription.filter?.includeBackground, ); if ( @@ -6119,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); @@ -6258,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(), @@ -6514,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` @@ -6608,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. @@ -6645,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); @@ -6707,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 } @@ -7247,7 +7370,6 @@ export class Session { emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), serviceProxy: this.serviceProxy, scriptRuntimeStore: this.scriptRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, @@ -7294,7 +7416,6 @@ export class Session { emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), serviceProxy: this.serviceProxy, scriptRuntimeStore: this.scriptRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, @@ -7382,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) => ({ @@ -7522,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)); @@ -7570,11 +7691,17 @@ export class Session { } private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> { + // An unknown agent is a null agent, not an error. Errors are for empty or ambiguous identifiers. const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { this.emit({ type: "fetch_agent_response", - payload: { requestId, agent: null, project: null, error: resolved.error }, + payload: { + requestId, + agent: null, + project: null, + error: resolved.notFound ? null : resolved.error, + }, }); return; } @@ -7583,12 +7710,7 @@ export class Session { if (!agent) { this.emit({ type: "fetch_agent_response", - payload: { - requestId, - agent: null, - project: null, - error: `Agent not found: ${resolved.agentId}`, - }, + payload: { requestId, agent: null, project: null, error: null }, }); return; } @@ -8081,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, @@ -8098,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, }); @@ -8157,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: { @@ -8171,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") { @@ -8183,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 1be52bc997..b9c04e8244 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -3466,7 +3466,7 @@ test("fetch_agent_request still resolves archived historical agents", async () = session.resolveAgentIdentifier = async (identifier: string) => identifier === "Archived History Agent" ? { ok: true, agentId: agent.id } - : { ok: false, error: `Agent not found: ${identifier}` }; + : { ok: false, notFound: true, error: `Agent not found: ${identifier}` }; session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null); session.buildProjectPlacementForWorkspaceId = async () => ({ projectKey: "proj-history-detail", @@ -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/owned-subscriptions/replies.ts b/packages/server/src/server/session/owned-subscriptions/replies.ts index 2fcfc1b97e..cf8a44b3cc 100644 --- a/packages/server/src/server/session/owned-subscriptions/replies.ts +++ b/packages/server/src/server/session/owned-subscriptions/replies.ts @@ -31,6 +31,7 @@ const exceptions = { agent_deleted: ["delete_agent_request"], agent_permission_resolved: ["agent_permission_response"], terminals_changed: ["subscribe_terminals_request"], + "usage.list_reports.update": ["usage.list_reports.request"], "daemon.update.progress": ["daemon.update.request"], // Transcription belongs to the explicitly retained voice operation, not a request ID. transcription_result: [], diff --git a/packages/server/src/server/session/usage/usage-session.test.ts b/packages/server/src/server/session/usage/usage-session.test.ts index a56d6f6c9a..2aa65992b8 100644 --- a/packages/server/src/server/session/usage/usage-session.test.ts +++ b/packages/server/src/server/session/usage/usage-session.test.ts @@ -18,7 +18,8 @@ test("lists reports from usage sources", async () => { emit: (message) => emitted.push(message), runtime: { async listUsageReports(options) { - requested.push(options); + requested.push({ forceRefresh: options.forceRefresh, reportIds: options.reportIds }); + options.onReport?.(entry); return [entry]; }, async listLegacyUsage() { @@ -31,7 +32,8 @@ test("lists reports from usage sources", async () => { await usage.handleListReports({ type: "usage.list_reports.request", requestId: "list" }); expect(requested).toEqual([{ forceRefresh: undefined, reportIds: undefined }]); expect(emitted).toEqual([ - { type: "usage.list_reports.response", payload: { requestId: "list", reports: [entry] } }, + { type: "usage.list_reports.update", payload: { requestId: "list", report: entry } }, + { type: "usage.list_reports.response", payload: { requestId: "list", error: null } }, ]); }); @@ -55,3 +57,18 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () = payload: { requestId: "u1", code: "provider_usage_list_failed" }, }); }); + +test("request failures terminate with an error response and no updates", async () => { + const emitted: SessionOutboundMessage[] = []; + const usage = new UsageSession({ + emit: (message) => emitted.push(message), + logger: pino({ level: "silent" }), + }); + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "failed" }); + expect(emitted).toEqual([ + { + type: "usage.list_reports.response", + payload: { requestId: "failed", error: "Plugin runtime is unavailable" }, + }, + ]); +}); diff --git a/packages/server/src/server/session/usage/usage-session.ts b/packages/server/src/server/session/usage/usage-session.ts index c94eaf2fcf..500c03a088 100644 --- a/packages/server/src/server/session/usage/usage-session.ts +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -1,3 +1,4 @@ +import type { ListUsageReportsOptions } from "../../plugins/usage-sources/index.js"; import type pino from "pino"; import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; @@ -5,10 +6,7 @@ import type { SessionInboundMessage, SessionOutboundMessage } from "../../messag export interface UsageSessionOptions { emit(message: SessionOutboundMessage): void; runtime?: { - listUsageReports(options: { - forceRefresh?: boolean; - reportIds?: string[]; - }): Promise<UsageReportEntry[]>; + listUsageReports(options: ListUsageReportsOptions): Promise<UsageReportEntry[]>; listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; logger: pino.Logger; @@ -22,16 +20,28 @@ export class UsageSession { ): Promise<void> { try { if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); - const reports = await this.options.runtime.listUsageReports({ + await this.options.runtime.listUsageReports({ forceRefresh: msg.forceRefresh, reportIds: msg.reportIds, + agentId: msg.agentId, + onReport: (report) => + this.options.emit({ + type: "usage.list_reports.update", + payload: { requestId: msg.requestId, report }, + }), }); this.options.emit({ type: "usage.list_reports.response", - payload: { requestId: msg.requestId, reports }, + payload: { requestId: msg.requestId, error: null }, }); } catch (error) { - this.emitError(msg, error, "usage_list_reports_failed"); + this.options.emit({ + type: "usage.list_reports.response", + payload: { + requestId: msg.requestId, + error: error instanceof Error ? error.message : String(error), + }, + }); } } @@ -64,20 +74,4 @@ export class UsageSession { }); } } - - private emitError( - msg: Extract<SessionInboundMessage, { type: "usage.list_reports.request" }>, - error: unknown, - code: string, - ): void { - this.options.emit({ - type: "rpc_error", - payload: { - requestId: msg.requestId, - requestType: msg.type, - error: error instanceof Error ? error.message : String(error), - code, - }, - }); - } } 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/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 0ba8a0169b..aa366a3495 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pino } from "pino"; import { afterEach, describe, expect, test } from "vitest"; -import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js"; +import type { + ScriptStatusUpdateMessage, + SessionOutboundMessage, + StartWorkspaceScriptRequest, +} from "../../messages.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { @@ -82,6 +86,7 @@ interface BuildOptions { function buildService(options: BuildOptions = {}) { const emitted: SessionOutboundMessage[] = []; + const published: ScriptStatusUpdateMessage[] = []; const spawnCalls: SpawnWorkspaceScriptOptions[] = []; const workspace = options.workspace === undefined @@ -108,6 +113,7 @@ function buildService(options: BuildOptions = {}) { resolveScriptHealth: null, logger, emit: (message) => emitted.push(message), + publishStatusUpdate: (message) => published.push(message), async spawnWorkspaceScript(spawnOptions): Promise<WorktreeScriptResult> { spawnCalls.push(spawnOptions); if (options.spawnThrows) { @@ -126,7 +132,7 @@ function buildService(options: BuildOptions = {}) { }, }); - return { service, emitted, spawnCalls }; + return { service, emitted, published, spawnCalls }; } const request: StartWorkspaceScriptRequest = { @@ -215,10 +221,11 @@ describe("buildSnapshot", () => { }); describe("emitStatusUpdate", () => { - test("emits one script_status_update carrying the snapshot", async () => { - const { service, emitted } = buildService(); + test("publishes one script_status_update carrying the snapshot", async () => { + const { service, emitted, published } = buildService(); await service.emitStatusUpdate("ws-1", "/tmp/repo"); - expect(emitted).toEqual([ + expect(emitted).toEqual([]); + expect(published).toEqual([ { type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } }, ]); }); @@ -332,7 +339,7 @@ describe("start", () => { }); test("spawns the script with resolved git metadata and reports success", async () => { - const { service, emitted, spawnCalls } = buildService(); + const { service, emitted, published, spawnCalls } = buildService(); await service.start(request); expect(spawnCalls).toHaveLength(1); @@ -345,7 +352,7 @@ describe("start", () => { daemonPort: 6767, daemonListenHost: "127.0.0.1", }); - expect(emitted).toContainEqual({ + expect(published).toContainEqual({ type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] }, }); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 2116e82b11..d449a48855 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -1,5 +1,6 @@ import type pino from "pino"; import type { + ScriptStatusUpdateMessage, SessionOutboundMessage, StartWorkspaceScriptRequest, WorkspaceDescriptorPayload, @@ -31,7 +32,7 @@ type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"]; /** * The service-proxy-backed scripts a workspace exposes: build the scripts payload - * snapshot, emit a script_status_update to clients, and start a script. + * snapshot, publish a script_status_update to every client, and start a script. * * The workspace descriptor builder, the script-status emission path, and the * start-script RPC all funnel through one assembly of buildWorkspaceScriptPayloads' @@ -66,7 +67,8 @@ export function createWorkspaceScriptsService(deps: { globalServicePorts?: PaseoServicePortAllocation; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; - wantsStatusUpdates?: () => boolean; + // Script status is daemon state, so it reaches every client, not only the requester. + publishStatusUpdate: (message: ScriptStatusUpdateMessage) => void; spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise<WorktreeScriptResult>; assertAutomationAllowed: (workspaceId: string) => Promise<void>; }): WorkspaceScriptsService { @@ -84,6 +86,7 @@ export function createWorkspaceScriptsService(deps: { globalServicePorts, logger, emit, + publishStatusUpdate, spawnWorkspaceScript, assertAutomationAllowed, } = deps; @@ -130,12 +133,11 @@ export function createWorkspaceScriptsService(deps: { } async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise<void> { - if (deps.wantsStatusUpdates && !deps.wantsStatusUpdates()) return; try { const workspace = await workspaceRegistry.get(workspaceId); if (!workspace) return; const project = await projectRegistry.get(workspace.projectId); - emit({ + publishStatusUpdate({ type: "script_status_update", payload: { workspaceId, scripts: buildSnapshot(workspace, project) }, }); diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts new file mode 100644 index 0000000000..e40cc25384 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { + ensureLocalSpeechModels, + getLocalSpeechModelDir, + listLocalSpeechModels, + listMissingLocalSpeechModels, +} from "./models.js"; + +const logger = pino({ level: "silent" }); +const modelId = "parakeet-tdt-0.6b-v2-int8"; +const spec = listLocalSpeechModels().find((model) => model.id === modelId)!; + +function writeCompleteModel(modelDir: string): void { + mkdirSync(modelDir, { recursive: true }); + for (const file of spec.requiredFiles) { + writeFileSync(path.join(modelDir, file), `complete ${file}`.padEnd(64_000, ".")); + } +} + +function setupModelsDirWithArchive(): { modelsDir: string; modelDir: string; archive: string } { + const root = mkdtempSync(path.join(tmpdir(), "paseo-local-models-")); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + const sourceRoot = path.join(root, "source"); + writeCompleteModel(path.join(sourceRoot, spec.extractedDir)); + const modelsDir = path.join(root, "models"); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", sourceRoot, spec.extractedDir]); + return { modelsDir, modelDir: getLocalSpeechModelDir(modelsDir, modelId), archive }; +} + +describe("local speech models", () => { + test("reinstalls a model whose extraction was interrupted", async () => { + // An interrupted extraction leaves the archive in .downloads and a truncated file in place. + const { modelsDir, modelDir, archive } = setupModelsDirWithArchive(); + writeCompleteModel(modelDir); + const encoder = path.join(modelDir, spec.requiredFiles[0]); + truncateSync(encoder, 1_000); + + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ + modelId, + ]); + + await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + + expect(readFileSync(encoder, "utf8")).toBe( + `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), + ); + expect(existsSync(archive)).toBe(false); + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index f6c7cc4e4e..a884be86d7 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,4 +1,8 @@ -import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; +import { + ensureSherpaOnnxModels, + getSherpaOnnxModelDir, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, @@ -30,6 +34,16 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } +export async function listMissingLocalSpeechModels(options: { + modelsDir: string; + modelIds: LocalSpeechModelId[]; +}): Promise<LocalSpeechModelId[]> { + const installed = await Promise.all( + options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), + ); + return options.modelIds.filter((_modelId, index) => !installed[index]); +} + export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts index 993c959b4d..bc2e462a53 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts @@ -1,10 +1,21 @@ import { describe, expect, test } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, + truncateSync, + readdirSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { ensureSherpaOnnxModel, getSherpaOnnxModelDir } from "./model-downloader.js"; +import { getSherpaOnnxModelSpec } from "./model-catalog.js"; function makeTmpDir(): string { return mkdtempSync(path.join(tmpdir(), "paseo-speech-models-")); @@ -13,6 +24,67 @@ function makeTmpDir(): string { const logger = pino({ level: "silent" }); describe("sherpa model downloader", () => { + test("does not expose a partial model when tar extraction fails", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + mkdirSync(sourceDir, { recursive: true }); + mkdirSync(path.dirname(archive), { recursive: true }); + writeFileSync(path.join(sourceDir, "model.onnx"), Buffer.alloc(32_768, 1)); + execFileSync("tar", [ + "cf", + archive, + "-C", + path.dirname(sourceDir), + `${spec.extractedDir}/model.onnx`, + ]); + truncateSync(archive, 1_024); + await expect(ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger })).rejects.toThrow( + "tar exited", + ); + expect(existsSync(getSherpaOnnxModelDir(modelsDir, spec.id))).toBe(false); + expect(existsSync(archive)).toBe(true); + expect(readdirSync(modelsDir)).toEqual([".downloads"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("repairs a partial legacy extraction when its cached archive remains", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const modelDir = getSherpaOnnxModelDir(modelsDir, spec.id); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + for (const dir of [sourceDir, modelDir]) { + mkdirSync(path.join(dir, "espeak-ng-data"), { recursive: true }); + for (const filename of ["model.onnx", "voices.bin", "tokens.txt"]) { + writeFileSync(path.join(dir, filename), "complete model data"); + } + } + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", path.dirname(sourceDir), spec.extractedDir]); + writeFileSync(path.join(modelDir, "model.onnx"), "truncated"); + await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger }); + expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("getSherpaOnnxModelDir maps modelId to extractedDir", () => { const modelsDir = "/tmp/models"; expect(getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8")).toContain( diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index 8ce21f5e01..cdd286389f 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -90,6 +90,38 @@ async function extractTarArchive( }); } +interface SherpaOnnxModelPaths { + modelDir: string; + archivePath: string; + extractionDir: string; +} + +function getSherpaOnnxModelPaths( + modelsDir: string, + modelId: SherpaOnnxModelId, +): SherpaOnnxModelPaths { + const spec = getSherpaOnnxModelSpec(modelId); + const downloadsDir = path.join(modelsDir, ".downloads"); + return { + modelDir: path.join(modelsDir, spec.extractedDir), + archivePath: path.join(downloadsDir, path.basename(new URL(spec.archiveUrl).pathname)), + extractionDir: path.join(downloadsDir, `${spec.extractedDir}.extracting`), + }; +} + +// The archive is removed only as its model moves into place, so a model whose archive is +// still in .downloads did not finish installing and may hold truncated files. +export async function isSherpaOnnxModelInstalled( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise<boolean> { + const spec = getSherpaOnnxModelSpec(modelId); + const { modelDir, archivePath } = getSherpaOnnxModelPaths(modelsDir, modelId); + return ( + !(await isNonEmptyFile(archivePath)) && (await hasRequiredFiles(modelDir, spec.requiredFiles)) + ); +} + async function isNonEmptyFile(filePath: string): Promise<boolean> { try { const s = await stat(filePath); @@ -110,18 +142,17 @@ export async function ensureSherpaOnnxModel( }); const spec = getSherpaOnnxModelSpec(options.modelId); - const modelDir = path.join(options.modelsDir, spec.extractedDir); - if (await hasRequiredFiles(modelDir, spec.requiredFiles)) { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths( + options.modelsDir, + options.modelId, + ); + if (await isSherpaOnnxModelInstalled(options.modelsDir, options.modelId)) { return modelDir; } logger.info({ modelsDir: options.modelsDir }, "Starting model download"); try { - const downloadsDir = path.join(options.modelsDir, ".downloads"); - const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname); - const archivePath = path.join(downloadsDir, archiveFilename); - if (!(await isNonEmptyFile(archivePath))) { await downloadToFile({ url: spec.archiveUrl, @@ -138,32 +169,40 @@ export async function ensureSherpaOnnxModel( }, "Extracting model archive", ); - await extractTarArchive(archivePath, options.modelsDir, options.signal); - - logger.info( - { - modelId: options.modelId, - modelDir, - }, - "Verifying downloaded model files", - ); - if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { - throw new Error( - `Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`, - ); - } - - logger.info( - { - modelId: options.modelId, - archivePath, - }, - "Finalizing model artifacts", - ); + // Extract beside the archive and move into place once complete, so an interrupted + // extraction never leaves partial files at modelDir. + await rm(extractionDir, { recursive: true, force: true }); try { - await rm(archivePath, { force: true }); - } catch { - // ignore + await extractTarArchive(archivePath, extractionDir, options.signal); + const extractedModelDir = path.join(extractionDir, spec.extractedDir); + + logger.info( + { + modelId: options.modelId, + modelDir, + }, + "Verifying downloaded model files", + ); + if (!(await hasRequiredFiles(extractedModelDir, spec.requiredFiles))) { + throw new Error( + `Downloaded and extracted ${path.basename(archivePath)}, but required files are missing.`, + ); + } + + logger.info( + { + modelId: options.modelId, + archivePath, + }, + "Finalizing model artifacts", + ); + // A retained archive marks the model as not installed, so remove it before the model + // is moved into place. If removal fails, nothing at modelDir looks installed. + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true, maxRetries: 3 }); + await rename(extractedModelDir, modelDir); + } finally { + await rm(extractionDir, { recursive: true, force: true }); } logger.info({ modelDir }, "Model download completed"); diff --git a/packages/server/src/server/speech/providers/openai/config.test.ts b/packages/server/src/server/speech/providers/openai/config.test.ts index 55da430edc..1f3433a347 100644 --- a/packages/server/src/server/speech/providers/openai/config.test.ts +++ b/packages/server/src/server/speech/providers/openai/config.test.ts @@ -203,4 +203,40 @@ describe("resolveOpenAiSpeechConfig", () => { expect(resolved?.stt?.apiKey).toBe("stt-only-key"); expect(resolved?.tts).toBeUndefined(); }); + + test("sends the model and voice an OpenAI-compatible server serves, as written", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { + openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } }, + }, + features: { + voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } }, + }, + }); + + const resolved = resolveOpenAiSpeechConfig({ + env: {} as NodeJS.ProcessEnv, + persisted, + providers: ALL_OPENAI, + }); + + expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0"); + expect(resolved?.tts?.voice).toBe("Aiden"); + }); + + test("accepts OpenAI's own gpt-4o-mini-tts model", () => { + const persisted = PersistedConfigSchema.parse({ + providers: { openai: { tts: { apiKey: "tts-key" } } }, + features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } }, + }); + + const resolved = resolveOpenAiSpeechConfig({ + env: {} as NodeJS.ProcessEnv, + persisted, + providers: ALL_OPENAI, + }); + + expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts"); + expect(resolved?.tts?.voice).toBe("alloy"); + }); }); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index ac8d2ffa3a..efb25bffaf 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -12,10 +12,6 @@ export interface OpenAiSpeechProviderConfig { tts?: Partial<TTSConfig> & { apiKey?: string }; } -const OpenAiTtsVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]); - -const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]); - const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]); const OptionalFiniteNumberSchema = NumberLikeSchema.pipe( @@ -44,14 +40,12 @@ const OpenAiSttOptionsSchema = z.object({ sttModel: OptionalTrimmedStringSchema, }); +// Voice and model are free-form so a self-hosted server can use its own ids; the +// OpenAI catalog is only the default. Both are sent as written, because the +// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0). const OpenAiTtsOptionsSchema = z.object({ - ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"), - ttsModel: z - .string() - .trim() - .toLowerCase() - .pipe(OpenAiTtsModelSchema) - .default(DEFAULT_OPENAI_TTS_MODEL), + ttsVoice: z.string().trim().default("alloy"), + ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL), }); function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean { diff --git a/packages/server/src/server/speech/providers/openai/tts.ts b/packages/server/src/server/speech/providers/openai/tts.ts index 6cc7e5c5bb..31f7b5b263 100644 --- a/packages/server/src/server/speech/providers/openai/tts.ts +++ b/packages/server/src/server/speech/providers/openai/tts.ts @@ -8,8 +8,8 @@ export type { SpeechStreamResult }; export interface TTSConfig { apiKey: string; baseUrl?: string; - model?: "tts-1" | "tts-1-hd"; - voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer"; + model?: "tts-1" | "tts-1-hd" | (string & {}); + voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" | (string & {}); responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm"; } diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index 428ecd27aa..c9c21b3e89 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -36,8 +36,7 @@ vi.mock("./providers/openai/runtime.js", () => ({ vi.mock("./providers/local/models.js", () => ({ ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - getLocalSpeechModelDir: vi.fn(() => ""), - listLocalSpeechModels: vi.fn(() => []), + listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), })); function createStubStt(id: string): SpeechToTextProvider { diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 6e56a9e955..932f87c077 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -1,14 +1,8 @@ -import { stat } from "node:fs/promises"; -import { join } from "node:path"; import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, -} from "./providers/local/models.js"; +import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -76,48 +70,6 @@ function resolveRequestedSpeechProviders( }; } -async function hasRequiredLocalModelFile(filePath: string): Promise<boolean> { - try { - const fileStat = await stat(filePath); - if (fileStat.isDirectory()) { - return true; - } - return fileStat.isFile() && fileStat.size > 0; - } catch { - return false; - } -} - -async function findMissingRequiredLocalModels(params: { - modelsDir: string | null; - requiredModelIds: LocalSpeechModelId[]; -}): Promise<LocalSpeechModelId[]> { - const { modelsDir, requiredModelIds } = params; - if (!modelsDir || requiredModelIds.length === 0) { - return []; - } - - const specsById = new Map(listLocalSpeechModels().map((model) => [model.id, model])); - const missing = new Set<LocalSpeechModelId>(); - - const checks = await Promise.all( - requiredModelIds.map(async (modelId) => { - const spec = specsById.get(modelId); - if (!spec) return { modelId, missing: true }; - const modelDir = getLocalSpeechModelDir(modelsDir, modelId); - const filePresence = await Promise.all( - spec.requiredFiles.map((relPath) => hasRequiredLocalModelFile(join(modelDir, relPath))), - ); - return { modelId, missing: !filePresence.every((present) => present) }; - }), - ); - for (const check of checks) { - if (check.missing) missing.add(check.modelId); - } - - return Array.from(missing); -} - function joinModelIds(modelIds: LocalSpeechModelId[]): string { if (modelIds.length === 0) { return "none"; @@ -493,10 +445,12 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise<void> => { - missingLocalModelIds = await findMissingRequiredLocalModels({ - modelsDir: localModelConfig?.modelsDir ?? null, - requiredModelIds: localModelConfig?.defaultModelIds ?? [], - }); + missingLocalModelIds = localModelConfig + ? await listMissingLocalSpeechModels({ + modelsDir: localModelConfig.modelsDir, + modelIds: localModelConfig.defaultModelIds, + }) + : []; }; const reconcileServices = async (): Promise<void> => { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 2a437f9bbf..1c7127d144 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -58,14 +58,18 @@ interface FakeAgentSessionOptions { sessionId?: string; memoryMarker?: string | null; closeSession?: () => Promise<void>; - onStartTurn?: (prompt: AgentPromptInput) => void; + onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; + beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>; + historyEvents?: readonly AgentStreamEvent[]; } export interface TestAgentClientOptions { - beforeCreateSession?: () => Promise<void>; + beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>; closeSession?: () => Promise<void>; - onStartTurn?: (prompt: AgentPromptInput) => void; + onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; + beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>; supportsMcpServers?: boolean; + historyEvents?: readonly AgentStreamEvent[]; } function createDeferred<T>(): Deferred<T> { @@ -337,7 +341,9 @@ class FakeAgentSession implements AgentSession { private activeForegroundTurnId: string | null = null; private readonly closeSession: (() => Promise<void>) | undefined; - private readonly onStartTurn: ((prompt: AgentPromptInput) => void) | undefined; + private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; + private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined; + private readonly historyEvents: readonly AgentStreamEvent[] | undefined; constructor(options: FakeAgentSessionOptions) { this.capabilities = { @@ -350,6 +356,8 @@ class FakeAgentSession implements AgentSession { this.memoryMarker = options.memoryMarker ?? null; this.closeSession = options.closeSession; this.onStartTurn = options.onStartTurn; + this.beforeTurnComplete = options.beforeTurnComplete; + this.historyEvents = options.historyEvents; this.historyPath = path.join( tmpdir(), "paseo-fake-provider-history", @@ -440,7 +448,7 @@ class FakeAgentSession implements AgentSession { const turnId = `fake-turn-${this.nextTurnOrdinal++}`; this.activeForegroundTurnId = turnId; - this.onStartTurn?.(prompt); + this.onStartTurn?.(prompt, this.config); void this.emitTurnEvents(prompt); @@ -799,6 +807,7 @@ class FakeAgentSession implements AgentSession { this.notifySubscribers(assistantChunkB); } + await this.beforeTurnComplete?.(prompt); const completed: AgentStreamEvent = { type: "turn_completed", provider: this.providerName, @@ -812,6 +821,10 @@ class FakeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator<AgentStreamEvent> { + if (this.historyEvents) { + yield* this.historyEvents; + return; + } let contents: string; try { contents = await readFile(this.historyPath, "utf8"); @@ -1213,13 +1226,15 @@ class FakeAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise<AgentSession> { - await this.options.beforeCreateSession?.(); + await this.options.beforeCreateSession?.(config); return new FakeAgentSession({ providerName: this.provider, config: { ...config }, supportsMcpServers: this.options.supportsMcpServers, closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, + beforeTurnComplete: this.options.beforeTurnComplete, + historyEvents: this.options.historyEvents, }); } @@ -1245,6 +1260,8 @@ class FakeAgentClient implements AgentClient { memoryMarker: typeof marker === "string" ? marker : null, closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, + beforeTurnComplete: this.options.beforeTurnComplete, + historyEvents: this.options.historyEvents, }); } diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 1c98686042..c5cf271ad3 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -35,6 +35,7 @@ export function asAgentManager(stub: { }): SessionOptions["agentManager"] { return createStub<SessionOptions["agentManager"]>({ listProviderSubagentActivity: () => [], + getRetiredInternalAgent: () => null, ...stub, }); } @@ -239,6 +240,7 @@ export function createProviderSnapshotManagerStub(): { const off = vi.fn(); const destroy = vi.fn<ProviderSnapshotManager["destroy"]>(); const stub = { + supportsConfigRequiredProvider: () => false, getSnapshot, refreshSnapshotForCwd, refreshSettingsSnapshot, diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index e7747f57ae..53b8a44c1e 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -55,6 +55,7 @@ export function createNoopWorkspaceGitService( suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], + invalidateWorktrees: () => {}, getProjectSlug: async (cwd: string) => { const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts index cc6754c50b..fda98d6cbf 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -190,6 +190,113 @@ index 1234567..abcdefg 100644 `; describe("parseDiff", () => { + it.each(["-- comment", "++ marker"])( + "preserves hunk content resembling patch headers: %s", + (content) => { + const files = parseDiff(`diff --git a/example.txt b/example.txt +index 1234567..abcdefg 100644 +--- a/example.txt ++++ b/example.txt +@@ -1 +1 @@ +-${content} before ++${content} after +`); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + path: "example.txt", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + }); + expect(files[0].hunks[0].lines).toEqual([ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: `${content} before` }, + { type: "add", content: `${content} after` }, + ]); + }, + ); + + it("decodes quoted Git patch paths before matching files", () => { + const files = parseDiff( + [ + 'diff --git "a/report \\"draft\\".txt" "b/report \\"draft\\".txt"', + "index 1234567..abcdefg 100644", + '--- "a/report \\"draft\\".txt"', + '+++ "b/report \\"draft\\".txt"', + "@@ -1 +1 @@", + "-before", + "+after", + "", + ].join("\n"), + ); + + expect(files).toEqual([ + { + path: 'report "draft".txt', + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], + }, + ]); + }); + + it.each([ + ["tab\\tname.txt", "tab\tname.txt"], + ["line\\nname.txt", "line\nname.txt"], + ["back\\\\slash.txt", "back\\slash.txt"], + ["bell\\aname.txt", "bell\u0007name.txt"], + ["vertical\\vname.txt", "vertical\u000bname.txt"], + ["\\346\\265\\213\\350\\257\\225.txt", "测试.txt"], + ['测试 \\"draft\\".txt', '测试 "draft".txt'], + ])("decodes C-quoted path %s without changing its diff lines", (encoded, path) => { + const files = parseDiff( + [ + 'diff --git "a/' + encoded + '" "b/' + encoded + '"', + "index 1234567..abcdefg 100644", + '--- "a/' + encoded + '"', + '+++ "b/' + encoded + '"', + "@@ -1 +1 @@", + "-before", + "+after", + "", + ].join("\n"), + ); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path, additions: 1, deletions: 1 }); + expect(files[0].hunks[0].lines).toEqual([ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ]); + }); + + it.each([ + ['"a/old \\"draft\\".txt" b/new with spaces.txt', "new with spaces.txt"], + ['a/old with spaces.txt "b/new \\"draft\\".txt"', 'new "draft".txt'], + ['"report \\"draft\\".txt" "report \\"draft\\".txt"', 'report "draft".txt'], + ])("reads quoted paths from header-only patches: %s", (header, path) => { + expect(parseDiff("diff --git " + header + "\nsimilarity index 100%\n")).toEqual([ + { path, isNew: false, isDeleted: false, additions: 0, deletions: 0, hunks: [] }, + ]); + }); + it("parses a simple diff with one hunk", () => { const files = parseDiff(SIMPLE_DIFF); @@ -597,6 +704,28 @@ describe("highlightDiffFromHunks", () => { }); describe("highlightDiffWithFileContent", () => { + it("renders an Astro diff with an empty expression and keeps surrounding highlighting", async () => { + const diff = ASTRO_DIFF.replace("{title}", "{title} {}"); + const file = parseDiff(diff)[0]; + const highlighted = await highlightDiffWithFileContent(file, ".", { + oldFileContent: + '---\nconst title = "Hello";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>', + newFileContent: + '---\nconst title = "Hello, Astro";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>', + }); + + expect(highlighted.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({ + text: '"Hello, Astro"', + style: "string", + }); + expect(highlighted.hunks[0].lines.map(({ content }) => content)).toEqual( + file.hunks[0].lines.map(({ content }) => content), + ); + expect( + highlightDiffFromHunks(file).hunks[0].lines.find((line) => line.type === "add")?.tokens, + ).toContainEqual({ text: '"Hello, Astro"', style: "string" }); + }); + it("leaves files with an oversized line unhighlighted", async () => { const oversizedLine = `const payload = "${"x".repeat(10_001)}";`; const [file] = parseDiff( diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index daa1ebbb3d..3c2df279e2 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight"; +import { unquoteGitPath } from "../../utils/git-quoted-path.js"; const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000; @@ -53,7 +54,7 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri return null; } - const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd(); + const path = unquoteGitPath(line.slice(prefix.length).replace(/\t.*$/, "")); return path === "/dev/null" ? null : path; } @@ -64,6 +65,17 @@ function extractPathFromDiffHeader(lines: string[]): string { return prefixedPathMatch[2]; } + const quotedPathMatch = + firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/); + if (quotedPathMatch) { + const oldPath = unquoteGitPath(quotedPathMatch[1]); + const newPath = unquoteGitPath(quotedPathMatch[2]); + if (usesDiffPathPrefixes(oldPath, newPath)) { + return newPath.slice(2); + } + return newPath; + } + const metadataPath = extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- "); if (metadataPath) { @@ -79,16 +91,6 @@ function extractPathFromDiffHeader(lines: string[]): string { return "unknown"; } -function isMetadataLine(line: string): boolean { - return ( - line.startsWith("index ") || - line.startsWith("--- ") || - line.startsWith("+++ ") || - line.startsWith("new file mode") || - line.startsWith("deleted file mode") - ); -} - function parseHunkHeader(line: string): DiffHunk | null { const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); if (!hunkMatch) return null; @@ -116,8 +118,6 @@ function parseSectionBody(lines: string[]): ParsedSectionBody { for (let i = 1; i < lines.length; i++) { const line = lines[i]; - if (isMetadataLine(line)) continue; - const newHunk = parseHunkHeader(line); if (newHunk) { if (currentHunk) hunks.push(currentHunk); diff --git a/packages/server/src/server/web-ui.ts b/packages/server/src/server/web-ui.ts index e7135a9eab..b59896c159 100644 --- a/packages/server/src/server/web-ui.ts +++ b/packages/server/src/server/web-ui.ts @@ -250,6 +250,22 @@ function serializeInlineScriptJson(value: unknown): string { .replace(/&/g, "\\u0026"); } +/** + * A Host header carries no port when the client used the scheme's default one, + * which is what every browser sends to a proxy terminating TLS on 443. The app + * parses `listen` with parseHostPort, which requires an explicit port and + * rejects the value outright, so the hint has to spell the port out. + */ +function hasExplicitPort(host: string): boolean { + if (host.startsWith("[")) { + // IPv6 literals are bracketed: [::1] has no port, [::1]:443 does. + const end = host.indexOf("]"); + return end !== -1 && /^:\d{1,5}$/.test(host.slice(end + 1)); + } + const colon = host.indexOf(":"); + return colon !== -1 && colon === host.lastIndexOf(":") && /^\d{1,5}$/.test(host.slice(colon + 1)); +} + function injectConnectionHint( html: string, req: Parameters<RequestHandler>[0], @@ -257,8 +273,16 @@ function injectConnectionHint( ): string { const host = typeof req.headers.host === "string" ? req.headers.host : ""; const useTls = req.protocol === "https"; + let listen = host; + if (host && !hasExplicitPort(host)) { + let defaultPort = 80; + if (useTls) { + defaultPort = 443; + } + listen = `${host}:${defaultPort}`; + } const hint = { - listen: host, + listen, useTls, label, }; diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 25acb9946b..10694a6ebe 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1141,6 +1141,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.stableProjectIdentity).toBe(true); expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); expect(serverInfo.features?.providersSnapshotCwd).toBe(true); + expect(serverInfo.features?.hopliteProvider).toBe(false); expect(serverInfo.features?.pluginLogs).toBe(true); expect(serverInfo.features?.workspaceMarkUnread).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 67c78184fb..e833a07ce3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -20,6 +20,7 @@ import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-m import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js"; import { type ServerInfoStatusPayload, + type ScriptStatusUpdateMessage, type SessionOutboundMessage, type WorkspaceSetupSnapshot, type WSHelloMessage, @@ -32,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 { @@ -245,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); @@ -820,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( @@ -978,6 +982,12 @@ export class VoiceAssistantWebSocketServer { } } + public publishScriptStatusUpdate(message: ScriptStatusUpdateMessage): void { + for (const session of this.listSessions()) { + session.emitServerMessage(message); + } + } + public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void { this.updateServerCapabilities(buildServerCapabilities({ readiness })); } @@ -1465,6 +1475,7 @@ export class VoiceAssistantWebSocketServer { ), ); }, + publishScriptStatusUpdate: (message) => this.publishScriptStatusUpdate(message), downloadTokenStore: this.downloadTokenStore, pushNotifications: this.pushNotifications, paseoHome: this.paseoHome, @@ -1785,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. @@ -1903,6 +1915,7 @@ export class VoiceAssistantWebSocketServer { commitBaseClassification: true, // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: true, + hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: true, // COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02. @@ -1915,6 +1928,7 @@ export class VoiceAssistantWebSocketServer { explicitEventSubscriptions: true, // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: true, + agentMessageProvenance: true, // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: true, // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index cc05d496ba..87cb758ac5 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -1,10 +1,13 @@ -import { execFileSync, spawn } from "node:child_process"; +import { execFile, execFileSync, spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { afterEach, beforeEach, expect, test } from "vitest"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { createWorktree } from "../utils/worktree.js"; import { createDaemonTestContext, DaemonClient, @@ -54,6 +57,32 @@ function createGitRepo(): string { return repoDir; } +async function runCli(args: string[], cwd: string): Promise<string> { + const result = await promisify(execFile)( + process.execPath, + [ + "--conditions=source", + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${ctx.daemon.port}`, + ...args, + ], + { + cwd, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + PASEO_HOME: ctx.daemon.paseoHome, + }, + timeout: 20000, + }, + ); + return result.stdout; +} + async function createLocalWorkspace(cwd: string, title: string): Promise<string> { const result = await ctx.client.createWorkspace({ source: { kind: "directory", path: cwd }, @@ -248,6 +277,78 @@ test("renaming a workspace updates every subscribed client", async () => { } }); +test.each(["worktree", "workspace"] as const)( + "%s archive immediately removes the deleted worktree from a warm CLI listing", + async (command) => { + const repoDir = createGitRepo(); + const created = await ctx.client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + worktreeSlug: "cached-worktree", + baseBranch: "main", + }, + }); + const workspace = created.workspace; + if (!workspace?.workspaceDirectory) { + throw new Error(created.error ?? "Failed to create worktree workspace"); + } + const worktreePath = workspace.workspaceDirectory; + const name = path.basename(worktreePath); + const before = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir)); + expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]); + + await runCli( + [command, "archive", command === "worktree" ? name : workspace.id, "--json"], + repoDir, + ); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir }).toString(), + ).not.toContain(worktreePath); + const after = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir)); + console.log( + `${command} archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`, + ); + expect(after).toEqual([]); + }, + 60000, +); + +test("archiving an unregistered worktree in a bare repo clears the main checkout listing", async () => { + const sourceRepo = createGitRepo(); + const repoRoot = makeTempDir("workspace-archive-bare-"); + const bareRepo = path.join(repoRoot, "repo.git"); + const mainCheckout = path.join(repoRoot, "main"); + execFileSync("git", ["clone", "--bare", sourceRepo, bareRepo], { stdio: "pipe" }); + execFileSync("git", ["worktree", "add", mainCheckout, "main"], { + cwd: bareRepo, + stdio: "pipe", + }); + const { worktreePath } = await createWorktree({ + cwd: mainCheckout, + source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" }, + worktreeSlug: "cached-bare-worktree", + runSetup: false, + paseoHome: ctx.daemon.paseoHome, + }); + expect(await activeWorkspaceIds()).toEqual(new Set()); + const name = path.basename(worktreePath); + const before = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout)); + expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]); + + await runCli(["worktree", "archive", name, "--json"], mainCheckout); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainCheckout }).toString(), + ).not.toContain(worktreePath); + const after = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout)); + console.log( + `bare repo archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`, + ); + expect(after).toEqual([]); +}, 60000); + test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => { const repoDir = createGitRepo(); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index c3b77f7070..d1cfb8ada7 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -140,7 +140,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies { github: createGitHubServiceStub(), workspaceGitService: { getSnapshot: vi.fn(async () => null), - } as unknown as Pick<WorkspaceGitService, "getSnapshot">, + invalidateWorktrees: () => {}, + } as unknown as Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">, agentManager: { listAgents: () => [], getAgent: () => null, @@ -617,28 +618,37 @@ describe("archiveByScope", () => { expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled(); }); - test("worktree scope removes an owned directory with zero matching records", async () => { - const { tempDir, repoDir } = createGitRepo(); - const paseoHome = path.join(tempDir, ".paseo"); - const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records"); + test.each(["intact", "missing"] as const)( + "worktree scope removes an owned directory with zero matching records and %s Git administration", + async (gitAdministration) => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records"); + if (gitAdministration === "missing") { + const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], { + cwd: worktree.worktreePath, + }) + .toString() + .trim(); + rmSync(gitDir, { recursive: true, force: true }); + } + const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] }); + const invalidateWorktrees = vi.fn(); + dependencies.workspaceGitService.invalidateWorktrees = invalidateWorktrees; - const result = await archiveByScope( - createArchiveDeps({ - paseoHome, - activeWorkspaces: [], - }), - { + const result = await archiveByScope(dependencies, { scope: { kind: "worktree", targetPath: worktree.worktreePath }, requestId: "req-zero-records", - }, - ); + }); - assertArchiveResult(result, { - archivedWorkspaceIds: [], - removedDirectory: true, - }); - expect(existsSync(worktree.worktreePath)).toBe(false); - }); + assertArchiveResult(result, { + archivedWorkspaceIds: [], + removedDirectory: true, + }); + expect(existsSync(worktree.worktreePath)).toBe(false); + expect(invalidateWorktrees).toHaveBeenCalledTimes(1); + }, + ); test("marks archiving, emits an upsert carrying the archiving state, then clears it and emits a remove", async () => { const { tempDir, repoDir } = createGitRepo(); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index a02c97eda8..e9e58681a1 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -32,7 +32,7 @@ export interface ArchiveDependencies { // Base directory that may hold worktrees across repositories. paseoWorktreesBaseRoot?: string; github: ForgeService; - workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">; + workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">; agentManager: Pick<AgentManager, "listAgents" | "getAgent" | "archiveAgent" | "archiveSnapshot">; agentStorage: Pick<AgentStorage, "listByWorkspace">; // Resolves the worktree at a path to its workspaceId for archive-by-path. The @@ -408,6 +408,7 @@ async function maybeRemoveDirectory( paseoHome: dependencies.paseoHome, worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, }); + dependencies.workspaceGitService.invalidateWorktrees(); dependencies.github.invalidate({ cwd: backing.path }); return true; } catch (error) { @@ -466,7 +467,7 @@ export async function archiveWorkspaceContents( const archivedAgents = new Set<string>(); const liveAgents = dependencies.agentManager - .listAgents() + .listAgents({ includeInternal: true }) .filter((agent) => agent.workspaceId === workspaceId); for (const agent of liveAgents) { archivedAgents.add(agent.id); diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 2e1f2c4e49..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 f28718c0f5..b146c59f31 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -115,7 +115,6 @@ function createWorkflowForRequestTest(options: { emit: () => {}, sessionLogger: createLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -489,7 +488,6 @@ describe("create-agent worktree setup boundary", () => { emit: (message) => emitted.push(message), sessionLogger: createLogger(), terminalManager: createTerminalManagerStub().manager, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -540,7 +538,6 @@ describe("create-agent worktree setup boundary", () => { emit: (message) => workspaceSetupEvents.push(message), sessionLogger: createLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -714,7 +711,6 @@ describe("runWorktreeSetupInBackground", () => { emit: () => {}, sessionLogger: createLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, }, { requestCwd: sourceWorkspaceCwd, @@ -759,7 +755,6 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -770,7 +765,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -828,11 +822,10 @@ describe("runWorktreeSetupInBackground", () => { }); expect(terminalManager.terminals).toHaveLength(0); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("42"); }); - test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => { + test("keeps the workspace available and emits a failed snapshot when setup cannot start", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -857,7 +850,6 @@ describe("runWorktreeSetupInBackground", () => { const snapshots = new Map<string, unknown>(); const logger = createLogger(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); const workspaceId = "ws-broken-feature"; await runWorktreeSetupInBackground( @@ -869,7 +861,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -901,7 +892,8 @@ describe("runWorktreeSetupInBackground", () => { status: "failed", error: expect.stringMatching(/Failed to parse paseo\.json at .*paseo\.json/), }); - expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId); + expect(existsSync(worktreePath)).toBe(true); + expect(existsSync(path.join(worktreePath, "paseo.json"))).toBe(true); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith(workspaceId); }); @@ -932,7 +924,6 @@ describe("runWorktreeSetupInBackground", () => { const snapshots = new Map<string, unknown>(); const logger = createLogger(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -943,7 +934,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1056,7 +1046,6 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -1067,7 +1056,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1116,7 +1104,6 @@ describe("runWorktreeSetupInBackground", () => { status: "completed", error: null, }); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("44"); }); @@ -1151,7 +1138,6 @@ describe("runWorktreeSetupInBackground", () => { }, }); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -1162,7 +1148,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1202,7 +1187,6 @@ describe("runWorktreeSetupInBackground", () => { status: "completed", error: null, }); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("45"); }); @@ -1233,7 +1217,6 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -1244,7 +1227,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1265,7 +1247,6 @@ describe("runWorktreeSetupInBackground", () => { status: "completed", error: null, }); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("46"); }); @@ -1427,7 +1408,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message: SessionOutboundMessage) => emitted.push(message), sessionLogger: createLogger(), terminalManager: terminalManager.manager, - archiveWorkspaceRecord: vi.fn(async () => {}), serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -2037,6 +2017,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2108,6 +2089,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2184,6 +2166,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2260,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 c693bc5380..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, @@ -108,7 +109,6 @@ interface CreatePaseoWorktreeInBackgroundDependencies { emit: EmitSessionMessage; sessionLogger: Logger; terminalManager: TerminalManager | null; - archiveWorkspaceRecord: (workspaceId: string) => Promise<void>; serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; getDaemonTcpPort: (() => number | null) | null; @@ -321,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 { @@ -466,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" }>, @@ -825,7 +863,6 @@ export async function runWorktreeSetupInBackground( ): Promise<void> { let worktree: WorktreeConfig = options.worktree; let setupResults: WorktreeSetupCommandResult[] = []; - let setupStarted = false; const progressAccumulator = createWorktreeSetupProgressAccumulator(); const workspaceId = options.workspaceId; @@ -862,7 +899,6 @@ export async function runWorktreeSetupInBackground( const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; const setupCommands = getWorktreeSetupCommands(workspaceCwd); if (setupCommands.length === 0) { - setupStarted = true; emitSetupProgress("completed", null); } else { const runtimeEnv = await resolveWorktreeRuntimeEnv({ @@ -874,7 +910,6 @@ export async function runWorktreeSetupInBackground( cwd: workspaceCwd, env: runtimeEnv, }); - setupStarted = true; setupResults = await runWorktreeSetupCommands({ worktreePath: workspaceCwd, branchName: worktree.branchName, @@ -906,10 +941,6 @@ export async function runWorktreeSetupInBackground( const message = error instanceof Error ? error.message : String(error); emitSetupProgress("failed", message); - if (!setupStarted) { - await dependencies.archiveWorkspaceRecord(options.workspaceId); - } - dependencies.sessionLogger.error( { err: error, @@ -917,7 +948,6 @@ export async function runWorktreeSetupInBackground( repoRoot: options.repoRoot, worktreeSlug: worktree.branchName, worktreePath: worktree.worktreePath, - setupStarted, }, "Background worktree setup failed", ); diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index cedc5013c9..4f76cb1887 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -93,7 +93,10 @@ export interface ArchiveCommandDependencies extends Omit< ArchiveDependencies, "workspaceGitService" > { - workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">; + workspaceGitService: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "invalidateWorktrees" + >; } export interface ArchiveCommandInput { diff --git a/packages/server/src/services/forge-service.ts b/packages/server/src/services/forge-service.ts index fba9833cd8..8499cc8756 100644 --- a/packages/server/src/services/forge-service.ts +++ b/packages/server/src/services/forge-service.ts @@ -39,6 +39,8 @@ export interface PullRequestCheckoutTarget { headRepositorySshUrl: string | null; headRepositoryUrl: string | null; isCrossRepository: boolean; + /** "pull-ref" when headRefName is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */ + headRefKind?: "branch" | "pull-ref"; } export interface PullRequestCheckoutRef { @@ -441,9 +443,9 @@ export interface ForgeService { getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>; /** * Refs to fetch for a change-request checkout when the resolved checkout - * target carries none. Adapters that expose a universal change-request head - * ref (GitHub's refs/pull/N/head) return it here; others let the shell fall - * back to the head branch. + * target carries none. Adapters with a universal change-request head ref + * (refs/pull/N/head) return it here; others let the shell fall back to the + * head branch. */ defaultCheckoutRefs?(params: { changeRequestNumber: number; @@ -451,7 +453,7 @@ export interface ForgeService { }): PullRequestCheckoutRef[]; /** * Local branch name for a checked-out change request when the adapter - * disambiguates cross-repository heads (GitHub prefixes the fork owner). + * disambiguates cross-repository heads (prefixes the fork owner). * Returns undefined to keep the head ref name as-is. */ buildPrLocalBranchName?(params: { diff --git a/packages/server/src/services/gitea-service.test.ts b/packages/server/src/services/gitea-service.test.ts index 768f1a7f05..9e3a3ecc62 100644 --- a/packages/server/src/services/gitea-service.test.ts +++ b/packages/server/src/services/gitea-service.test.ts @@ -2297,11 +2297,13 @@ describe("createGiteaService", () => { checkoutRefs: [ { remoteName: "origin", remoteRef: "refs/pull/5/head" }, { remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, ], headOwnerLogin: null, headRepositorySshUrl: null, headRepositoryUrl: null, isCrossRepository: false, + headRefKind: "branch", }); }); @@ -2332,17 +2334,215 @@ describe("createGiteaService", () => { headRefName: "feat/sample-change", checkoutRefs: [ { remoteName: "origin", remoteRef: "refs/pull/5/head" }, - { remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, ], headOwnerLogin: "contributor", headRepositorySshUrl: "git@gitea.com:contributor/sample-repo.git", headRepositoryUrl: "https://gitea.com/contributor/sample-repo", isCrossRepository: true, + headRefKind: "branch", }); expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]); }); + it("skips the origin branch fallback and tracking for an AGit pull request", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok( + JSON.stringify({ + flow: 1, + head: { ref: "refs/pull/5/head", repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }), + ) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target).toMatchObject({ + checkoutRefs: [ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ], + isCrossRepository: false, + headRefKind: "pull-ref", + }); + }); + + it("names the local branch pr-<number> end to end for an agit pull request", async () => { + // the tea CLI reports head as the raw pull ref too when there's no real + // branch, same as the Forgejo API's head.ref + const { service } = makeService((args) => + args[0] === "api" + ? ok( + JSON.stringify({ + flow: 1, + head: { ref: "refs/pull/5/head", repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }), + ) + : ok(JSON.stringify({ ...STATUS_PR_VIEW, head: "refs/pull/5/head" })), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: target.headRefName, + checkoutTarget: target, + }); + + expect(localBranchName).toBe("pr-5"); + }); + + it("skips the origin branch fallback when the head branch was deleted", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok( + JSON.stringify({ + head: { ref: "refs/pull/5/head", repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }), + ) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target).toMatchObject({ + checkoutRefs: [ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ], + isCrossRepository: false, + headRefKind: "pull-ref", + }); + }); + + it("keeps a pull request from a deleted fork cross-repository", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok(JSON.stringify({ head: { repo: null }, base: { repo: { id: 1 } } })) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target).toMatchObject({ + checkoutRefs: [ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ], + headOwnerLogin: null, + isCrossRepository: true, + headRefKind: "branch", + }); + }); + + it("keeps the origin branch fallback when the server sends no head ref", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok(JSON.stringify({ head: { repo: { id: 1 } }, base: { repo: { id: 1 } } })) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target.checkoutRefs).toContainEqual({ + remoteName: "origin", + remoteRef: "refs/heads/feat/sample-change", + }); + expect(target.headRefKind).toBe("branch"); + }); + + it("prefixes the local branch name with the fork owner for a cross-repository checkout", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "patch-1", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "patch-1", + headOwnerLogin: "contributor", + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + }, + }); + + expect(localBranchName).toBe("contributor/patch-1"); + }); + + it("names the local branch pr-<number> for a same-repo pull-ref-only head", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "refs/pull/5/head", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "refs/pull/5/head", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + headRefKind: "pull-ref", + }, + }); + + expect(localBranchName).toBe("pr-5"); + }); + + it("prefixes pr-<number> with the fork owner for a pull-ref-only head", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "refs/pull/5/head", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "refs/pull/5/head", + headOwnerLogin: "contributor", + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + headRefKind: "pull-ref", + }, + }); + + expect(localBranchName).toBe("contributor/pr-5"); + }); + + it("does not double the pr number for a deleted fork's pull-ref-only head", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "refs/pull/5/head", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "refs/pull/5/head", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + headRefKind: "pull-ref", + }, + }); + + expect(localBranchName).toBe("pr-5"); + }); + + it("returns the pull head ref for default checkout refs", () => { + const { service } = makeService(() => ok("")); + + expect(service.defaultCheckoutRefs?.({ changeRequestNumber: 5, headRef: "patch-1" })).toEqual([ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ]); + }); + it("creates a pull request and parses the resulting URL and index", async () => { const { service, calls } = makeService((args) => { if (args[0] === "pr" && args[1] === "create") { diff --git a/packages/server/src/services/gitea-service.ts b/packages/server/src/services/gitea-service.ts index 88b5caa004..892f9cc33a 100644 --- a/packages/server/src/services/gitea-service.ts +++ b/packages/server/src/services/gitea-service.ts @@ -5,6 +5,10 @@ import pLimit from "p-limit"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { findExecutable } from "../executable-resolution/executable-resolution.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { + buildForkLocalBranchName, + buildPullHeadCheckoutRefs, +} from "../utils/change-request-checkout.js"; import { execCommand } from "../utils/spawn.js"; import { createCachedCliPathResolver, @@ -75,6 +79,8 @@ const CURRENT_PR_HEAD_SHA_CACHE_TTL_MS = 30_000; // the sweep to the 250 most recent Actions tasks. const GITEA_ACTION_PAGE_LIMIT = 50; const GITEA_ACTION_MAX_PAGES = 5; +// PullRequestFlowAGit in forgejo models/issues/pull.go +const GITEA_PULL_REQUEST_FLOW_AGIT = 1; /** * Fields requested from `tea pr list -o json`. tea's default field set omits the @@ -225,8 +231,12 @@ const GiteaPullRequestRepoSchema = z const GiteaPullRequestApiSchema = z .object({ + flow: z.number().optional(), head: z - .object({ repo: GiteaPullRequestRepoSchema.nullable().optional() }) + .object({ + ref: z.string().optional(), + repo: GiteaPullRequestRepoSchema.nullable().optional(), + }) .passthrough() .optional(), base: z @@ -1435,17 +1445,30 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For return runJsonParse(args, runOptions, stdout, schema); } + function resolveHeadRefKind( + pr: z.infer<typeof GiteaPullRequestApiSchema>, + number: number, + ): "branch" | "pull-ref" { + // head.ref only becomes the branch name when the branch still exists, + // agit PRs and deleted branches keep the pull ref + const isPullRefOnly = + pr.flow === GITEA_PULL_REQUEST_FLOW_AGIT || pr.head?.ref === `refs/pull/${number}/head`; + return isPullRefOnly ? "pull-ref" : "branch"; + } + async function resolveForkCheckoutFacts( cwd: string, summary: PullRequestSummary, ): Promise<{ isCrossRepository: boolean; + headRefKind: "branch" | "pull-ref"; headOwnerLogin: string | null; headRepositorySshUrl: string | null; headRepositoryUrl: string | null; }> { const sameRepo = { isCrossRepository: false, + headRefKind: "branch" as const, headOwnerLogin: null, headRepositorySshUrl: null, headRepositoryUrl: null, @@ -1464,14 +1487,17 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For ); const headRepo = pr.head?.repo ?? null; const baseRepo = pr.base?.repo ?? null; - if (headRepo?.id == null || baseRepo?.id == null || headRepo.id === baseRepo.id) { - return sameRepo; + const headRefKind = resolveHeadRefKind(pr, summary.number); + if (baseRepo?.id == null || headRepo?.id === baseRepo.id) { + return { ...sameRepo, headRefKind }; } + // headRepo is null when the fork was deleted return { isCrossRepository: true, - headOwnerLogin: headRepo.owner?.login ?? null, - headRepositorySshUrl: headRepo.ssh_url ?? null, - headRepositoryUrl: headRepo.html_url ?? null, + headRefKind, + headOwnerLogin: headRepo?.owner?.login ?? null, + headRepositorySshUrl: headRepo?.ssh_url ?? null, + headRepositoryUrl: headRepo?.html_url ?? null, }; } @@ -1997,17 +2023,29 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For return summary.headRefName; }, + defaultCheckoutRefs({ changeRequestNumber }) { + return buildPullHeadCheckoutRefs(changeRequestNumber); + }, + + buildPrLocalBranchName({ headRef, checkoutTarget }) { + return buildForkLocalBranchName({ headRef, ...checkoutTarget }); + }, + async getPullRequestCheckoutTarget( input: GetPullRequestOptions, ): Promise<PullRequestCheckoutTarget> { const summary = await this.getPullRequest(input); - const checkoutRefs = [ - { remoteName: "origin", remoteRef: `refs/pull/${summary.number}/head` }, - ]; - if (summary.headRefName) { + const fork = await resolveForkCheckoutFacts(input.cwd, summary); + const [originPullRef, upstreamPullRef] = buildPullHeadCheckoutRefs(summary.number); + const checkoutRefs = [originPullRef]; + // for a fork PR a branch with that name on origin belongs to the base repo + const headBranchOnOrigin = + !fork.isCrossRepository && fork.headRefKind === "branch" && Boolean(summary.headRefName); + if (headBranchOnOrigin) { checkoutRefs.push({ remoteName: "origin", remoteRef: `refs/heads/${summary.headRefName}` }); } - const fork = await resolveForkCheckoutFacts(input.cwd, summary); + // upstream can hold a different PR with the same number, so it goes last + checkoutRefs.push(upstreamPullRef); return { number: summary.number, baseRefName: summary.baseRefName, @@ -2017,6 +2055,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For headRepositorySshUrl: fork.headRepositorySshUrl, headRepositoryUrl: fork.headRepositoryUrl, isCrossRepository: fork.isCrossRepository, + headRefKind: fork.headRefKind, }; }, diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index ab71115f36..3109bcc63c 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -797,6 +797,7 @@ describe("ForgeService", () => { headRepositorySshUrl: "git@github.com:therainisme/paseo.git", headRepositoryUrl: "https://github.com/therainisme/paseo", isCrossRepository: true, + headRefKind: "branch", }); expect(runner.calls).toHaveLength(2); diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index b6de0cbd6e..2dd3fdc269 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -6,6 +6,10 @@ import { } from "@getpaseo/protocol/git-remote"; import { findExecutable } from "../executable-resolution/executable-resolution.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { + buildForkLocalBranchName, + buildPullHeadCheckoutRefs, +} from "../utils/change-request-checkout.js"; import { execCommand } from "../utils/spawn.js"; import { resolveSshHostname } from "../utils/ssh-hostname.js"; import { @@ -2104,17 +2108,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }, defaultCheckoutRefs({ changeRequestNumber }) { - return [ - { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, - { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, - ]; + return buildPullHeadCheckoutRefs(changeRequestNumber); }, buildPrLocalBranchName({ headRef, checkoutTarget }) { - const owner = checkoutTarget.isCrossRepository - ? normalizeGitHubOwnerForBranch(checkoutTarget.headOwnerLogin) - : null; - return owner ? `${owner}/${headRef}` : headRef; + return buildForkLocalBranchName({ headRef, ...checkoutTarget }); }, supportsCrossRepoCheckoutWithoutRefs: true, @@ -2663,11 +2661,6 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G return api; } -function normalizeGitHubOwnerForBranch(owner: string | null): string | null { - const normalized = owner?.trim().toLowerCase() ?? ""; - return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; -} - function getGithubStatusFacts( status: PullRequestCommandStatus | null | undefined, ): GitHubPullRequestStatusFacts | null { @@ -3529,6 +3522,7 @@ function toPullRequestCheckoutTarget( headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null, headRepositoryUrl: pullRequest.headRepository?.url || null, isCrossRepository: pullRequest.isCrossRepository, + headRefKind: "branch", }; } diff --git a/packages/server/src/services/gitlab-service.test.ts b/packages/server/src/services/gitlab-service.test.ts index c74224e444..b9b6d447d4 100644 --- a/packages/server/src/services/gitlab-service.test.ts +++ b/packages/server/src/services/gitlab-service.test.ts @@ -477,6 +477,7 @@ describe("createGitLabService", () => { headRepositorySshUrl: null, headRepositoryUrl: null, isCrossRepository: false, + headRefKind: "branch", }); expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]); }); @@ -1328,6 +1329,132 @@ describe("createGitLabService", () => { ); }); + it("finds a merge request by number even when its title and description omit it", async () => { + const { service } = makeService((args) => { + // GitLab text search does not search the IID. Model both API behaviors. + if (args.includes("--search")) return ok("[]"); + if (args[0] === "api" && args[1].includes("iids[]=14")) { + return ok(JSON.stringify([OPEN_MR])); + } + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: " 14 ", + kinds: ["change_request"], + }); + expect(result.items).toEqual([ + expect.objectContaining({ + kind: "change_request", + number: 14, + title: OPEN_MR.title, + headRefName: OPEN_MR.source_branch, + baseRefName: OPEN_MR.target_branch, + }), + ]); + }); + + it("finds issues by number through the same search interface", async () => { + const { service } = makeService((args) => { + if (args.includes("--search")) return ok("[]"); + if (args[0] === "api" && args[1].includes("iids[]=7")) { + return ok(JSON.stringify([OPEN_ISSUE])); + } + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: "7", + kinds: ["issue"], + }); + expect(result.items).toEqual([ + expect.objectContaining({ kind: "issue", number: 7, title: OPEN_ISSUE.title }), + ]); + }); + + it.each([ + { kind: "change_request" as const, item: OPEN_MR }, + { kind: "issue" as const, item: OPEN_ISSUE }, + ])( + "preserves numeric text matches after the exact $kind number, without duplicates", + async ({ kind, item }) => { + const textMatches = [ + { + ...item, + iid: 90, + title: `References ${item.iid}`, + updated_at: "2026-07-01T00:00:00.000Z", + }, + item, + { + ...item, + iid: 91, + description: `References ${item.iid}`, + updated_at: "2026-06-30T00:00:00.000Z", + }, + ]; + const { service } = makeService((args) => { + if (args.includes("--search")) return ok(JSON.stringify(textMatches)); + if (args[0] === "api") return ok(JSON.stringify([item])); + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: String(item.iid), + limit: 2, + kinds: [kind], + }); + expect( + result.items.map((resultItem) => ({ kind: resultItem.kind, number: resultItem.number })), + ).toEqual([ + { kind, number: item.iid }, + { kind, number: 90 }, + ]); + }, + ); + + it.each([ + { kind: "change_request" as const, item: OPEN_MR }, + { kind: "issue" as const, item: OPEN_ISSUE }, + ])("keeps $kind text matches when the queried number is not open", async ({ kind, item }) => { + const { service } = makeService((args) => { + if (args.includes("--search")) { + return ok(JSON.stringify([{ ...item, description: "References 2024" }])); + } + if (args[0] === "api") return ok("[]"); + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: "2024", + kinds: [kind], + }); + expect(result.items.map(({ number }) => number)).toEqual([item.iid]); + }); + + it("keeps closed and missing merge requests out of number search results", async () => { + const closedMr = { ...OPEN_MR, state: "closed" }; + const { service } = makeService((args) => { + if (args.includes("--search")) return ok("[]"); + if (args[0] !== "api") throw new Error(`unexpected glab args: ${args.join(" ")}`); + const endpoint = new URL(args[1], "https://gitlab.example.com/api/v4/"); + const matches = + endpoint.searchParams.get("iids[]") === String(closedMr.iid) && + endpoint.searchParams.get("state") !== "opened"; + return ok(JSON.stringify(matches ? [closedMr] : [])); + }); + for (const query of ["14", "999999"]) { + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query, + limit: 10, + kinds: ["change_request"], + }); + expect(result.authState).toBe("authenticated"); + expect(result.items).toEqual([]); + } + }); + it("searches issues and merge requests and maps them to neutral results", async () => { const { service, calls } = makeService((args) => { if (args[0] === "issue") return ok(JSON.stringify([OPEN_ISSUE])); diff --git a/packages/server/src/services/gitlab-service.ts b/packages/server/src/services/gitlab-service.ts index f0b166cdda..c5f54be2cb 100644 --- a/packages/server/src/services/gitlab-service.ts +++ b/packages/server/src/services/gitlab-service.ts @@ -882,6 +882,31 @@ function throwFirstNonGlabAuthSearchRejection(results: PromiseSettledResult<unkn } } +function buildGitLabListArgs({ + kind, + input, +}: { + kind: "mr" | "issue"; + input: ListPullRequestsOptions | ListIssuesOptions; +}): string[] { + const query = input.query?.trim(); + // issue list uses -O for JSON; its -F flag controls details/ids/urls. + // mr list uses -F for JSON instead. + const args = [kind, "list", kind === "mr" ? "-F" : "-O", "json"]; + if (query) { + args.push("--search", query); + } + if (typeof input.limit === "number") { + args.push("-P", String(input.limit)); + } + return args; +} + +function gitLabNumberQuery(query: string | undefined): string | null { + const trimmed = query?.trim(); + return trimmed && /^\d+$/.test(trimmed) ? trimmed : null; +} + export function createGitLabService(options: CreateGitLabServiceOptions = {}): ForgeService { const runner = options.runner ?? runGlabCommand; const resolveGlab = createCachedCliPathResolver(options.resolveGlabPath ?? resolveGlabPath); @@ -1057,42 +1082,51 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F } } + async function runList<T extends { iid: number }>({ + kind, + input, + schema, + }: { + kind: "mr" | "issue"; + input: ListPullRequestsOptions | ListIssuesOptions; + schema: z.ZodType<T[]>; + }): Promise<T[]> { + const textSearch = runJson(buildGitLabListArgs({ kind, input }), { cwd: input.cwd }, schema); + const numberQuery = gitLabNumberQuery(input.query); + if (!numberQuery) return textSearch; + + // GitLab text search omits IIDs, so supplement it with an open-item lookup. + const resource = kind === "mr" ? "merge_requests" : "issues"; + const [numberMatches, textMatches] = await Promise.all([ + runJson( + ["api", `projects/:id/${resource}?state=opened&iids[]=${numberQuery}`], + { cwd: input.cwd }, + schema, + ), + textSearch, + ]); + const seen = new Set<number>(); + const items = [...numberMatches, ...textMatches].filter((item) => { + if (seen.has(item.iid)) return false; + seen.add(item.iid); + return true; + }); + return typeof input.limit === "number" ? items.slice(0, input.limit) : items; + } + async function runMergeRequestList( input: ListPullRequestsOptions, ): Promise<PullRequestSummary[]> { - const args = ["mr", "list", "-F", "json"]; - const query = input.query?.trim(); - if (query) { - args.push("--search", query); - } - if (typeof input.limit === "number") { - args.push("-P", String(input.limit)); - } - const mergeRequests = await runJson( - args, - { cwd: input.cwd }, - z.array(GitLabMergeRequestSchema), - ); + const mergeRequests = await runList({ + kind: "mr", + input, + schema: z.array(GitLabMergeRequestSchema), + }); return mergeRequests.map(toPullRequestSummary); } - /** - * `glab issue list` toggles JSON output with `-O/--output` (text|json); its - * `-F/--output-format` flag means something else (details|ids|urls) and - * silently falls back to the human-readable table for an unknown value. This - * differs from `glab mr list`, where `-F/--output` is the JSON toggle. Using - * the wrong flag here would emit a text table that fails JSON parsing. - */ async function runIssueList(input: ListIssuesOptions): Promise<IssueSummary[]> { - const args = ["issue", "list", "-O", "json"]; - const query = input.query?.trim(); - if (query) { - args.push("--search", query); - } - if (typeof input.limit === "number") { - args.push("-P", String(input.limit)); - } - const issues = await runJson(args, { cwd: input.cwd }, z.array(GitLabIssueSchema)); + const issues = await runList({ kind: "issue", input, schema: z.array(GitLabIssueSchema) }); return issues.map(toIssueSummary); } @@ -1170,6 +1204,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F mr.target_project_id !== undefined && mr.target_project_id !== null && mr.source_project_id !== mr.target_project_id, + headRefKind: "branch", }; }, @@ -1359,9 +1394,16 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F }); } } - items.sort( - (left, right) => parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt), - ); + const numberQuery = gitLabNumberQuery(input.query); + items.sort((left, right) => { + if (numberQuery) { + const numberMatchOrder = + Number(right.number === Number(numberQuery)) - + Number(left.number === Number(numberQuery)); + if (numberMatchOrder !== 0) return numberMatchOrder; + } + return parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt); + }); return { items, diff --git a/packages/server/src/terminal/agent-hooks/claude/claude.ts b/packages/server/src/terminal/agent-hooks/claude/claude.ts index 35bfa9eca5..82ddcf770d 100644 --- a/packages/server/src/terminal/agent-hooks/claude/claude.ts +++ b/packages/server/src/terminal/agent-hooks/claude/claude.ts @@ -40,8 +40,8 @@ function isIdlePrompt(raw: string | null): boolean { try { const notification = JSON.parse(raw) as unknown; if (!notification || typeof notification !== "object") return false; - const payload = notification as { matcher?: unknown; reason?: unknown }; - return payload.matcher === "idle_prompt" || payload.reason === "idle_prompt"; + const payload = notification as { notification_type?: unknown }; + return payload.notification_type === "idle_prompt"; } catch { return false; } diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 9881f4bdfd..751fb1b33a 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { buildTerminalEnvironment, + assertTerminalCommandCanStart, createTerminal, ensureNodePtySpawnHelperExecutableForCurrentPlatform, resolveDefaultTerminalShell, @@ -24,7 +25,9 @@ import { } from "node:fs"; import { spawnSync } from "node:child_process"; import * as pty from "node-pty"; -import { join } from "node:path"; +import xterm from "@xterm/headless"; +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; +import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; import { stripVTControlCharacters } from "node:util"; @@ -50,6 +53,10 @@ function getLines(state: ReturnType<TerminalSession["getState"]>): string[] { return state.grid.map(rowToText); } +function rowHasChar(row: TerminalRow, char: string): boolean { + return row.some((cell) => cell.char === char); +} + // Wait for terminal state to match expected lines async function waitForLines( session: TerminalSession, @@ -523,6 +530,44 @@ describe("createTerminal", () => { expect(withoutFlags.scrollbackWrapped).toBeUndefined(); }); + it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => { + // Each Hangul syllable is a wide char: xterm stores it as a 2-cell pair + // whose second cell has no chars. Capturing that continuation as " " made + // every restored syllable three columns wide (paseo#3434). + const text = "통합 구현은 전체 테스트를 통과했습니다"; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 80, + rows: 10, + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`, + ], + }), + ); + + const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "다")); + + // Replay the snapshot the way the client does on tab switch and read the + // row back the way a selection copy would. + const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true }); + try { + await new Promise<void>((resolve) => + restored.write(renderTerminalSnapshotToAnsi(state), resolve), + ); + expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text); + } finally { + restored.dispose(); + } + + // The continuation cell must stay empty so the restore path can skip it. + expect(state.grid[0][0].char).toBe("통"); + expect(state.grid[0][1].char).toBe(""); + }); + it("captures exit diagnostics from the terminal buffer", async () => { const session = trackSession( await createTerminal({ @@ -593,6 +638,151 @@ describe("createTerminal", () => { expect(exitInfo.lastOutputLines.length).toBeGreaterThan(0); expect(exitInfo.lastOutputLines[exitInfo.lastOutputLines.length - 1]).toBe("line-2999"); }); + + it.runIf(isPlatform("linux", "darwin"))( + "rejects a profile command that is not installed instead of opening a terminal", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Codex", + command: "paseo-qa-absent-codex", + args: [], + }), + ).rejects.toThrow("paseo-qa-absent-codex: command not found"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "opens a command found only on the PATH carried by the env option", + async () => { + // execve resolves the command against the child's PATH, which carries the + // per-cwd registered environment. A version-manager shim lives there and + // not necessarily on the daemon's own PATH. + const binDir = mkdtempSync(join(tmpdir(), "terminal-envpath-bin-")); + temporaryDirs.push(binDir); + const toolPath = join(binDir, "paseo-qa-envpath-tool"); + writeFileSync(toolPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(toolPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Shim", + command: "paseo-qa-envpath-tool", + args: [], + env: { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` }, + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "reports command not found when the command is on neither PATH", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Codex", + command: "paseo-qa-absent-codex", + args: [], + env: { PATH: `/paseo-qa-empty${delimiter}${process.env.PATH ?? ""}` }, + }), + ).rejects.toThrow("paseo-qa-absent-codex: command not found"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "opens a relative command path resolved against the terminal cwd", + async () => { + // execve resolves a path-ish command against the child's cwd, never the + // daemon's own working directory. + const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-cmd-")); + temporaryDirs.push(projectDir); + mkdirSync(join(projectDir, "scripts")); + const scriptPath = join(projectDir, "scripts", "dev.sh"); + writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(projectDir), + name: "Dev script", + command: "./scripts/dev.sh", + args: [], + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))("opens an absolute command path", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "terminal-absolute-cmd-")); + temporaryDirs.push(projectDir); + const scriptPath = join(projectDir, "run.sh"); + writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Absolute", + command: scriptPath, + args: [], + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }); + + it("leaves the command check to the terminal itself on Windows", async () => { + // Windows resolves inside resolveTerminalSpawnCommand, which has its own + // shim handling and its own fallback for an unresolved command. + await expect( + assertTerminalCommandCanStart({ + command: "paseo-qa-absent-codex", + cwd: realpathSync(tmpdir()), + env: { PATH: "/paseo-qa-empty" }, + platform: "win32", + }), + ).resolves.toBeUndefined(); + }); + + it.runIf(isPlatform("linux", "darwin"))( + "still opens a terminal for an installed command that exits non-zero", + async () => { + // A profile running a failing test suite must keep opening a tab with its + // output. Only a command that cannot start at all is reported as an error, + // and "exited non-zero" does not distinguish the two: a missing binary and + // `false` both surface as exit code 1 from node-pty. + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Failing suite", + command: "false", + args: [], + }), + ); + + const exitInfo = await new Promise<{ exitCode: number | null }>((resolve) => { + session.onExit((info) => resolve({ exitCode: info.exitCode })); + }); + + expect(exitInfo).toEqual({ exitCode: 1 }); + }, + ); }); describe.skipIf(isPlatform("win32"))("send input", () => { @@ -1344,3 +1534,43 @@ describe.runIf(isPlatform("win32"))(".cmd shim argv round-trip on Windows", () = expect(await roundTrip("first\nsecond")).toEqual(["first second"]); }); }); + +it.runIf(isPlatform("linux", "darwin"))( + "opens a profile command from a PATH entry relative to the terminal cwd", + async () => { + const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-path-")); + mkdirSync(join(projectDir, "bin")); + const script = join(projectDir, "bin", "paseo-relative-path-tool"); + writeFileSync(script, "#!/bin/sh\nprintf 'paseo-launch-ok\\n'\nexec /bin/sleep 30\n"); + chmodSync(script, 0o755); + let session: TerminalSession | undefined; + try { + session = await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(projectDir), + command: "paseo-relative-path-tool", + env: { PATH: "bin" }, + }); + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(session.cwd).toBe(realpathSync(projectDir)); + expect(session.getExitInfo()).toBeNull(); + } finally { + await session?.killAndWait({ gracefulTimeoutMs: 1000, forceTimeoutMs: 500 }); + rmSync(projectDir, { recursive: true, force: true }); + } + }, +); + +it.runIf(isPlatform("linux", "darwin"))( + "checks the exact command rather than accepting a trimmed executable name", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + command: " node ", + }), + ).rejects.toThrow(" node : command not found"); + }, +); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b9d1b74017..24f243b6ea 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -1,14 +1,25 @@ import * as pty from "node-pty"; import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; -import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { + accessSync, + chmodSync, + constants, + existsSync, + mkdirSync, + readFileSync, + statSync, +} from "node:fs"; import { tmpdir, userInfo } from "node:os"; import { basename, delimiter, dirname, extname, join, resolve as resolvePath } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { writePrivateFileAtomicSync } from "../server/private-files.js"; -import { findExecutable } from "../executable-resolution/executable-resolution.js"; +import { + findExecutable, + findExecutableWithoutProbing, +} from "../executable-resolution/executable-resolution.js"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js"; @@ -33,6 +44,20 @@ export interface TerminalExitInfo { lastOutputLines: string[]; } +// The field is assigned explicitly rather than declared as a constructor +// parameter property: the terminal worker loads this module under +// --experimental-strip-types, which erases types but cannot emit the +// assignment a parameter property implies. +export class TerminalCommandNotFoundError extends Error { + readonly command: string; + + constructor(command: string) { + super(`${command}: command not found`); + this.name = "TerminalCommandNotFoundError"; + this.command = command; + } +} + export interface TerminalCommandFinishedInfo { exitCode: number | null; } @@ -580,7 +605,9 @@ function extractCell(terminal: TerminalType, row: number, col: number): Terminal const bg = bgMode !== 0 ? cell.getBgColor() : undefined; return { - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, @@ -639,7 +666,9 @@ function extractScrollback( const fg = fgMode !== 0 ? cell.getFgColor() : undefined; const bg = bgMode !== 0 ? cell.getBgColor() : undefined; rowCells.push({ - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, @@ -882,6 +911,62 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] { return lines.slice(-limit); } +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) { + return false; + } + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +export interface AssertTerminalCommandCanStartInput { + command: string | undefined; + // The terminal's own cwd and environment, not the daemon's: execve in the + // child resolves against those, so anything else produces false negatives. + cwd: string; + env: Record<string, string>; + platform?: NodeJS.Platform; +} + +// node-pty spawns directly through execve on POSIX, with no shell, so a command +// that does not exist fails inside the spawn helper and surfaces as exit code 1 +// with no output -- indistinguishable from `false` or any program that simply +// failed. Resolving it up front is the only signal that separates "cannot start" +// from "ran and exited non-zero", and it lets the create report the failure +// instead of handing back a terminal that dies immediately. Windows keeps +// resolving inside resolveTerminalSpawnCommand, which has its own shim handling +// and its own fallback for an unresolved command. +export async function assertTerminalCommandCanStart( + input: AssertTerminalCommandCanStartInput, +): Promise<void> { + const { command, cwd, env } = input; + const platform = input.platform ?? process.platform; + if (command === undefined || platform === "win32") { + return; + } + + // A path-ish command is resolved by execve against the child's cwd, never + // through PATH and never against the daemon's working directory. + if (command.includes("/")) { + if (isExecutableFile(resolvePath(cwd, command))) { + return; + } + throw new TerminalCommandNotFoundError(command); + } + + const resolved = await findExecutableWithoutProbing(command, { + path: env[getPathEnvKey(env)], + cwd, + }); + if (!resolved) { + throw new TerminalCommandNotFoundError(command); + } +} + export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> { const { cwd, @@ -941,19 +1026,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te const { command: spawnCommand, args: spawnArgs } = command ? await resolveTerminalSpawnCommand(command, args) : { command: resolvedShell, args: [] as string[] }; + const terminalEnv = buildTerminalEnvironment({ + shell: spawnCommand, + env: { + ...env, + ...activityEnv, + PASEO_WORKSPACE_ID: workspaceId, + }, + }); + + await assertTerminalCommandCanStart({ command, cwd, env: terminalEnv }); + const ptyProcess = pty.spawn(spawnCommand, spawnArgs, { name: "xterm-256color", cols, rows, cwd, - env: buildTerminalEnvironment({ - shell: spawnCommand, - env: { - ...env, - ...activityEnv, - PASEO_WORKSPACE_ID: workspaceId, - }, - }), + env: terminalEnv, }); function emitTitleChange(nextTitle: string | undefined): void { diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 2dc999c0c3..e11c9eca9f 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as session.send({ type: "input", data: `${command}\r` }); - await waitForCondition(() => existsSync(markerPath), 10000); - - expect(readFileSync(markerPath, "utf8")).toBe("shell-ok"); + await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok"); }); it("lists subdirectory terminals when querying the workspace root", async () => { @@ -970,3 +968,139 @@ it("removes a killed worker terminal from terminalExit without duplicate snapsho }, ]); }); + +it("does not resurrect a terminal that exited before its create response was handled", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) }); + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + + // The worker delivers all three messages in one tick, so the awaiting + // createTerminal continuation resumes only after the exit was processed. + const terminal = { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: null, + }; + const state = createTerminalState(); + const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] }; + worker.emitWorkerMessage({ type: "terminalCreated", terminal, state }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: { terminal, state }, + }); + worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo }); + + const session = await created; + + expect(manager.getTerminal("terminal-a")).toBeUndefined(); + expect(await manager.getTerminals("/workspace")).toEqual([]); + expect(session.getExitInfo()).toEqual(exitInfo); + expect(snapshots).toEqual([ + { cwd: "/workspace", terminalIds: ["terminal-a"] }, + { cwd: "/workspace", terminalIds: [] }, + ]); +}); + +it("replays the exit to a listener attached after a create that lost the race", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + + const terminal = { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: null, + }; + const state = createTerminalState(); + const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] }; + worker.emitWorkerMessage({ type: "terminalCreated", terminal, state }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: { terminal, state }, + }); + worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo }); + + const session = await created; + + // The session controller subscribes only after createTerminal resolves; that + // subscription is what turns into a terminal_exit for the client. + const observedExits: Array<{ exitCode: number | null }> = []; + session.onExit((info) => { + observedExits.push({ exitCode: info.exitCode }); + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(observedExits).toEqual([{ exitCode: 0 }]); +}); + +it("rejects createTerminal and registers nothing when the worker cannot start the command", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) }); + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + command: "paseo-qa-absent-codex", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: false, + error: "paseo-qa-absent-codex: command not found", + }); + + await expect(created).rejects.toThrow("paseo-qa-absent-codex: command not found"); + expect(manager.getTerminal("terminal-a")).toBeUndefined(); + expect(await manager.getTerminals("/workspace")).toEqual([]); + expect(snapshots).toEqual([]); +}); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 0f0243d7a8..ca84cfe622 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -156,6 +156,9 @@ export function createWorkerTerminalManager( const recordsById = new Map<string, WorkerTerminalRecord>(); const terminalIdsByCwd = new Map<string, Set<string>>(); const terminalActivityTokenById = new Map<string, string>(); + // Creation owns a reference to the session independently of the live-terminal + // index. Only terminalCreated registers it; terminalExit removes it permanently. + const pendingCreationsById = new Map<string, { session: TerminalSession | null }>(); const terminalsChangedListeners = new Set<TerminalsChangedListener>(); const terminalActivityListeners = new Set<TerminalActivityListener>(); const terminalWorkspaceContributionChangedListeners = @@ -542,10 +545,14 @@ export function createWorkerTerminalManager( function handleWorkerEvent(message: TerminalWorkerToParentMessage): void { switch (message.type) { case "terminalCreated": { - registerRecord({ + const session = registerRecord({ info: asRequiredWorkerTerminalInfo(message.terminal), state: message.state, }); + const pendingCreation = pendingCreationsById.get(message.terminal.id); + if (pendingCreation) { + pendingCreation.session = session; + } emitTerminalsChanged({ cwd: message.terminal.cwd, terminals: listTerminalItemsForCwd(message.terminal.cwd), @@ -686,12 +693,10 @@ export function createWorkerTerminalManager( const activityToken = createActivityToken(); const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null; terminalActivityTokenById.set(terminalId, activityToken); - let result: { - terminal: RequiredWorkerTerminalInfo; - state: TerminalState; - }; + const creation = { session: null as TerminalSession | null }; + pendingCreationsById.set(terminalId, creation); try { - result = (await sendRequest({ + await sendRequest({ type: "createTerminal", options: { ...options, @@ -699,16 +704,17 @@ export function createWorkerTerminalManager( activityToken, activityUrl: terminalActivityUrl, }, - })) as { - terminal: RequiredWorkerTerminalInfo; - state: TerminalState; - }; + }); + if (!creation.session) { + throw new Error("Terminal worker completed creation without a terminalCreated event"); + } + return creation.session; } catch (error) { terminalActivityTokenById.delete(terminalId); throw error; + } finally { + pendingCreationsById.delete(terminalId); } - const session = registerRecord({ info: result.terminal, state: result.state }); - return session; }, registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void { diff --git a/packages/server/src/utils/change-request-checkout.test.ts b/packages/server/src/utils/change-request-checkout.test.ts new file mode 100644 index 0000000000..74a57c2445 --- /dev/null +++ b/packages/server/src/utils/change-request-checkout.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + buildForkLocalBranchName, + normalizeForgeOwnerForBranch, +} from "./change-request-checkout.js"; + +describe("normalizeForgeOwnerForBranch", () => { + it("lowercases a valid login", () => { + expect(normalizeForgeOwnerForBranch("Contributor")).toBe("contributor"); + }); + + it("allows dots and underscores", () => { + expect(normalizeForgeOwnerForBranch("a.b_c")).toBe("a.b_c"); + }); + + it("rejects a login starting with a dash", () => { + expect(normalizeForgeOwnerForBranch("-abc")).toBeNull(); + }); + + it("rejects a login starting with a dot", () => { + expect(normalizeForgeOwnerForBranch(".hidden")).toBeNull(); + }); + + it("rejects a login containing '..'", () => { + expect(normalizeForgeOwnerForBranch("a..b")).toBeNull(); + }); + + it("rejects a login ending in .lock", () => { + expect(normalizeForgeOwnerForBranch("contributor.lock")).toBeNull(); + }); + + it("rejects a login with a path traversal segment", () => { + expect(normalizeForgeOwnerForBranch("../evil")).toBeNull(); + }); + + it("rejects an empty or null login", () => { + expect(normalizeForgeOwnerForBranch(null)).toBeNull(); + expect(normalizeForgeOwnerForBranch("")).toBeNull(); + expect(normalizeForgeOwnerForBranch(" ")).toBeNull(); + }); +}); + +describe("buildForkLocalBranchName", () => { + const fork = { headRef: "patch-1", number: 42, isCrossRepository: true }; + + it("keeps the head ref for a same-repository head", () => { + expect( + buildForkLocalBranchName({ ...fork, isCrossRepository: false, headOwnerLogin: "arthur" }), + ).toBe("patch-1"); + }); + + it("prefixes a fork head with the lowercased owner", () => { + expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur" })).toBe("arthur/patch-1"); + }); + + it("falls back to the pr number when the owner is unknown", () => { + expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: null })).toBe("pr-42/patch-1"); + }); + + it("falls back to the pr number when the owner is not a safe ref segment", () => { + expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "-zaphod" })).toBe("pr-42/patch-1"); + }); + + // pull-ref-only heads (agit PRs, deleted branches) report headRef as the + // literal pull ref, e.g. refs/pull/42/head, not a real branch name + it("collapses a same-repository pull-ref-only head to pr-<number>", () => { + expect( + buildForkLocalBranchName({ + ...fork, + isCrossRepository: false, + headOwnerLogin: "arthur", + headRefKind: "pull-ref", + }), + ).toBe("pr-42"); + }); + + it("prefixes pr-<number> with the owner for a fork pull-ref-only head", () => { + expect( + buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur", headRefKind: "pull-ref" }), + ).toBe("arthur/pr-42"); + }); + + it("does not double the pr number when the fork owner is unknown and there is no head branch", () => { + expect( + buildForkLocalBranchName({ ...fork, headOwnerLogin: null, headRefKind: "pull-ref" }), + ).toBe("pr-42"); + }); +}); diff --git a/packages/server/src/utils/change-request-checkout.ts b/packages/server/src/utils/change-request-checkout.ts new file mode 100644 index 0000000000..12c779571b --- /dev/null +++ b/packages/server/src/utils/change-request-checkout.ts @@ -0,0 +1,47 @@ +import type { PullRequestCheckoutRef } from "../services/forge-service.js"; + +// upstream for clones where origin is your fork +export function buildPullHeadCheckoutRefs(number: number): PullRequestCheckoutRef[] { + return [ + { remoteName: "origin", remoteRef: `refs/pull/${number}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${number}/head` }, + ]; +} + +// forgejo username pattern, covers github logins too +export function normalizeForgeOwnerForBranch(owner: string | null): string | null { + const normalized = owner?.trim().toLowerCase() ?? ""; + if (!/^[a-z0-9][a-z0-9_.-]*$/.test(normalized)) { + return null; + } + // git refuses .. and .lock, trailing dot too + if (normalized.includes("..") || normalized.endsWith(".") || normalized.endsWith(".lock")) { + return null; + } + return normalized; +} + +export interface ForkLocalBranchNameInput { + headRef: string; + number: number; + isCrossRepository: boolean; + headOwnerLogin: string | null; + /** "pull-ref" when headRef is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */ + headRefKind?: "branch" | "pull-ref"; +} + +export function buildForkLocalBranchName(params: ForkLocalBranchNameInput): string { + const pullRefFallback = `pr-${params.number}`; + // headRef is the literal pull ref (e.g. refs/pull/42/head) when there is no + // real branch to name after, so use the pr number instead + const headSegment = params.headRefKind === "pull-ref" ? pullRefFallback : params.headRef; + if (!params.isCrossRepository) { + return headSegment; + } + const owner = normalizeForgeOwnerForBranch(params.headOwnerLogin); + if (!owner) { + // don't double up pr-<n>/pr-<n> when the owner is also unknown + return headSegment === pullRefFallback ? headSegment : `${pullRefFallback}/${headSegment}`; + } + return `${owner}/${headSegment}`; +} diff --git a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts index fbb22fe3b9..576e9b0c80 100644 --- a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts +++ b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; -import { getCommitFileDiff } from "./checkout-git.js"; +import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js"; +import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js"; const tempDirs: string[] = []; @@ -44,6 +45,75 @@ function headSha(repoDir: string): string { } describe("getCommitFileDiff", () => { + it.each(["new.txt", "with spaces.txt"])("preserves a rename-only commit to %s", async (name) => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["mv", "old.txt", name], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + const sha = headSha(repoDir); + + const history = await listCheckoutCommits({ cwd: repoDir }); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: name, status: "renamed", additions: 0, deletions: 0 }, + ]); + expect(await getCommitFileDiff({ cwd: repoDir, sha, path: name })).toEqual({ + path: name, + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }); + }); + + it.each(["new.txt", "目录.txt"])( + "preserves the edits in a committed rename to %s", + async (name) => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\nkeep\nlast\n", "initial"); + git(["mv", "old.txt", name], repoDir); + writeFileSync(join(repoDir, name), "same\nKEEP\nlast\n"); + git(["add", "."], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], repoDir); + const sha = headSha(repoDir); + + const history = await listCheckoutCommits({ cwd: repoDir }); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: name, status: "renamed", additions: 1, deletions: 1 }, + ]); + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: name }); + expect(file).toMatchObject({ + path: name, + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "keep" }), + expect.objectContaining({ type: "add", content: "KEEP" }), + ]), + ); + }, + ); + + it.skipIf(process.platform === "win32")("preserves a rename to a quoted filename", async () => { + const repoDir = initRepo(); + const name = 'new "draft".txt'; + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["mv", "old.txt", name], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + expect(await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: name })).toEqual({ + path: name, + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }); + }); + it("returns the parsed diff for a modified file in a commit", async () => { const repoDir = initRepo(); commitFile(repoDir, "foo.txt", "a\nb\nc\n", "initial"); @@ -78,6 +148,78 @@ describe("getCommitFileDiff", () => { expect(file?.deletions).toBe(0); }); + it.each([ + ["exact", "line1\nline2\nline3\nline4\nline5\nline6\n"], + ["edited", "line1\nline2\nline3\nline4\nline5\nCHANGED\n"], + ])("shows an added %s copy as a new file", async (_kind, addedContent) => { + const repoDir = initRepo(); + commitFile(repoDir, "source.txt", "line1\nline2\nline3\nline4\nline5\nline6\n", "initial"); + commitFile(repoDir, "added.txt", addedContent, "add similar file"); + const sha = headSha(repoDir); + + const history = await listCheckoutCommits({ cwd: repoDir }); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: "added.txt", status: "added", additions: 6, deletions: 0 }, + ]); + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" }); + expect(file).toMatchObject({ + path: "added.txt", + isNew: true, + isDeleted: false, + additions: 6, + deletions: 0, + }); + expect( + file?.hunks + .flatMap((hunk) => hunk.lines) + .filter((line) => line.type !== "header") + .map(({ type, content }) => ({ type, content })), + ).toEqual( + addedContent + .trimEnd() + .split("\n") + .map((content) => ({ type: "add", content })), + ); + }); + + it("shares commit rename discovery across concurrent and later file requests", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["mv", "old.txt", "new.txt"], repoDir); + commitFile(repoDir, "added.txt", "added\n", "rename and add"); + const sha = headSha(repoDir); + + startGitCommandMetrics(); + try { + const [renamed, added] = await Promise.all([ + getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" }), + getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" }), + ]); + expect(renamed).toMatchObject({ isNew: false, additions: 0, deletions: 0 }); + expect(added).toMatchObject({ isNew: true, additions: 1, deletions: 0 }); + expect(await getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" })).toEqual(renamed); + } finally { + const metrics = stopGitCommandMetrics(); + expect(metrics.commands.filter(({ args }) => args.includes("--name-status"))).toHaveLength(1); + } + }); + + it("rediscovers renames when a symbolic commit ref moves", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "old.txt" })).toMatchObject({ + isNew: true, + additions: 1, + }); + git(["mv", "old.txt", "new.txt"], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "new.txt" })).toMatchObject({ + isNew: false, + additions: 0, + deletions: 0, + }); + }); + it("returns null for a path not changed in the commit", async () => { const repoDir = initRepo(); commitFile(repoDir, "foo.txt", "a\n", "initial"); @@ -89,6 +231,59 @@ describe("getCommitFileDiff", () => { expect(file).toBeNull(); }); + it("does not follow an unchanged file back to an earlier commit", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "foo.txt", "same\n", "initial"); + commitFile(repoDir, "other.txt", "other\n", "unrelated change"); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }), + ).toBeNull(); + }); + + it("returns the added file in a root commit and the deleted file in its deletion commit", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "foo.txt", "same\n", "initial"); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }), + ).toMatchObject({ + isNew: true, + isDeleted: false, + additions: 1, + deletions: 0, + }); + git(["rm", "foo.txt"], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "delete"], repoDir); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }), + ).toMatchObject({ + isNew: false, + isDeleted: true, + additions: 0, + deletions: 1, + }); + }); + + it("preserves a rename introduced by a merge against its first parent", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["checkout", "-b", "feature"], repoDir); + git(["mv", "old.txt", "new.txt"], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + git(["checkout", "main"], repoDir); + commitFile(repoDir, "main.txt", "main\n", "advance main"); + git(["merge", "--no-ff", "feature", "-m", "merge feature"], repoDir); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "new.txt" }), + ).toMatchObject({ + path: "new.txt", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }); + }); + it("returns a merge commit diff against its first parent", async () => { const repoDir = initRepo(); commitFile(repoDir, "README.md", "base\n", "initial"); diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index 5a006ae0eb..d614e32f9b 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -25,7 +25,10 @@ function git(args: string[], cwd?: string): void { } function commit(repoDir: string, message: string): void { - git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir); + git( + ["-c", "commit.gpgsign=false", "commit", "--author=Test User <test@test.com>", "-m", message], + repoDir, + ); } function commitFile(repoDir: string, name: string, content: string, message: string): void { @@ -92,6 +95,30 @@ function addBareRemote(repoDir: string, tempDir: string): string { } describe("listCheckoutCommits", () => { + it("keeps fixture authors independent of command-scoped Git configuration", async () => { + const overrides = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "Other User", + }; + const previous = Object.fromEntries( + Object.keys(overrides).map((key) => [key, process.env[key]]), + ); + let repoDir: string; + try { + Object.assign(process.env, overrides); + ({ repoDir } = initRepoOnMain()); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + expect(commits[0]?.authorName).toBe("Test User"); + }); + it("lists recent commits newest-first with on-remote flags and file stats", async () => { const { repoDir, tempDir } = initRepoOnMain(); git(["checkout", "-b", "feature"], repoDir); diff --git a/packages/server/src/utils/checkout-git.patch-format.test.ts b/packages/server/src/utils/checkout-git.patch-format.test.ts new file mode 100644 index 0000000000..61e864a401 --- /dev/null +++ b/packages/server/src/utils/checkout-git.patch-format.test.ts @@ -0,0 +1,416 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getCheckoutDiff, + getCheckoutShortstat, + getCommitFileDiff, + listCheckoutCommits, +} from "./checkout-git.js"; + +const repos: string[] = []; + +const EDITED_TEXT_DIFF = { + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], +}; + +afterEach(() => { + vi.unstubAllEnvs(); + for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true }); +}); + +it.skipIf(process.platform === "win32")( + "returns a quoted filename and its uncommitted diff using its actual path", + async () => { + const cwd = createChangedRepo("core.quotepath=false"); + const name = 'report "draft".txt'; + writeFileSync(join(cwd, "file.txt"), "before\n"); + writeFileSync(join(cwd, name), "before\n"); + git(cwd, "add", "--", name); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add quoted filename"); + writeFileSync(join(cwd, name), "after\n"); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured).toHaveLength(1); + expect(result.structured?.[0]).toMatchObject({ + path: name, + status: "ok", + ...EDITED_TEXT_DIFF, + }); + }, +); + +describe.skipIf(process.platform === "win32")("quoted paths from real Git", () => { + const names = [ + "normal.txt", + "with spaces.txt", + 'report "draft".txt', + "back\\slash.txt", + "tab\tname.txt", + "line\nname.txt", + " leading and trailing .txt ", + 'report "old => draft".txt', + ]; + + function createQuotedPathsRepo(): string { + const cwd = createChangedRepo("core.quotepath=false"); + writeFileSync(join(cwd, "file.txt"), "before\n"); + for (const name of names) writeFileSync(join(cwd, name), "before\n"); + git(cwd, "add", "--", ...names); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add filenames"); + git(cwd, "checkout", "-b", "feature"); + for (const name of names) writeFileSync(join(cwd, name), "after\n"); + return cwd; + } + + it("preserves tracked and untracked paths, diff lines and summary counts", async () => { + const cwd = createQuotedPathsRepo(); + const untrackedNames = names.map((name) => "untracked-" + name); + for (const name of untrackedNames) writeFileSync(join(cwd, name), "new file\n"); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured?.map((file) => file.path).sort()).toEqual( + [...names, ...untrackedNames].sort(), + ); + for (const name of names) { + expect(result.structured?.find((file) => file.path === name)).toMatchObject({ + path: name, + status: "ok", + ...EDITED_TEXT_DIFF, + }); + } + for (const name of untrackedNames) { + expect(result.structured?.find((file) => file.path === name)).toMatchObject({ + path: name, + status: "ok", + isNew: true, + isDeleted: false, + additions: 1, + deletions: 0, + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [ + { type: "header", content: "@@ -0,0 +1 @@" }, + { type: "add", content: "new file" }, + ], + }, + ], + }); + } + expect(await getCheckoutShortstat(cwd)).toEqual({ + additions: names.length + untrackedNames.length, + deletions: names.length, + }); + }); + + it("opens base and commit diffs using the actual paths from the commit list", async () => { + const cwd = createQuotedPathsRepo(); + git(cwd, "add", "--", ...names); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit filenames"); + const sha = git(cwd, "rev-parse", "HEAD").trim(); + + const base = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + expect(base.structured?.map((file) => file.path).sort()).toEqual([...names].sort()); + for (const name of names) { + expect(base.structured?.find((file) => file.path === name)).toMatchObject({ + path: name, + status: "ok", + ...EDITED_TEXT_DIFF, + }); + } + + const history = await listCheckoutCommits({ cwd }); + const commit = history.commits[0]; + expect(commit.sha).toBe(sha); + expect(commit.files).toEqual( + [...names].sort().map((name) => ({ + path: name, + additions: 1, + deletions: 1, + status: "modified", + })), + ); + for (const file of commit.files) { + expect(await getCommitFileDiff({ cwd, sha, path: file.path })).toMatchObject({ + path: file.path, + ...EDITED_TEXT_DIFF, + }); + } + }); + + it("preserves quoted rename destinations and binary paths in diff and commit lists", async () => { + const cwd = createChangedRepo("core.quotepath=false"); + const oldPath = 'old "draft".txt'; + const newPath = 'new "draft".txt'; + const binaryPath = "image\tdata.bin"; + writeFileSync(join(cwd, "file.txt"), "before\n"); + writeFileSync(join(cwd, oldPath), "unchanged\n"); + writeFileSync(join(cwd, binaryPath), Buffer.from([0, 1, 2])); + git(cwd, "add", "--", oldPath, binaryPath); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add rename and binary files"); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "mv", "--", oldPath, newPath); + writeFileSync(join(cwd, binaryPath), Buffer.from([0, 3, 4])); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + expect( + result.structured?.map(({ path, oldPath: sourcePath, status }) => ({ + path, + oldPath: sourcePath, + status, + })), + ).toEqual([ + { path: binaryPath, oldPath: undefined, status: "binary" }, + { + path: newPath, + oldPath, + status: "ok", + }, + ]); + expect(result.structured?.[0]).toEqual({ + path: binaryPath, + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + status: "binary", + }); + + git(cwd, "add", "--", binaryPath); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename file and edit binary"); + const history = await listCheckoutCommits({ cwd }); + expect(history.commits[0].files).toEqual([ + { path: binaryPath, additions: 0, deletions: 0, status: "modified" }, + { path: newPath, additions: 0, deletions: 0, status: "renamed" }, + ]); + }); +}); + +it.each(["diff.mnemonicPrefix=true", "color.diff=always", "diff.noprefix=true"])( + "preserves batched paths, spaces and literal prefix directories with %s", + async (config) => { + const cwd = createChangedRepo(config); + mkdirSync(join(cwd, "a")); + for (const name of ["a/literal.txt", "with spaces.txt"]) { + writeFileSync(join(cwd, name), "before\n"); + } + git(cwd, "add", "a/literal.txt", "with spaces.txt"); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add files"); + for (const name of ["a/literal.txt", "with spaces.txt"]) { + writeFileSync(join(cwd, name), "after\n"); + } + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured?.map((file) => file.path)).toEqual([ + "a/literal.txt", + "file.txt", + "with spaces.txt", + ]); + for (const file of result.structured ?? []) { + expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + } + expect(git(cwd, "config", "--get", config.split("=")[0]).trim()).toBe(config.split("=")[1]); + }, +); + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); +} + +function createChangedRepo(config: string): string { + const cwd = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-patch-format-"))); + repos.push(cwd); + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "file.txt"), "before\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + git(cwd, "config", ...config.split("=")); + writeFileSync(join(cwd, "file.txt"), "after\n"); + return cwd; +} + +function createExternalDiffCommand() { + const directory = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-external-diff-"))); + repos.push(directory); + const script = join(directory, "external-diff.cjs"); + const marker = join(directory, "invoked"); + writeFileSync( + script, + 'require("node:fs").writeFileSync(process.argv[2], "invoked");\n' + + 'process.stdout.write("CUSTOM EXTERNAL DIFF\\n");\n', + ); + const command = [process.execPath, script, marker] + .map((argument) => `'${argument.replaceAll("'", "'\\''")}'`) + .join(" "); + return { command, marker }; +} + +function createExternalDiffRepo() { + vi.stubEnv("GIT_EXTERNAL_DIFF", undefined); + const cwd = createChangedRepo("color.diff=always"); + const { command, marker } = createExternalDiffCommand(); + git(cwd, "config", "diff.external", command); + expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n"); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + return { cwd, command, marker }; +} + +function expectStandardPatch(result: Awaited<ReturnType<typeof getCheckoutDiff>>) { + expect(result.structured).toMatchObject([ + { + path: "file.txt", + additions: 1, + deletions: 1, + status: "ok", + hunks: [ + { + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], + }, + ]); +} + +it("returns Git patch hunks when a repository configures an external diff command", async () => { + const { cwd, command, marker } = createExternalDiffRepo(); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command); +}); + +it("returns Git patch hunks against the base branch with an external diff command", async () => { + const { cwd, command, marker } = createExternalDiffRepo(); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + + const result = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command); +}); + +it("returns Git patch hunks when GIT_EXTERNAL_DIFF configures the external command", async () => { + const cwd = createChangedRepo("color.diff=always"); + const { command, marker } = createExternalDiffCommand(); + vi.stubEnv("GIT_EXTERNAL_DIFF", command); + expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n"); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(process.env.GIT_EXTERNAL_DIFF).toBe(command); +}); + +describe.each(["diff.mnemonicPrefix=true", "color.diff=always"])( + "patch format with %s", + (config) => { + it("returns displayable uncommitted diff lines with their file counts", async () => { + const cwd = createChangedRepo(config); + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured).toHaveLength(1); + expect(result.structured?.[0]).toMatchObject({ + path: "file.txt", + additions: 1, + deletions: 1, + }); + expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + }); + + it("returns displayable committed changes against the base branch", async () => { + const cwd = createChangedRepo(config); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const result = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expect(result.structured?.[0]).toMatchObject({ + path: "file.txt", + additions: 1, + deletions: 1, + }); + expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]), + ); + }); + + it("returns displayable lines when opening an individual commit file", async () => { + const cwd = createChangedRepo(config); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const file = await getCommitFileDiff({ + cwd, + sha: git(cwd, "rev-parse", "HEAD").trim(), + path: "file.txt", + }); + + expect(file).toMatchObject({ path: "file.txt", additions: 1, deletions: 1 }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]), + ); + }); + }, +); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index b5f505ea2d..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); @@ -991,9 +1111,9 @@ const x = 1; }); it("does not report local-only no-track worktree commits as ahead of origin", async () => { - setupRemoteTrackingMain(repoDir, tempDir); - commitFile(repoDir, "second.txt", "second\n", "second commit"); - execFileSync("git", ["push"], { cwd: repoDir }); + // This case needs a remote base ref, not a second clone or extra base history. + execFileSync("git", ["remote", "add", "origin", repoDir], { cwd: repoDir }); + execFileSync("git", ["fetch", "origin", "main:refs/remotes/origin/main"], { cwd: repoDir }); const worktree = await createLegacyWorktreeForTest({ branchName: "fresh-feature", @@ -1007,6 +1127,7 @@ const x = 1; const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status).toMatchObject({ isGit: true, + hasRemote: true, isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 1, behind: 0 }, @@ -1506,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); }); @@ -3575,6 +3700,7 @@ const x = 1; cwd: repoDir, }); execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir }); + execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir }); execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir }); await expect(mergeToBase(repoDir, { baseRef: "main", mode: "squash" })).rejects.toThrow( diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index e1246a90a5..ce00702908 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,3 +1,4 @@ +import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; @@ -31,6 +32,7 @@ import { import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand, type RunGitCommand } from "./run-git-command.js"; import { readGitFileContents } from "./git-file-contents.js"; +import { unquoteGitPath } from "./git-quoted-path.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { branchNameFromRef, @@ -514,22 +516,18 @@ async function listCheckoutFileChanges( const { stdout: nameStatusOut } = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, - extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)], + extra: ["-z", "--name-status", ...getCheckoutDiffRefArgs(refs)], }), { cwd, envOverlay: READ_ONLY_GIT_ENV }, ); - for (const line of nameStatusOut - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)) { - // `--name-status` uses TAB separators, which preserves filenames with spaces. - const tabParts = line.split("\t"); - const rawStatus = (tabParts[0] ?? "").trim(); + const nameStatusFields = nameStatusOut.split("\0"); + for (let index = 0; index < nameStatusFields.length; ) { + const rawStatus = nameStatusFields[index++]; if (!rawStatus) continue; if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) { - const oldPath = tabParts[1]; - const newPath = tabParts[2]; + const oldPath = nameStatusFields[index++]; + const newPath = nameStatusFields[index++]; if (newPath) { changes.push({ path: newPath, @@ -542,7 +540,7 @@ async function listCheckoutFileChanges( continue; } - const path = tabParts[1]; + const path = nameStatusFields[index++]; if (!path) continue; const code = rawStatus[0]; changes.push({ @@ -555,16 +553,13 @@ async function listCheckoutFileChanges( if (refs.includeUntracked) { const { stdout: untrackedOut } = await runGitCommand( - ["ls-files", "--others", "--exclude-standard"], + ["ls-files", "-z", "--others", "--exclude-standard"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }, ); - for (const file of untrackedOut - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)) { + for (const file of untrackedOut.split("\0").filter(Boolean)) { changes.push({ path: file, status: "U", @@ -622,22 +617,34 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string type FileStat = { additions: number; deletions: number; isBinary: boolean } | null; function normalizeNumstatPath(pathField: string): string { + if (/^"(?:\\.|[^"\\])*"$/.test(pathField)) { + return unquoteGitPath(pathField); + } + const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/); if (braceRenameMatch) { const [, prefix, , renamed, suffix] = braceRenameMatch; - return `${prefix}${renamed}${suffix}`; + return unquoteGitPath(`${prefix}${renamed}${suffix}`); } const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/); if (inlineRenameMatch) { - return inlineRenameMatch[2] ?? pathField; + return unquoteGitPath(inlineRenameMatch[2] ?? pathField); } - return pathField; + return unquoteGitPath(pathField); } +// Parsed patches need a stable wire format regardless of terminal presentation settings. +const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"]; + function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] { - return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra]; + return [ + "diff", + ...GIT_PATCH_FORMAT_ARGS, + ...(args.ignoreWhitespace ? ["-w"] : []), + ...args.extra, + ]; } const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB @@ -660,7 +667,7 @@ async function getTrackedNumstatByPath( const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace, - extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)], + extra: ["-z", "--numstat", ...getCheckoutDiffRefArgs(refs)], }), { cwd, @@ -671,21 +678,21 @@ async function getTrackedNumstatByPath( ); const stats = new Map<string, FileStat>(); - const lines = result.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - for (const line of lines) { - const parts = line.split("\t"); + const records = result.stdout.split("\0"); + for (let index = 0; index < records.length; index += 1) { + const parts = records[index].split("\t"); if (parts.length < 3) { continue; } const additionsField = parts[0] ?? ""; const deletionsField = parts[1] ?? ""; - const rawPath = parts.slice(2).join("\t"); - const path = normalizeNumstatPath(rawPath); + let path = parts.slice(2).join("\t"); + if (!path) { + // A rename has an empty path here, followed by separate old/new NUL fields. + path = records[index + 2]; + index += 2; + } if (!path) { continue; @@ -713,12 +720,19 @@ async function getTrackedDiffTextForPath(input: { cwd: string; refsForDiff: CheckoutDiffRefs; path: string; + oldPath?: string; ignoreWhitespace: boolean; }): Promise<{ path: string; text: string; truncated: boolean }> { + const paths = input.oldPath ? [input.oldPath, input.path] : [input.path]; const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace: input.ignoreWhitespace, - extra: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", `:(literal)${input.path}`], + extra: [ + ...(input.oldPath ? ["--diff-filter=RC"] : []), + ...getCheckoutDiffRefArgs(input.refsForDiff), + "--", + ...paths.map((path) => `:(literal)${path}`), + ], }), { cwd: input.cwd, @@ -727,9 +741,22 @@ async function getTrackedDiffTextForPath(input: { }, ); + // A source path can participate in another rename/copy in the same request. + // Return only the requested destination; the filter keeps source edits out of its budget. + let text = result.stdout; + if (input.oldPath && !result.truncated) { + text = result.stdout + .split(/(?=^diff --git )/m) + .filter((section) => { + const hunkStart = section.indexOf("\n@@ "); + const header = hunkStart < 0 ? section : section.slice(0, hunkStart); + return parseDiff(header)[0]?.path === input.path; + }) + .join(""); + } return { path: input.path, - text: result.stdout, + text, truncated: result.truncated, }; } @@ -1159,13 +1186,16 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] { async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> { try { - const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - const entries = parseWorktreeList(stdout); const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`; - return entries.find((entry) => entry.branchRef === ref)?.path ?? null; + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname)%00%(worktreepath)%00", ref], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + for (const record of stdout.split("\0\n")) { + const [reference, worktreePath] = record.split("\0"); + if (reference === ref) return worktreePath || null; + } + return null; } catch { return null; } @@ -1560,11 +1590,15 @@ export async function resolveRepositoryDefaultBranch( ); return localName; } catch { + await getRunGitCommand(context)(["show-ref", "--verify", "--quiet", ref], { + cwd: repoRoot, + envOverlay: READ_ONLY_GIT_ENV, + }); return remoteShort; } } } catch { - // ignore + // A stale origin/HEAD must fall through to the available local branches. } const { stdout } = await getRunGitCommand(context)(["branch", "--format=%(refname:short)"], { @@ -2117,13 +2151,13 @@ export async function getCheckoutSnapshotFacts( const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB -const RELAY_MAX_FRAME_BYTES = 32 * 1024 * 1024; const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024; // Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is // binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for // the surrounding WebSocket JSON envelope after inverting that exact wire expansion. export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES = - maxBase64EncryptedPlaintextByteLength(RELAY_MAX_FRAME_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; + maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - + CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; interface StructuredDiffAccumulator { files: ParsedDiffFile[]; @@ -2388,7 +2422,7 @@ function parseRawStatusLine(line: string, statuses: Map<string, CheckoutCommitFi if (!path) { return; } - statuses.set(path, status); + statuses.set(unquoteGitPath(path), status); } // A `--numstat` line: `<adds>\t<dels>\t<path>` (renames use `old => new`, binary @@ -2609,6 +2643,60 @@ export async function listCheckoutCommits({ return { baseRef: comparisonBaseRef, commits }; } +const commitRenamesCache = new TTLCache<string, Promise<ReadonlyMap<string, string>>>({ + max: 100, + ttl: 60_000, + checkAgeOnGet: true, +}); + +function getCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<string, string>> { + // Symbolic refs and abbreviated IDs can resolve to a different commit later. + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) { + return readCommitRenames(cwd, sha); + } + const key = JSON.stringify([cwd, sha]); + const cached = commitRenamesCache.get(key); + if (cached) { + return cached; + } + const lookup = readCommitRenames(cwd, sha).catch((error: unknown) => { + if (commitRenamesCache.get(key) === lookup) { + commitRenamesCache.delete(key); + } + throw error; + }); + // Store the pending lookup too, so concurrent file requests share the scan. + commitRenamesCache.set(key, lookup); + return lookup; +} + +// Discover renames across the commit before narrowing the patch to a file. +// --follow also detects copies, which would disagree with history's -M-only list. +async function readCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<string, string>> { + const result = await runGitCommand( + [ + "show", + sha, + "--format=", + "--diff-merges=first-parent", + "--name-status", + "-z", + "-M", + "--diff-filter=R", + ], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + if (result.truncated) { + throw new Error("Commit renames exceeded the git output limit"); + } + const renames = new Map<string, string>(); + const fields = result.stdout.split("\0"); + for (let index = 0; index + 2 < fields.length; index += 3) { + renames.set(fields[index + 2]!, fields[index + 1]!); + } + return renames; +} + /** * Fetches the unified diff of a single file as introduced by one commit and * parses it into the same {@link ParsedDiffFile} shape the diff subscription @@ -2630,10 +2718,23 @@ export async function getCommitFileDiff({ sha: string; path: string; }): Promise<ParsedDiffFile | null> { + const worktreeRoot = await requireGitWorktreeRoot(cwd); + const renames = await getCommitRenames(worktreeRoot, sha); + const oldPath = renames.get(path); + const paths = oldPath === undefined ? [path] : [oldPath, path]; const { stdout } = await runGitCommand( - ["show", sha, "--format=", "--diff-merges=first-parent", "--", path], + [ + "show", + sha, + "--format=", + "--diff-merges=first-parent", + "-M", + ...GIT_PATCH_FORMAT_ARGS, + "--", + ...paths.map((filePath) => `:(literal)${filePath}`), + ], { - cwd, + cwd: worktreeRoot, envOverlay: READ_ONLY_GIT_ENV, }, ); @@ -2642,13 +2743,13 @@ export async function getCommitFileDiff({ return null; } - const parsedFiles = await parseAndHighlightDiff(stdout, cwd, { - getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path), - getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path), + const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, { + getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path), + getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path), }); - // `--` scopes the diff to a single pathspec, so there is at most one real - // entry. Pick by path to drop any stray header-only section the parser emits. + // Including the rename source lets Git pair it with the destination. Select + // the requested entry if another change also matches the source path. const file = parsedFiles.find((candidate) => candidate.path === path) ?? null; if (!file) { return null; @@ -2701,16 +2802,13 @@ async function countUntrackedAdditions( ): Promise<number> { try { const { stdout } = await getRunGitCommand(context)( - ["ls-files", "--others", "--exclude-standard"], + ["ls-files", "-z", "--others", "--exclude-standard"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }, ); - const files = stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); + const files = stdout.split("\0").filter(Boolean); let additions = 0; for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) { @@ -3302,26 +3400,29 @@ async function processTrackedChanges( let trackedDiffText = ""; let trackedDiffBytes = 0; - const renamedPaths = new Set( - trackedChanges.filter((change) => change.oldPath).map((change) => change.path), + const oldPathByPath = new Map( + trackedChanges + .filter((change) => change.oldPath) + .map((change) => [change.path, change.oldPath]), ); for (let start = 0; start < trackedDiffPaths.length; start += TRACKED_DIFF_BATCH_SIZE) { const paths = trackedDiffPaths.slice(start, start + TRACKED_DIFF_BATCH_SIZE); - // Keep rename/copy path filtering identical to the existing per-file command. + // Rename/copy detection needs both paths. const ordinary = await getTrackedDiffTexts({ cwd, refsForDiff, - paths: paths.filter((path) => !renamedPaths.has(path)), + paths: paths.filter((path) => !oldPathByPath.has(path)), ignoreWhitespace, }); const renamed = await Promise.all( paths - .filter((path) => renamedPaths.has(path)) + .filter((path) => oldPathByPath.has(path)) .map((path) => getTrackedDiffTextForPath({ cwd, refsForDiff, path, + oldPath: oldPathByPath.get(path), ignoreWhitespace, }), ), @@ -3388,7 +3489,7 @@ export async function getCheckoutDiff( compare: CheckoutDiffCompare, context?: CheckoutContext, ): Promise<CheckoutDiffResult> { - await requireGitRepo(cwd); + cwd = await requireGitWorktreeRoot(cwd); const refsForDiff = await resolveCheckoutDiffRefs(cwd, compare, context); if (!refsForDiff) { @@ -3518,13 +3619,13 @@ export async function commitAll(cwd: string, message: string): Promise<void> { } export async function discardChanges(cwd: string, pathspecs: string[]): Promise<void> { - await requireGitRepo(cwd); + const worktreeRoot = await requireGitWorktreeRoot(cwd); if (pathspecs.length === 0) { return; } try { await runGitCommand(["--literal-pathspecs", "reset", "-q", "HEAD", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } catch { @@ -3532,7 +3633,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< await runGitCommand( ["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }, ); @@ -3544,7 +3645,7 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< const status = await runGitCommand( ["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--", ...pathspecs], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }, ); @@ -3570,13 +3671,13 @@ export async function discardChanges(cwd: string, pathspecs: string[]): Promise< } if (tracked.length > 0) { await runGitCommand(["--literal-pathspecs", "checkout", "-q", "--", ...tracked], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } if (untracked.length > 0) { await runGitCommand(["--literal-pathspecs", "clean", "-fd", "-q", "--", ...untracked], { - cwd, + cwd: worktreeRoot, timeout: DISCARD_CHANGES_TIMEOUT_MS, }); } diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index c7d4126af1..711a721e48 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -1,3 +1,4 @@ +import { createHook } from "node:async_hooks"; import { execFileSync } from "node:child_process"; import { mkdtempSync, @@ -696,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; @@ -1024,3 +1124,65 @@ describe("home-tree scan cost", () => { }, ); }); + +// Every pending fs call holds one of libuv's shared threadpool threads (4 by default). A read that +// never returns, such as one on a hung network mount, keeps its thread, so searches that pile up +// while the user types must not be able to take every thread from the rest of the daemon. +describe("filesystem threads held by concurrent searches", () => { + const SEARCHES = 8; + let scanRoot: string; + + beforeEach(() => { + scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-threads-"))); + for (let branch = 0; branch < 20; branch += 1) { + mkdirSync(path.join(scanRoot, `b${branch}`, "inner", "leaf"), { recursive: true }); + } + }); + + afterEach(() => { + rmSync(scanRoot, { recursive: true, force: true }); + }); + + async function peakPendingFilesystemRequests(run: () => Promise<unknown>): Promise<number> { + const pending = new Set<number>(); + let peak = 0; + const hook = createHook({ + init(asyncId, type) { + if (type !== "FSREQPROMISE" && type !== "FSREQCALLBACK") return; + pending.add(asyncId); + peak = Math.max(peak, pending.size); + }, + before(asyncId) { + pending.delete(asyncId); + }, + }); + hook.enable(); + try { + await run(); + } finally { + hook.disable(); + } + return peak; + } + + function searchWhileTyping() { + const queries = Array.from({ length: SEARCHES }, (_, index) => `nomatch${index}`); + return Promise.all( + queries.map((query) => searchAbsoluteDirectoryPaths({ homeDir: scanRoot, query })), + ); + } + + it("leaves threadpool threads free for the rest of the daemon", async () => { + const peak = await peakPendingFilesystemRequests(searchWhileTyping); + + expect(peak).toBeLessThanOrEqual(2); + }); + + // Searches over one tree walk it in the same order, so searches stuck on one unreadable folder + // must hold one thread between them and leave the other for unrelated searches. + it("shares one filesystem request among searches waiting on the same path", async () => { + const peak = await peakPendingFilesystemRequests(searchWhileTyping); + + expect(peak).toBe(1); + }); +}); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 35e8f5154b..e0443475bc 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -1,6 +1,7 @@ import type { Dirent, Stats } from "node:fs"; -import { readdir, realpath, stat } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import path from "node:path"; +import pLimit from "p-limit"; import { scorePathMatch, type MatchScore } from "@getpaseo/protocol/search/text-match"; import { isPathInsideRoot } from "./path.js"; import { runGitCommand } from "./run-git-command.js"; @@ -32,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 { @@ -54,6 +58,11 @@ interface RawChildEntry { kind: DirectorySuggestionKind | "symlink"; } +interface WalkStart { + visiblePath: string; + resolvedPath: string; +} + interface TraversedEntry extends ChildEntry { visiblePath: string; depth: number; @@ -102,10 +111,42 @@ const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER; const NO_MATCH_TIER = 5; + +// Each pending fs call holds one of libuv's shared threadpool threads (4 by default), and a read on +// a hung mount or a protected folder never returns. Searches pile up while the user types, so they +// share two threads and leave the rest of the pool to the daemon. Searches over one tree walk it +// in the same order and stop at the same stuck folder, so they share its pending call and hold one +// thread between them instead of both. +const filesystemCalls = pLimit(2); +const pendingFilesystemCalls = new Map<string, Promise<unknown>>(); + +function sharedFilesystemCall<T>(key: string, call: () => Promise<T>): Promise<T> { + const pending = pendingFilesystemCalls.get(key); + if (pending) return pending as Promise<T>; + const started = filesystemCalls(call).finally(() => pendingFilesystemCalls.delete(key)); + pendingFilesystemCalls.set(key, started); + return started; +} + +function readdir(directory: string): Promise<Dirent[]> { + return sharedFilesystemCall(`readdir:${directory}`, () => + fs.readdir(directory, { withFileTypes: true }), + ); +} + +function realpath(target: string): Promise<string> { + return sharedFilesystemCall(`realpath:${target}`, () => fs.realpath(target)); +} + +function stat(target: string): Promise<Stats> { + return sharedFilesystemCall(`stat:${target}`, () => fs.stat(target)); +} + export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ ".agents", ".claude", ".codex", + ".cursor", ".github", ".opencode", ".paseo", @@ -142,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; @@ -186,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), @@ -193,6 +270,7 @@ function buildSearchInput( maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED, confidentResultScanThreshold: options.confidentResultScanThreshold, gitIgnoredPaths, + excludedDiscoveryPaths, }; } @@ -220,6 +298,7 @@ interface SearchInput { includeDirectories: boolean; includeFiles: boolean; matchMode: DirectorySuggestionMatchMode; + pathQueryPolicy: PathQueryPolicy; pathFormat: DirectorySuggestionPathFormat; hiddenDirectoryNames: Set<string>; limit: number; @@ -227,6 +306,7 @@ interface SearchInput { maxEntriesScanned: number; confidentResultScanThreshold: number | undefined; gitIgnoredPaths: Set<string>; + excludedDiscoveryPaths: Set<string>; } async function searchChildren(input: SearchInput): Promise<RankedEntry[]> { @@ -234,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, @@ -247,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, ), @@ -338,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("."); } @@ -354,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)) { @@ -655,7 +758,7 @@ async function readChildren(directory: string): Promise<ChildEntry[]> { ) { rawEntries = cached.entries; } else { - const dirents = await readdir(directory, { withFileTypes: true }).catch(() => [] as Dirent[]); + const dirents = await readdir(directory).catch(() => [] as Dirent[]); rawEntries = dirents .map(toRawChildEntry) .filter((entry): entry is RawChildEntry => entry !== null) 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..82505dc800 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -10,11 +10,13 @@ import { type WorktreeConfig, } from "./worktree"; import { execFileSync } from "child_process"; +import { rm } from "node:fs/promises"; import { mkdtempSync, mkdirSync, rmSync, existsSync, + readFileSync, realpathSync, symlinkSync, writeFileSync, @@ -89,10 +91,68 @@ describe("paseo worktree manager", () => { }); }); - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); + it.each([ + { existing: [], preferred: "main/patch-1", expected: "main-1/patch-1" }, + { existing: ["main-1"], preferred: "main/patch-1", expected: "main-2/patch-1" }, + { + existing: ["main-1/patch-1"], + preferred: "main/patch-1", + expected: "main-1/patch-1-2", + }, + { + existing: ["bob/patch-1/child"], + preferred: "bob/patch-1", + expected: "bob/patch-1-1", + }, + ])( + "checks out a fork when $preferred conflicts with existing branch paths", + async ({ existing, preferred, expected }) => { + const git = (...args: string[]) => + execFileSync("git", args, { cwd: repoDir, encoding: "utf8", stdio: "pipe" }).trim(); + git("config", "core.autocrlf", "false"); + const baseHead = git("rev-parse", "main"); + for (const branch of existing) git("branch", branch); + git("checkout", "-b", "fixture-pr"); + writeFileSync(join(repoDir, "file.txt"), "from-pr\n"); + git("add", "file.txt"); + git("-c", "commit.gpgsign=false", "commit", "-m", "fork patch"); + const prHead = git("rev-parse", "HEAD"); + git("checkout", "main"); + const remote = join(tempDir, "origin.git"); + git("clone", "--bare", repoDir, remote); + git("remote", "add", "origin", remote); + git(`--git-dir=${remote}`, "update-ref", "refs/pull/1/head", prHead); + git("pack-refs", "--all"); + + const result = await createWorktreePrimitive({ + cwd: repoDir, + paseoHome, + worktreeSlug: "fork-review", + runSetup: false, + source: { + kind: "checkout-change-request", + forge: "gitea", + changeRequestNumber: 1, + headRef: "patch-1", + localBranchName: preferred, + baseRefName: "main", + checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1/head" }], + }, + }); + + expect(result.branchName).toBe(expected); + expect(git("rev-parse", result.branchName)).toBe(prHead); + expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n"); + for (const branch of ["main", ...existing]) { + expect(git("rev-parse", branch)).toBe(baseHead); + } + }, + ); + it("treats a worktree as paseo-owned even when its .git admin is missing", async () => { const created = await createLegacyWorktreeForTest({ branchName: "orphan-admin-branch", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index f7f28205e0..39b64d8137 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -39,6 +39,7 @@ import { writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; import { runGitCommand } from "./run-git-command.js"; +import { getCurrentBranch, resolveRepositoryDefaultBranch } from "./checkout-git.js"; import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; @@ -1279,10 +1280,7 @@ export const createWorktree = async ({ return { branchName: sourcePlan.branchName, worktreePath, - comparisonBaseRef: - source.kind === "checkout-branch" - ? null - : (sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName), + comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName, }; }; @@ -1399,11 +1397,12 @@ async function resolveWorktreeSourcePlan({ case "checkout-branch": { await validateGitBranchName(cwd, source.branchName); await ensureLocalBranch(cwd, source.branchName); + const baseRef = await resolveCheckoutBranchBaseRef(cwd, source.branchName); if (await isBranchCheckedOut(cwd, source.branchName)) { const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName); return { branchName, - metadataBaseRefName: source.branchName, + metadataBaseRefName: baseRef, changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ headRef: branchName, localBranchName: branchName, @@ -1414,7 +1413,7 @@ async function resolveWorktreeSourcePlan({ return { branchName: source.branchName, - metadataBaseRefName: source.branchName, + metadataBaseRefName: baseRef, changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ headRef: source.branchName, localBranchName: source.branchName, @@ -1669,6 +1668,21 @@ function normalizeRequiredBaseBranch(baseBranch: string): string { return normalizedBaseBranch; } +// The checked-out branch itself isn't a base to diff against — resolve the +// repository's default branch so the diff panel has a real comparison target. +// If the default branch can't be determined (no origin/HEAD, no local +// main/master — e.g. a local-only repo based on "develop"), fall back to +// whichever branch is currently checked out in the main repo, since that's +// almost always the branch this one was intended to be compared against. +async function resolveCheckoutBranchBaseRef(cwd: string, branchName: string): Promise<string> { + const defaultBranch = await resolveRepositoryDefaultBranch(cwd); + if (defaultBranch) { + return defaultBranch; + } + const currentBranch = await getCurrentBranch(cwd); + return currentBranch && currentBranch !== branchName ? currentBranch : branchName; +} + async function resolveBaseBranchForWorktree( cwd: string, requestedBaseBranch: string, @@ -1764,13 +1778,30 @@ async function localBranchExists(cwd: string, branchName: string): Promise<boole } async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise<string> { - let newBranchName = candidateBranch; + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname:strip=2)", "refs/heads/"], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + const existingBranches = new Set(stdout.trim().split("\n").filter(Boolean)); + const candidateSegments = candidateBranch.split("/"); + const segments = [...candidateSegments]; let suffix = 1; - while (await localBranchExists(cwd, newBranchName)) { - newBranchName = `${candidateBranch}-${suffix}`; - suffix++; + while (true) { + const branchName = segments.join("/"); + const parentConflict = segments.findIndex((_, index) => + existingBranches.has(segments.slice(0, index + 1).join("/")), + ); + const childConflict = [...existingBranches].some((branch) => + branch.startsWith(`${branchName}/`), + ); + if (parentConflict === -1 && !childConflict) { + return branchName; + } + // Git refs form a namespace: a branch cannot also be another branch's + // parent directory. Rename the conflicting segment, preserving the rest. + const conflictIndex = parentConflict === -1 ? segments.length - 1 : parentConflict; + segments[conflictIndex] = `${candidateSegments[conflictIndex]}-${suffix++}`; } - return newBranchName; } async function isBranchCheckedOut(cwd: string, branchName: string): Promise<boolean> { diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 4b25764445..c80345c79e 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -19,9 +19,9 @@ export default defineConfig({ setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")], pool: "forks", fileParallelism: false, - // Windows runners intermittently starve subprocess-heavy Git tests at the - // default worker count, leaving child processes alive past their deadlines. - maxWorkers: process.platform === "win32" ? 2 : undefined, + // Even two concurrent Git-heavy suites can exhaust Windows test deadlines. + // Run files serially so their subprocesses do not compete across suites. + maxWorkers: process.platform === "win32" ? 1 : undefined, exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"], }, }); diff --git a/packages/website/README.md b/packages/website/README.md new file mode 100644 index 0000000000..5a938ca2f4 --- /dev/null +++ b/packages/website/README.md @@ -0,0 +1,22 @@ +# Paseo website + +## Plugin install counts + +Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or +`?intent=install`, using `/api/plugins/resolve/<owner>/<slug>` or +`/plugins/<owner>/<slug>.json`. Ordinary resolves do not count. These numbers +measure install intent, not successful artifact installation or npm downloads. + +The worker requires Cloudflare's `CF-Connecting-IP` header to count a report. +It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key +with a one hour TTL, never the raw IP. An existing key skips the count; repeats +do not extend the hour. Missing or empty IP headers do not count. + +Accepted reports increment the existing all-time total and UTC daily buckets in +`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day +and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped +to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts) +reads that endpoint into the published plugin details and index; the website +reads windowed counts from KV through its five-minute cache. Existing counts +remain intact. The KV guard is advisory: concurrent requests and KV propagation +are not serialized. diff --git a/packages/website/e2e/overview.fixture.json b/packages/website/e2e/overview.fixture.json new file mode 100644 index 0000000000..2553fe83c6 --- /dev/null +++ b/packages/website/e2e/overview.fixture.json @@ -0,0 +1,32 @@ +{ + "id": "security/overview", + "name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">", + "description": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">", + "artifact": { + "kind": "npm", + "package": "@alhassanaraouf/paseo-base2tone-theme", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz", + "integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg==" + }, + "license": "MIT", + "repository": { + "url": "javascript:alert(1)" + }, + "categories": ["themes"], + "author": { + "github": "security", + "name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">" + }, + "media": [ + "data:image/svg+xml,<svg onload=\"globalThis.__overviewExecuted=1\"/>", + "http://example.com/image.png" + ], + "submittedAt": "2026-09-24T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-24T17:12:05.467Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "## Overview security\n\n</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">", + "icon": "data:image/png;base64,AAAA" +} diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts new file mode 100644 index 0000000000..6eb9f7d816 --- /dev/null +++ b/packages/website/e2e/plugins.spec.ts @@ -0,0 +1,782 @@ +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) { + await page.goto("/plugins"); + 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, +}) => { + // WebKit has no clipboard permission to grant; record what the page writes instead. + await context.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => { + (window as unknown as { __copied?: string }).__copied = text; + return Promise.resolve(); + }, + }, + }); + }); + await openPlugins(page); + + await page.getByRole("region", { name: "Categories" }).getByRole("link", { name: /Git/ }).click(); + await expect(page).toHaveURL(/\/plugins\/category\/git$/); + await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + 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 add omercnet/fresh-worktrees")).toHaveCount(1); + await expect( + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Copy to clipboard" }).click(); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied)) + .toBe("paseo plugin 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$/); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Agent Monitor/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0); +}); + +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( + "aria-current", + "page", + ); + await page.getByRole("link", { name: "This month" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?window=month$/); + await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( + "aria-current", + "true", + ); + + await page.getByRole("link", { name: "Newest" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=new$/); + await expect(page.getByRole("navigation", { name: "Time window" })).toHaveCount(0); + 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 submitSearch(page, "graphite"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), + ).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + await searchPlugins(page, "zzzz-nothing"); + await expect(page.getByText("No plugins match.")).toBeVisible(); + await page.getByRole("link", { name: "Clear filters" }).click(); + 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(""); +}); + +test("keeps the directory's ranking window when searching", async ({ page }) => { + await page.goto("/plugins?window=month"); + 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", + "true", + ); +}); + +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(/\/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 }) => { + await page.goto("/plugins?category=git&sort=new"); + await expect(page).toHaveURL(/\/plugins\/category\/git\?sort=new$/); + await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible(); +}); + +test("explains a plugin that is not listed", async ({ page }) => { + await page.goto("/plugins/acme/does-not-exist"); + await expect(page.getByRole("heading", { level: 1, name: "Plugin not found" })).toBeVisible(); + await page.getByRole("link", { name: "Browse all plugins" }).click(); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +}); + +test.describe("search engine visits without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("reads directory, category, author, and complete plugin HTML with page metadata", async ({ + page, + }) => { + await openPlugins(page); + await expect(page.getByRole("link", { name: /Base2Tone/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Plugins – Extend Paseo with community plugins", "/plugins"); + + await page.goto("/plugins/category/git"); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible(); + await expectPageMetadata(page, "Git – Paseo plugins", "/plugins/category/git"); + + await page.goto("/plugins/omercnet"); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Omer Cohen – Paseo plugins", "/plugins/omercnet"); + + const response = await page.goto("/plugins/omercnet/fresh-worktrees"); + 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 add omercnet/fresh-worktrees")).toBeVisible(); + await expect( + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), + ).toBeVisible(); + await expectPageMetadata( + page, + "Fresh Worktrees – Paseo plugin", + "/plugins/omercnet/fresh-worktrees", + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", + ); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + "Fast-forwards clean local base branches before Paseo creates branch-off worktrees", + ); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + + await page.goto("/plugins/tomgrin10/graphite"); + await expectPageMetadata(page, "Graphite – Paseo plugin", "/plugins/tomgrin10/graphite"); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://paseo.sh/og-image.png", + ); + }); + + test("renders search results and submits the search form", async ({ page }) => { + await page.goto("/plugins/all?q=graphite"); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), + ).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + await page.goto("/plugins/category/git"); + await searchPlugins(page, "fresh"); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/category\/git\?q=fresh$/); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “fresh” in Git/ }), + ).toBeVisible(); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible(); + await page.getByRole("link", { name: "Clear", exact: true }).click(); + await expect(page).toHaveURL(/\/plugins\/category\/git$/); + }); + + test("returns real 404 pages for unknown plugins, authors, and categories", async ({ page }) => { + const plugin = await page.goto("/plugins/acme/does-not-exist"); + expect(plugin?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible(); + await expect(page).toHaveTitle("Plugin not found – Paseo"); + const author = await page.goto("/plugins/does-not-exist"); + expect(author?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Author not found" })).toBeVisible(); + await expect(page).toHaveTitle("Author not found – Paseo"); + const category = await page.goto("/plugins/category/does-not-exist"); + expect(category?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Category not found" })).toBeVisible(); + await expect(page).toHaveTitle("Category not found – Paseo"); + }); + + test("redirects old category links permanently", async ({ request }) => { + const response = await request.get("/plugins?category=git", { maxRedirects: 0 }); + expect(response.status()).toBe(301); + expect(response.headers().location).toMatch(/\/plugins\/category\/git$/); + const search = await request.get("/plugins?q=graphite", { maxRedirects: 0 }); + expect(search.status()).toBe(301); + expect(search.headers().location).toMatch(/\/plugins\/all\?q=graphite$/); + const windowed = await request.get("/plugins?q=graphite&window=month", { maxRedirects: 0 }); + expect(windowed.headers().location).toMatch(/\/plugins\/all\?q=graphite&window=month$/); + }); + + test("discovers plugin, category, and author URLs through robots and the sitemap index", async ({ + request, + }) => { + const robots = await request.get("/robots.txt"); + expect(await robots.text()).toContain("Sitemap: https://paseo.sh/sitemap-index.xml"); + const index = await request.get("/sitemap-index.xml"); + expect(await index.text()).toContain("https://paseo.sh/sitemap-plugins.xml"); + const plugins = await request.get("/sitemap-plugins.xml"); + expect(plugins.status()).toBe(200); + expect(plugins.headers()["content-type"]).toContain("application/xml"); + const sitemap = await plugins.text(); + expect(sitemap).toContain("<loc>https://paseo.sh/plugins/all</loc>"); + expect(sitemap).toContain("<loc>https://paseo.sh/plugins/category/git</loc>"); + expect(sitemap).toContain("<loc>https://paseo.sh/plugins/omercnet</loc>"); + expect(sitemap).toContain("<loc>https://paseo.sh/plugins/omercnet/fresh-worktrees</loc>"); + }); +}); + +/** 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( + "href", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute("content", title); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute( + "content", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:type"]')).toHaveAttribute("content", "website"); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute( + "content", + "summary_large_image", + ); + const description = await page.locator('meta[name="description"]').getAttribute("content"); + expect(description).toBeTruthy(); + await expect(page.locator('meta[property="og:description"]')).toHaveAttribute( + "content", + description!, + ); +} + +test("links the directory from the site navigation", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("navigation").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveAttribute("href", "/plugins"); + await expect( + page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveAttribute("href", "/plugins"); + await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute( + "href", + "/plugins", + ); + const response = await page.goto("/plugins"); + expect(response?.status()).toBe(200); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +}); + +// External deployments own their registry contents; these assertions use the local fixture. +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, + }) => { + await openPlugins(page); + const categories = page.getByRole("region", { name: "Categories" }); + await expect(categories.getByRole("link")).toHaveText( + CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)), + ); + await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0"); + 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 new file mode 100644 index 0000000000..228f3dd859 --- /dev/null +++ b/packages/website/e2e/registry-server.mjs @@ -0,0 +1,25 @@ +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"); + if (request.url === "/index.json") { + response.end(JSON.stringify(registry)); + return; + } + 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 new file mode 100644 index 0000000000..4ad4eabf1b --- /dev/null +++ b/packages/website/e2e/registry.fixture.json @@ -0,0 +1,638 @@ +{ + "schemaVersion": 1, + "registry": { + "name": "Paseo plugins", + "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", + "label": "Daemon management", + "description": "Manage hosts, daemon jobs, resources, approvals, and MCP servers." + }, + { + "slug": "themes", + "label": "Themes", + "description": "Change the app color scheme." + }, + { + "slug": "providers", + "label": "Providers", + "description": "Connect coding agents, ACP servers, and model routers, with account and quota fallback." + }, + { + "slug": "orchestration", + "label": "Orchestration", + "description": "Coordinate agents, delegate work, and send messages across hosts." + }, + { + "slug": "git", + "label": "Git", + "description": "Work with branches, worktrees, pull requests, stacks, diffs, and reviews." + }, + { + "slug": "workspaces", + "label": "Workspaces", + "description": "Add workspace panels and tabs for boards, browsers, documents, and terminals." + }, + { + "slug": "sidebar", + "label": "Sidebar", + "description": "Add sidebar items and panels for usage, notifications, pricing, and links." + }, + { + "slug": "extras", + "label": "Extras", + "description": "Add presence, pets, puzzles, wellbeing reminders, and demos." + }, + { + "slug": "utils", + "label": "Utils", + "description": "Extend the composer and timeline with commands, prompts, skills, and small helpers." + } + ], + "plugins": [ + { + "id": "alhassanaraouf/base2tone", + "name": "Base2Tone", + "description": "Base2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md.", + "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": "https://github.com/alhassanaraouf/paseo-themes/tree/HEAD/packages/paseo-base2tone-theme" + }, + "categories": ["themes"], + "author": { + "npm": "alhassanaraouf", + "name": "alhassanaraouf", + "github": "alhassanaraouf" + }, + "media": [ + "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" + ], + "submittedAt": "2026-09-24T00: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." + }, + { + "id": "dorasto/sayr", + "name": "Sayr", + "description": "A paseo plugin for viewing and managing your Sayr tasks", + "artifact": { + "kind": "npm", + "package": "@sayrio/paseo-plugin", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sayrio/paseo-plugin/-/paseo-plugin-0.2.0.tgz", + "integrity": "sha512-LPmVkEPWl8Km6US4zQAzhqSbGm7lBDwCjgNoysOhCsDiRy0jbtDKYRg1XVgwwUkoEwhF1EdZHCPwIuT8IizyWQ==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/dorasto/sayr/tree/HEAD/packages/paseo-plugin", + "commit": "e70919b89df3cf9e95c18242939193a79c7593b1" + }, + "categories": ["workspaces"], + "author": { + "npm": "trent-001", + "name": "Tom Lundy", + "github": "dorasto" + }, + "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-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" + }, + { + "id": "gpambrozio/github-board", + "name": "GitHub Board", + "description": "Paseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-github-board", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-github-board/-/paseo-github-board-0.10.0.tgz", + "integrity": "sha512-G6LxSsbt4Kt36yWEB2rXUxOwNLFsA4DbF20ViroVqaicMPjaw5H4JUWXjzEZAxWJT+0DlH12/TtqN4I0sNoQtg==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/github-board", + "commit": "530a66c592e20915169b5e67a35de8793f71ec60" + }, + "categories": ["git"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "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-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" + }, + { + "id": "gpambrozio/herald", + "name": "Herald", + "description": "Paseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-herald", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-herald/-/paseo-herald-0.5.1.tgz", + "integrity": "sha512-rXdlaxgRlYP3nmZrvbG29sQeluz5JC7TPuJDgKzyh9itDH1AvZYGjLgGp9XZGKxdBBhaGxgGAFhDh+VTL8h9dw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/herald", + "commit": "b658d0ab58735959e46d12660ae179c002cdadda" + }, + "categories": ["sidebar"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "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-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" + }, + { + "id": "gpambrozio/launchd-jobs", + "name": "launchd Jobs", + "description": "Paseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-launchd-jobs", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-launchd-jobs/-/paseo-launchd-jobs-0.5.0.tgz", + "integrity": "sha512-H6CQnalMdyQjW9ta9ZT40qEPJsREgNFSEAH8EC4sJXdz2Hz9tIsInSUmm2bDcgfY3D3HFffUrwf1mmeevi/wUA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/launchd-jobs", + "commit": "08e75003b287208ef804f3d31b2fda2597f69dce" + }, + "categories": ["daemon-management"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "media": [ + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4" + ], + "submittedAt": "2026-09-19T00: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" + }, + { + "id": "gpambrozio/model-pricing", + "name": "Model Pricing", + "description": "Paseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-model-pricing", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-model-pricing/-/paseo-model-pricing-0.3.0.tgz", + "integrity": "sha512-jLfC6iCLOwNACiili8nw3/qmBymoj6YHitMeGCa2RggiMdUhT6n+INRsSjoTs2kNzeYMftMfQ3MZJXeLLF1opA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/model-pricing", + "commit": "08e75003b287208ef804f3d31b2fda2597f69dce" + }, + "categories": ["sidebar"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "media": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" + ], + "submittedAt": "2026-09-19T00: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" + }, + { + "id": "gpambrozio/skills", + "name": "Skills", + "description": "Paseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-skills", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-skills/-/paseo-skills-0.4.0.tgz", + "integrity": "sha512-zMgP2U/vrRsLkKhoQICkN52+YoDy2WkkYymEDmruduKxf4Dq99Ct8Pk3xqJyAzijq2oprMFYLbj/wT6XepBLeA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/skills", + "commit": "d04187ee60ca3702ca5843803567600427102d99" + }, + "categories": ["utils"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "media": [], + "submittedAt": "2026-09-19T00: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" + }, + { + "id": "hungcuong9125/prompt-kit", + "name": "PromptKit", + "description": "PromptKit - rewrite and transform prompts directly from the Paseo composer.", + "artifact": { + "kind": "npm", + "package": "paseo-prompt-kit", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/paseo-prompt-kit/-/paseo-prompt-kit-0.6.0.tgz", + "integrity": "sha512-eiagOY1OokDx2iihfe3Vjh2dgKKP6IsBKbJYbfhM+TXw3aU7duV/rs/TUnBSeeWAk0cEsVg2Jd+EkeaPM8IJdA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/hungcuong9125/paseo-prompt-kit" + }, + "categories": ["utils"], + "author": { + "npm": "hungcuong9125", + "name": "hungcuong9125", + "github": "hungcuong9125" + }, + "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-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." + }, + { + "id": "omercnet/agent-monitor", + "name": "Agent Monitor", + "description": "A Paseo plugin that provides a host-wide agent triage roster.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-agent-monitor", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-agent-monitor/-/paseo-agent-monitor-1.2.0.tgz", + "integrity": "sha512-dY7/3f49sPMVwb+Wr7iUsw4jCoQWRX2UF9Li+uU8yJdN0cj4BvTsz4GDiR3OsKk8zJ1/o01DSRi1e4uDRS3TmA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/agent-monitor", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["orchestration"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "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-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." + }, + { + "id": "omercnet/beads", + "name": "Beads", + "description": "A dependency-aware Beads work queue for each Paseo workspace.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-beads", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-beads/-/paseo-beads-1.2.0.tgz", + "integrity": "sha512-UkI1SNP73RUrW0ap4qOx2Jv1GCxkrw4HIMrWOFCS3FDoPg2jw06DqHC8Jf34psaFglxXKWLAOkfL2wEZJhlUFw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-beads", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["workspaces"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "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-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." + }, + { + "id": "omercnet/dracula", + "name": "Dracula", + "description": "Dracula Classic and Alucard Classic themes for Paseo.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-dracula", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-dracula/-/paseo-dracula-1.2.0.tgz", + "integrity": "sha512-I3yUiFIvaIe3jdvuQ1tZyngsfgukaUJwEQza2XYkEaB8yIA0pplZehqJLvqqmA403cjNXdVtsdW7umPtmnycqw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-dracula", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["themes"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "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-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." + }, + { + "id": "omercnet/fresh-worktrees", + "name": "Fresh Worktrees", + "description": "Fast-forwards clean local base branches before Paseo creates branch-off worktrees", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-fresh-worktrees", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-fresh-worktrees/-/paseo-fresh-worktrees-2.2.0.tgz", + "integrity": "sha512-WRarzdfTbrZ36JfzmvX9HfnIYh6Pp+GV9TkoeVwM7I6f1MAEZ4gzPEmT51lWuUyMGvwTs2HFB7LthCa+VYs7IA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/fresh-worktrees", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["git"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "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-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" + }, + { + "id": "omercnet/omp", + "name": "OMP", + "description": "Paseo integration for OMP, including its direct provider and workspace tooling.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-omp", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-omp/-/paseo-omp-1.2.0.tgz", + "integrity": "sha512-ueZede5D34hoNrdF4y6DBaomErA+hHcv+bcz0Sd6eArQKqF8L6ecKXdBe2tEZd3Ev8uQwxMHuiDQhMBx3q6gOw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-omp", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["providers"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "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-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." + }, + { + "id": "omercnet/pr-radar", + "name": "PR Radar", + "description": "A viewer-aware delivery queue for pull requests linked to Paseo workspaces.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-pr-radar", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-pr-radar/-/paseo-pr-radar-1.2.0.tgz", + "integrity": "sha512-sk+zuzTGZ5Vmri2toic3z3uChm4d6kEShSr/UjkqfOTeILrsZVDHUc+XKX/CtZIQazT/vWvbgZ115+KUiAonCw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/pr-radar", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["git"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [], + "submittedAt": "2026-09-17T00: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." + }, + { + "id": "omercnet/shared-browser", + "name": "Shared Browser", + "description": "One real Chromium session per Paseo workspace, shared live across every connected client.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-shared-browser", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-shared-browser/-/paseo-shared-browser-1.2.0.tgz", + "integrity": "sha512-ddvtkBSzHLFzyTRZFixO1y+QXOKW0of8Uo3PSkETRhW5xhd9ep0jqsJJF9JSfWLt1ouvVa5K8zFXic/EBzQ8lw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-shared-browser", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["workspaces"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "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-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." + }, + { + "id": "omercnet/tell-agent", + "name": "Tell Agent", + "description": "Send messages to agents in other workspaces on the same Paseo host.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-tell-agent", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-tell-agent/-/paseo-tell-agent-1.2.0.tgz", + "integrity": "sha512-4XqL1RK1x3TQLoRRKTkZfDxytJbUCEnu2em5pbPDpsGQ5Gv6VN1h07mBci9iW8s2FbNINohNcIBdUaAMTXdOHA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/tell-agent", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["orchestration"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [], + "submittedAt": "2026-09-17T00: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." + }, + { + "id": "tomgrin10/defer", + "name": "Defer", + "description": "Defer messages to Paseo coding agents until a chosen time or usage-window reset.", + "artifact": { + "kind": "npm", + "package": "paseo-defer", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/paseo-defer/-/paseo-defer-2.1.9.tgz", + "integrity": "sha512-xBBf4TUC8zkTh9WMzQRRDt0alRAYPRCmUdBnBI1ZEkatUFC4i9MxFHgaTY939xij99dASM/261IWqctcKsQppQ==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/tomgrin10/paseo-defer" + }, + "categories": ["utils"], + "author": { + "npm": "tomgrin10", + "name": "Tom Gringauz", + "github": "tomgrin10" + }, + "media": [ + "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" + ], + "submittedAt": "2026-09-22T00: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. <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", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/paseo-graphite/-/paseo-graphite-0.2.4.tgz", + "integrity": "sha512-aAFvh+118c5mmSQ3sTsDKO8Is7g1ZU8m2+0fisr8nFrgPMu0m2mokPJnM/LaIqQc3Zaaak1chB6M6k0dWOPeIA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/tomgrin10/paseo-graphite" + }, + "categories": ["git"], + "author": { + "npm": "tomgrin10", + "name": "Tom Gringauz", + "github": "tomgrin10" + }, + "media": [], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", + "installs": 0, + "readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo." + } + ] +} diff --git a/packages/website/e2e/sponsors.spec.ts b/packages/website/e2e/sponsors.spec.ts new file mode 100644 index 0000000000..7b42316d54 --- /dev/null +++ b/packages/website/e2e/sponsors.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "playwright/test"; + +const PAGES = ["/", "/docs/connectivity", "/changelog"]; + +for (const path of PAGES) { + test(`${path} shows the sponsors and invites the next one`, async ({ page }) => { + await page.goto(path); + + const sponsors = page.getByRole("region", { name: "Sponsors", exact: true }); + await expect(sponsors.getByRole("link", { name: "Hoplite" })).toHaveAttribute( + "href", + "https://hoplite.sh/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor", + ); + await expect(sponsors.getByText("Cloud coding agents", { exact: true })).toBeVisible(); + await expect(sponsors.getByRole("link", { name: "Become a sponsor" })).toHaveAttribute( + "href", + "/sponsor#spot", + ); + }); +} diff --git a/packages/website/package.json b/packages/website/package.json index 5f64191da9..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": { @@ -15,6 +15,7 @@ "@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", @@ -37,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/playwright.config.ts b/packages/website/playwright.config.ts index 011a34e6ad..a80b84abd8 100644 --- a/packages/website/playwright.config.ts +++ b/packages/website/playwright.config.ts @@ -12,9 +12,18 @@ export default defineConfig({ ], webServer: process.env.WEBSITE_TEST_URL ? undefined - : { - command: "npm run dev -- --host 127.0.0.1 --port 8187 --strictPort", - url: baseURL, - reuseExistingServer: !process.env.CI, - }, + : [ + { + command: "node e2e/registry-server.mjs", + url: "http://127.0.0.1:8188/index.json", + }, + { + command: "npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", + url: baseURL, + env: { + CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", + PLUGINS_REGISTRY_URL: "http://127.0.0.1:8188", + }, + }, + ], }); diff --git a/packages/website/public/robots.txt b/packages/website/public/robots.txt new file mode 100644 index 0000000000..75001ce745 --- /dev/null +++ b/packages/website/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://paseo.sh/sitemap-index.xml diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index c1cfbb4b6c..0ba1cdbc7f 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -1,188 +1,270 @@ { - "$ref": "#/definitions/PaseoConfigV1", - "definitions": { - "PaseoConfigV1": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "type": "number", + "const": 1 + }, + "daemon": { "type": "object", "properties": { - "$schema": { + "listen": { "type": "string" }, - "version": { - "type": "number", - "const": 1 + "hostnames": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, - "daemon": { + "allowedHosts": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "trustedProxies": { + "description": "Express trusted proxy setting for X-Forwarded-* headers. Defaults to [\"loopback\"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.", + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mcp": { "type": "object", "properties": { + "enabled": { + "type": "boolean" + }, + "injectIntoAgents": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "browserTools": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "git": { + "type": "object", + "properties": { + "maxProcessesPerSecond": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxProcessConcurrency": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "autoArchiveAfterMerge": { + "type": "boolean" + }, + "enableTerminalAgentHooks": { + "type": "boolean" + }, + "appendSystemPrompt": { + "type": "string" + }, + "terminalProfiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "type": "string" + } + }, + "required": ["id", "name", "command"], + "additionalProperties": {} + } + }, + "agentProfiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "color": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modeId": { + "type": "string" + }, + "thinkingOptionId": { + "type": "string" + }, + "featureValues": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "notes": { + "type": "string" + } + }, + "required": ["id", "name", "provider"], + "additionalProperties": {} + } + }, + "cors": { + "type": "object", + "properties": { + "allowedOrigins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "relay": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + }, + "publicEndpoint": { + "type": "string" + }, + "useTls": { + "type": "boolean" + }, + "publicUseTls": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "serviceProxy": { + "type": "object", + "properties": { + "enabled": { + "description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + "type": "boolean" + }, "listen": { + "description": "Optional service-only listener address. Presence enables the standalone service proxy listener.", "type": "string" }, - "hostnames": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "allowedHosts": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "trustedProxies": { - "description": "Express trusted proxy setting for X-Forwarded-* headers. Defaults to [\"loopback\"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.", - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "mcp": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "injectIntoAgents": { - "type": "boolean" - } - }, - "additionalProperties": true - }, - "git": { - "type": "object", - "properties": { - "maxProcessesPerSecond": { - "type": "integer", - "exclusiveMinimum": 0 - }, - "maxProcessConcurrency": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false - }, - "autoArchiveAfterMerge": { - "type": "boolean" - }, - "enableTerminalAgentHooks": { - "type": "boolean" - }, - "appendSystemPrompt": { - "type": "string" - }, - "cors": { - "type": "object", - "properties": { - "allowedOrigins": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "relay": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "endpoint": { - "type": "string" - }, - "publicEndpoint": { - "type": "string" - }, - "useTls": { - "type": "boolean" - }, - "publicUseTls": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "serviceProxy": { - "type": "object", - "properties": { - "enabled": { - "description": "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", - "type": "boolean" - }, - "listen": { - "description": "Optional service-only listener address. Presence enables the standalone service proxy listener.", - "type": "string" - }, - "publicBaseUrl": { - "description": "Optional public base URL for service host aliases.", - "type": "string", - "format": "uri" - } - }, - "additionalProperties": false - }, - "auth": { - "type": "object", - "properties": { - "password": { - "type": "string", - "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" - } - }, - "additionalProperties": false + "publicBaseUrl": { + "description": "Optional public base URL for service host aliases.", + "type": "string", + "format": "uri" } }, "additionalProperties": false }, - "app": { + "auth": { "type": "object", "properties": { - "baseUrl": { - "type": "string" + "password": { + "type": "string", + "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" } }, "additionalProperties": false - }, - "worktrees": { + } + }, + "additionalProperties": false + }, + "app": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + } + }, + "additionalProperties": false + }, + "providers": { + "type": "object", + "properties": { + "openai": { "type": "object", "properties": { - "root": { + "apiKey": { "type": "string", "minLength": 1 - } - }, - "additionalProperties": false - }, - "providers": { - "type": "object", - "properties": { - "openai": { + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "stt": { "type": "object", "properties": { "apiKey": { @@ -192,42 +274,18 @@ "baseUrl": { "type": "string", "minLength": 1 - }, - "stt": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false } }, "additionalProperties": false }, - "local": { + "tts": { "type": "object", "properties": { - "modelsDir": { + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { "type": "string", "minLength": 1 } @@ -237,256 +295,466 @@ }, "additionalProperties": false }, - "agents": { + "local": { "type": "object", "properties": { - "providers": { - "type": "object", - "additionalProperties": { + "modelsDir": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "pluginRegistries": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "authorization": { + "type": "string", + "minLength": 1 + } + }, + "required": ["authorization"] + } + }, + "pluginsEnabled": { + "type": "boolean" + }, + "plugins": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "directory" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "enabled": { + "type": "boolean" + } + }, + "required": ["source", "path"], + "additionalProperties": false + } + ] + } + }, + "worktrees": { + "type": "object", + "properties": { + "root": { + "type": "string", + "minLength": 1 + }, + "servicePorts": { + "type": "object", + "properties": { + "range": { + "type": "string", + "pattern": "^(\\d{1,5})-(\\d{1,5})$" + }, + "portScript": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "agents": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "extends": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "command": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "env": { "type": "object", - "properties": { - "extends": { - "type": "string" - }, - "label": { - "type": "string" - }, - "description": { - "type": "string" - }, - "command": { - "type": "array", - "items": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string", "minLength": 1 }, - "minItems": 1 - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "models": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "label": { - "type": "string", - "minLength": 1 - }, - "description": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "thinkingOptions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - }, - "description": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - } - }, - "required": ["id", "label"], - "additionalProperties": false - } - } - }, - "required": ["id", "label"], - "additionalProperties": false - } - }, - "additionalModels": { - "type": "array", - "items": { - "$ref": "#/definitions/PaseoConfigV1/properties/agents/properties/providers/additionalProperties/properties/models/items" - } - }, - "disallowedTools": { - "type": "array", - "items": { - "type": "string" - } - }, - "paseoTools": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "disabledTools": { - "type": "array", - "items": { - "type": "string" - } - } + "label": { + "type": "string", + "minLength": 1 }, - "additionalProperties": false + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "thinkingOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": ["id", "label"] + } + } }, + "required": ["id", "label"] + } + }, + "additionalModels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "thinkingOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": ["id", "label"] + } + } + }, + "required": ["id", "label"] + } + }, + "disallowedTools": { + "type": "array", + "items": { + "type": "string" + } + }, + "paseoTools": { + "type": "object", + "properties": { "enabled": { "type": "boolean" }, - "order": { - "type": "number" + "disabledTools": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "enabled": { + "type": "boolean" + }, + "order": { + "type": "number" + } + } + } + }, + "catalogRefreshTimeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647 + }, + "metadataGeneration": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "thinkingOptionId": { + "type": "string", + "minLength": 1 } }, + "required": ["provider"], "additionalProperties": false } - }, - "catalogRefreshTimeoutMs": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647 } }, "additionalProperties": false }, - "features": { + "skills": { "type": "object", "properties": { - "dictation": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "stt": { + "selection": { + "oneOf": [ + { "type": "object", "properties": { - "provider": { - "allOf": [ - { - "type": "string" - }, - { - "type": "string", - "enum": ["openai", "local"] - } - ] - }, - "model": { + "mode": { "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - }, - "confidenceThreshold": { - "type": "number" + "const": "all" } }, + "required": ["mode"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "custom" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "skills"], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "features": { + "type": "object", + "properties": { + "dictation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" } }, "additionalProperties": false + } + }, + "additionalProperties": false + }, + "voiceMode": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, - "voiceMode": { + "llm": { "type": "object", "properties": { - "enabled": { - "type": "boolean" + "provider": { + "type": "string" }, - "llm": { - "type": "object", - "properties": { - "provider": { - "type": "string" - }, - "model": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "stt": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "turnDetection": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "provider": { - "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" - }, - "model": { - "type": "string", - "minLength": 1 - }, - "voice": { - "type": "string", - "enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] - }, - "speakerId": { - "type": "integer" - }, - "speed": { - "type": "number" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "webUi": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "distDir": { + "model": { "type": "string", "minLength": 1 } }, "additionalProperties": false + }, + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "turnDetection": { + "type": "object", + "properties": { + "provider": { + "type": "string" + } + }, + "additionalProperties": false + }, + "tts": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "voice": { + "type": "string", + "minLength": 1 + }, + "speakerId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "speed": { + "type": "number" + } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "log": { + "webUi": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "distDir": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "log": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error", "fatal"] + }, + "format": { + "type": "string", + "enum": ["pretty", "json"] + }, + "console": { "type": "object", "properties": { "level": { @@ -496,42 +764,32 @@ "format": { "type": "string", "enum": ["pretty", "json"] + } + }, + "additionalProperties": false + }, + "file": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": ["trace", "debug", "info", "warn", "error", "fatal"] }, - "console": { + "path": { + "type": "string", + "minLength": 1 + }, + "rotate": { "type": "object", "properties": { - "level": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" - }, - "format": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/format" - } - }, - "additionalProperties": false - }, - "file": { - "type": "object", - "properties": { - "level": { - "$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level" - }, - "path": { + "maxSize": { "type": "string", "minLength": 1 }, - "rotate": { - "type": "object", - "properties": { - "maxSize": { - "type": "string", - "minLength": 1 - }, - "maxFiles": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false + "maxFiles": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "additionalProperties": false @@ -543,5 +801,6 @@ "additionalProperties": false } }, - "$schema": "http://json-schema.org/draft-07/schema#" + "additionalProperties": false, + "title": "PaseoConfigV1" } diff --git a/packages/website/public/sitemap-index.xml b/packages/website/public/sitemap-index.xml new file mode 100644 index 0000000000..ae15d8df3b --- /dev/null +++ b/packages/website/public/sitemap-index.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <sitemap><loc>https://paseo.sh/sitemap.xml</loc></sitemap> + <sitemap><loc>https://paseo.sh/sitemap-plugins.xml</loc></sitemap> +</sitemapindex> diff --git a/packages/website/public/sponsors/hoplite-on-dark.svg b/packages/website/public/sponsors/hoplite-on-dark.svg new file mode 100644 index 0000000000..55b1da0592 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-dark.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="543" height="132" viewBox="0 0 543.36 131.55" fill="none" role="img" aria-label="Hoplite"><g transform="translate(8 21.057) scale(0.573290)"><path d="M302.691 155.065H288.942C287.892 155.065 287.041 154.214 287.041 153.164V147.506C287.041 146.804 286.654 146.159 286.035 145.829L261.463 132.724C260.165 132.032 260.111 130.192 261.366 129.425L274.298 121.522C275.075 121.047 276.079 121.19 276.693 121.862L304.095 151.883C305.209 153.103 304.343 155.065 302.691 155.065Z" fill="#eaeaea"></path><path d="M252.762 108.228L233.61 107.75H98.3204C97.9834 107.75 97.6525 107.84 97.3615 108.01L37.0718 143.236L27.0043 148.614C26.3855 148.944 25.9991 149.589 25.9991 150.29V153.149C25.9991 154.205 25.1389 155.058 24.0833 155.05L4.18116 154.894C2.54933 154.881 1.69116 152.953 2.77368 151.732L47.3957 101.396C47.4864 101.293 47.5658 101.181 47.6325 101.062L53.6519 90.2923C54.5951 88.6046 52.7097 86.7502 51.0379 87.7212L35.8453 96.5457C24.2461 103.283 9.39029 99.5363 2.37343 88.1041L0.280818 84.6947C0.097197 84.3956 0 84.0514 0 83.7004V58.3505C0 56.4105 2.56456 55.7168 3.54252 57.3923L10.4681 69.2575C15.7292 78.271 27.2735 81.357 36.3303 76.171L58.8584 63.2712C59.4456 62.935 59.8097 62.312 59.8147 61.6355L59.8659 54.615C59.8766 53.1519 58.2994 52.2258 57.0268 52.948L33.8106 66.1245C28.937 68.8905 22.7405 66.8612 20.4473 61.7481L0.166472 16.5284C0.0567329 16.2838 7.51957e-06 16.0186 2.20646e-05 15.7505L0.000767057 2.01526C0.000821237 1.01636 0.810609 0.206616 1.80951 0.206616C17.8277 0.206616 33.7837 2.20008 49.3095 6.14104L70.7728 11.5891C71.9741 11.8941 73.1413 10.9862 73.1413 9.74669V1.59489C73.1413 0.828169 73.7628 0.206616 74.5296 0.206616C88.0714 0.206616 101.274 4.44343 112.287 12.3233L137.017 30.0176C137.339 30.2484 137.726 30.3725 138.123 30.3725H161.504C162.554 30.3725 163.405 29.5215 163.405 28.4717V25.5583C163.405 24.5085 162.554 23.6575 161.504 23.6575H138.446C136.536 23.6575 135.819 21.1555 137.44 20.1441L152.071 11.0128C163.603 3.81567 176.923 0 190.517 0C203.224 0 215.708 3.33443 226.723 9.67017L231.117 12.1975C232.347 12.9051 232.396 14.6625 231.207 15.4376L212.014 27.9505C211.476 28.3014 211.151 28.9004 211.151 29.5429V35.984C211.151 36.6551 210.797 37.2766 210.22 37.6189L203.831 41.4081C202.133 42.4151 200.215 40.4867 201.231 38.7944L202.078 37.3845C202.839 36.1175 201.927 34.5048 200.449 34.5048H192.32C190.287 34.5048 189.673 37.2658 191.514 38.1273L198.321 41.3127C199.687 41.9517 199.8 43.8498 198.521 44.6474L186.136 52.3701C185.834 52.5582 185.486 52.6579 185.13 52.6579H179.616C178.581 52.6579 177.742 53.497 177.742 54.532C177.742 55.567 178.581 56.4061 179.616 56.4061H189.794C190.136 56.4061 190.471 56.314 190.765 56.1395L267.664 10.4567C276.712 5.08166 288.289 6.78035 295.411 14.5282C295.556 14.6857 295.636 14.8917 295.636 15.1055V20.8068C295.636 22.457 297.595 23.3234 298.816 22.2134L303.82 17.6635C305.042 16.5534 307 17.4198 307 19.07V81.8818C307 82.9316 306.149 83.7827 305.099 83.7827H296.529C295.963 83.7827 295.427 83.5307 295.066 83.0953L276.998 61.3111C276.255 60.4154 274.889 60.3915 274.116 61.2606L259.05 78.1821C258.276 79.0519 258.458 80.4071 259.435 81.0408L290.165 100.974C291.35 101.742 291.313 103.489 290.096 104.206L242.218 132.42C240.989 133.144 239.43 132.307 239.355 130.882L238.675 117.953C238.617 116.865 239.484 115.952 240.573 115.952H248.301C248.767 115.952 249.216 115.781 249.564 115.472L253.977 111.549C255.266 110.404 254.486 108.271 252.762 108.228Z" fill="#eaeaea"></path><path d="M137.364 113.639H111.169C110.831 113.639 110.499 113.729 110.208 113.9L80.0946 131.554C78.7781 132.326 78.862 134.256 80.2405 134.911L107.275 147.751C107.938 148.066 108.361 148.734 108.361 149.468V153.164C108.361 154.214 109.212 155.065 110.262 155.065H126.237C127.886 155.065 128.753 153.109 127.645 151.888L112.427 135.099C111.618 134.207 111.832 132.797 112.87 132.185L138.329 117.177C139.997 116.194 139.299 113.639 137.364 113.639Z" fill="#eaeaea"></path></g><path fill="#eaeaea" d="M94.51 109.18Q93.17 109.18 92.00 108.67Q90.84 108.17 89.94 107.28Q89.05 106.39 88.55 105.22Q88.05 104.06 88.05 102.72Q88.05 101.38 88.55 100.21Q89.05 99.05 89.94 98.15Q90.84 97.26 92.00 96.76Q93.17 96.26 94.51 96.26Q95.85 96.26 97.01 96.76Q98.18 97.26 99.07 98.15Q99.96 99.05 100.47 100.21Q100.97 101.38 100.97 102.72Q100.97 104.06 100.47 105.22Q99.96 106.39 99.07 107.28Q98.18 108.17 97.01 108.67Q95.85 109.18 94.51 109.18ZM94.51 107.82Q95.57 107.82 96.49 107.42Q97.41 107.02 98.11 106.32Q98.82 105.62 99.21 104.69Q99.61 103.77 99.61 102.72Q99.61 101.65 99.21 100.73Q98.82 99.81 98.11 99.11Q97.41 98.40 96.49 98.01Q95.57 97.62 94.51 97.62Q93.45 97.62 92.53 98.01Q91.60 98.40 90.90 99.11Q90.20 99.81 89.81 100.73Q89.41 101.65 89.41 102.72Q89.41 103.77 89.81 104.69Q90.20 105.62 90.90 106.32Q91.60 107.02 92.53 107.42Q93.45 107.82 94.51 107.82ZM94.53 106.13Q93.63 106.13 92.92 105.71Q92.21 105.29 91.80 104.52Q91.38 103.74 91.38 102.72Q91.38 101.67 91.80 100.91Q92.21 100.14 92.92 99.72Q93.63 99.30 94.53 99.30Q95.63 99.30 96.42 99.92Q97.22 100.53 97.43 101.73H95.84Q95.71 101.15 95.36 100.88Q95.01 100.61 94.53 100.61Q93.80 100.61 93.36 101.19Q92.92 101.77 92.92 102.72Q92.92 103.64 93.36 104.21Q93.80 104.78 94.53 104.78Q95.05 104.78 95.41 104.49Q95.77 104.21 95.91 103.61H97.45Q97.27 104.87 96.45 105.50Q95.62 106.13 94.53 106.13Z"/><path fill="#eaeaea" d="M232.45 100.43V35.44H241.63V62.78H279.09V35.44H288.27V100.43H279.09V70.68H241.63V100.43ZM322.81 101.40Q315.31 101.40 309.76 98.31Q304.21 95.21 301.15 89.59Q298.09 83.98 298.09 76.35Q298.09 68.72 301.12 63.10Q304.14 57.49 309.73 54.41Q315.32 51.33 322.82 51.33Q330.46 51.33 335.96 54.37Q341.46 57.42 344.48 63.04Q347.49 68.66 347.49 76.36Q347.49 83.98 344.48 89.64Q341.46 95.30 335.92 98.35Q330.38 101.40 322.81 101.40ZM322.81 93.56Q330.27 93.56 334.39 89.01Q338.50 84.47 338.50 76.35Q338.50 68.16 334.39 63.66Q330.27 59.16 322.81 59.16Q315.39 59.16 311.24 63.69Q307.08 68.22 307.08 76.34Q307.08 84.47 311.24 89.01Q315.39 93.56 322.81 93.56ZM357.92 119.69V61.60H353.27V52.29H362.90V59.67H366.27Q368.71 55.61 372.85 53.47Q377.00 51.33 382.84 51.33Q390.08 51.33 395.15 54.37Q400.22 57.41 402.90 63.02Q405.58 68.64 405.58 76.36Q405.58 84.08 402.95 89.71Q400.32 95.33 395.39 98.36Q390.47 101.40 383.61 101.40Q377.90 101.40 373.67 99.13Q369.44 96.85 367.23 92.65H366.91V119.69ZM381.45 93.69Q388.69 93.69 392.64 89.12Q396.60 84.55 396.60 76.36Q396.60 68.18 392.64 63.60Q388.69 59.03 381.45 59.03Q374.87 59.03 370.89 62.79Q366.91 66.56 366.91 72.86V79.86Q366.91 86.17 370.89 89.93Q374.87 93.69 381.45 93.69ZM414.43 100.43V33.03H423.41V100.43ZM435.45 100.43V52.29H444.44V100.43ZM439.95 45.69Q437.68 45.69 436.09 44.11Q434.50 42.52 434.50 40.25Q434.50 37.99 436.09 36.40Q437.68 34.81 439.94 34.81Q442.21 34.81 443.80 36.40Q445.38 37.98 445.38 40.25Q445.38 42.51 443.80 44.10Q442.21 45.69 439.95 45.69ZM468.00 100.43Q464.18 100.43 462.05 98.48Q459.92 96.53 459.92 92.60V59.22H450.94V52.29H460.02V37.84H468.81V52.29H481.46V59.22H468.91V93.37H482.71V100.43ZM511.09 101.40Q503.59 101.40 498.05 98.31Q492.52 95.22 489.51 89.53Q486.50 83.84 486.50 76.20Q486.50 68.56 489.49 62.98Q492.49 57.41 497.93 54.37Q503.38 51.33 510.72 51.33Q517.71 51.33 522.80 54.13Q527.89 56.93 530.69 62.06Q533.49 67.20 533.49 74.24V77.89H495.12Q495.55 85.43 499.77 89.62Q503.99 93.82 511.01 93.82Q515.96 93.82 519.33 91.78Q522.69 89.73 523.97 85.78H533.09Q531.31 93.20 525.51 97.30Q519.72 101.40 511.09 101.40ZM495.49 71.34H524.82Q524.23 65.34 520.50 62.05Q516.77 58.77 510.67 58.77Q504.59 58.77 500.59 62.06Q496.59 65.35 495.49 71.34Z"/></svg> diff --git a/packages/website/public/sponsors/hoplite-on-light.svg b/packages/website/public/sponsors/hoplite-on-light.svg new file mode 100644 index 0000000000..144d1a59c9 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-light.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="543" height="132" viewBox="0 0 543.36 131.55" fill="none" role="img" aria-label="Hoplite"><g transform="translate(8 21.057) scale(0.573290)"><path d="M302.691 155.065H288.942C287.892 155.065 287.041 154.214 287.041 153.164V147.506C287.041 146.804 286.654 146.159 286.035 145.829L261.463 132.724C260.165 132.032 260.111 130.192 261.366 129.425L274.298 121.522C275.075 121.047 276.079 121.19 276.693 121.862L304.095 151.883C305.209 153.103 304.343 155.065 302.691 155.065Z" fill="#121212"></path><path d="M252.762 108.228L233.61 107.75H98.3204C97.9834 107.75 97.6525 107.84 97.3615 108.01L37.0718 143.236L27.0043 148.614C26.3855 148.944 25.9991 149.589 25.9991 150.29V153.149C25.9991 154.205 25.1389 155.058 24.0833 155.05L4.18116 154.894C2.54933 154.881 1.69116 152.953 2.77368 151.732L47.3957 101.396C47.4864 101.293 47.5658 101.181 47.6325 101.062L53.6519 90.2923C54.5951 88.6046 52.7097 86.7502 51.0379 87.7212L35.8453 96.5457C24.2461 103.283 9.39029 99.5363 2.37343 88.1041L0.280818 84.6947C0.097197 84.3956 0 84.0514 0 83.7004V58.3505C0 56.4105 2.56456 55.7168 3.54252 57.3923L10.4681 69.2575C15.7292 78.271 27.2735 81.357 36.3303 76.171L58.8584 63.2712C59.4456 62.935 59.8097 62.312 59.8147 61.6355L59.8659 54.615C59.8766 53.1519 58.2994 52.2258 57.0268 52.948L33.8106 66.1245C28.937 68.8905 22.7405 66.8612 20.4473 61.7481L0.166472 16.5284C0.0567329 16.2838 7.51957e-06 16.0186 2.20646e-05 15.7505L0.000767057 2.01526C0.000821237 1.01636 0.810609 0.206616 1.80951 0.206616C17.8277 0.206616 33.7837 2.20008 49.3095 6.14104L70.7728 11.5891C71.9741 11.8941 73.1413 10.9862 73.1413 9.74669V1.59489C73.1413 0.828169 73.7628 0.206616 74.5296 0.206616C88.0714 0.206616 101.274 4.44343 112.287 12.3233L137.017 30.0176C137.339 30.2484 137.726 30.3725 138.123 30.3725H161.504C162.554 30.3725 163.405 29.5215 163.405 28.4717V25.5583C163.405 24.5085 162.554 23.6575 161.504 23.6575H138.446C136.536 23.6575 135.819 21.1555 137.44 20.1441L152.071 11.0128C163.603 3.81567 176.923 0 190.517 0C203.224 0 215.708 3.33443 226.723 9.67017L231.117 12.1975C232.347 12.9051 232.396 14.6625 231.207 15.4376L212.014 27.9505C211.476 28.3014 211.151 28.9004 211.151 29.5429V35.984C211.151 36.6551 210.797 37.2766 210.22 37.6189L203.831 41.4081C202.133 42.4151 200.215 40.4867 201.231 38.7944L202.078 37.3845C202.839 36.1175 201.927 34.5048 200.449 34.5048H192.32C190.287 34.5048 189.673 37.2658 191.514 38.1273L198.321 41.3127C199.687 41.9517 199.8 43.8498 198.521 44.6474L186.136 52.3701C185.834 52.5582 185.486 52.6579 185.13 52.6579H179.616C178.581 52.6579 177.742 53.497 177.742 54.532C177.742 55.567 178.581 56.4061 179.616 56.4061H189.794C190.136 56.4061 190.471 56.314 190.765 56.1395L267.664 10.4567C276.712 5.08166 288.289 6.78035 295.411 14.5282C295.556 14.6857 295.636 14.8917 295.636 15.1055V20.8068C295.636 22.457 297.595 23.3234 298.816 22.2134L303.82 17.6635C305.042 16.5534 307 17.4198 307 19.07V81.8818C307 82.9316 306.149 83.7827 305.099 83.7827H296.529C295.963 83.7827 295.427 83.5307 295.066 83.0953L276.998 61.3111C276.255 60.4154 274.889 60.3915 274.116 61.2606L259.05 78.1821C258.276 79.0519 258.458 80.4071 259.435 81.0408L290.165 100.974C291.35 101.742 291.313 103.489 290.096 104.206L242.218 132.42C240.989 133.144 239.43 132.307 239.355 130.882L238.675 117.953C238.617 116.865 239.484 115.952 240.573 115.952H248.301C248.767 115.952 249.216 115.781 249.564 115.472L253.977 111.549C255.266 110.404 254.486 108.271 252.762 108.228Z" fill="#121212"></path><path d="M137.364 113.639H111.169C110.831 113.639 110.499 113.729 110.208 113.9L80.0946 131.554C78.7781 132.326 78.862 134.256 80.2405 134.911L107.275 147.751C107.938 148.066 108.361 148.734 108.361 149.468V153.164C108.361 154.214 109.212 155.065 110.262 155.065H126.237C127.886 155.065 128.753 153.109 127.645 151.888L112.427 135.099C111.618 134.207 111.832 132.797 112.87 132.185L138.329 117.177C139.997 116.194 139.299 113.639 137.364 113.639Z" fill="#121212"></path></g><path fill="#121212" d="M94.51 109.18Q93.17 109.18 92.00 108.67Q90.84 108.17 89.94 107.28Q89.05 106.39 88.55 105.22Q88.05 104.06 88.05 102.72Q88.05 101.38 88.55 100.21Q89.05 99.05 89.94 98.15Q90.84 97.26 92.00 96.76Q93.17 96.26 94.51 96.26Q95.85 96.26 97.01 96.76Q98.18 97.26 99.07 98.15Q99.96 99.05 100.47 100.21Q100.97 101.38 100.97 102.72Q100.97 104.06 100.47 105.22Q99.96 106.39 99.07 107.28Q98.18 108.17 97.01 108.67Q95.85 109.18 94.51 109.18ZM94.51 107.82Q95.57 107.82 96.49 107.42Q97.41 107.02 98.11 106.32Q98.82 105.62 99.21 104.69Q99.61 103.77 99.61 102.72Q99.61 101.65 99.21 100.73Q98.82 99.81 98.11 99.11Q97.41 98.40 96.49 98.01Q95.57 97.62 94.51 97.62Q93.45 97.62 92.53 98.01Q91.60 98.40 90.90 99.11Q90.20 99.81 89.81 100.73Q89.41 101.65 89.41 102.72Q89.41 103.77 89.81 104.69Q90.20 105.62 90.90 106.32Q91.60 107.02 92.53 107.42Q93.45 107.82 94.51 107.82ZM94.53 106.13Q93.63 106.13 92.92 105.71Q92.21 105.29 91.80 104.52Q91.38 103.74 91.38 102.72Q91.38 101.67 91.80 100.91Q92.21 100.14 92.92 99.72Q93.63 99.30 94.53 99.30Q95.63 99.30 96.42 99.92Q97.22 100.53 97.43 101.73H95.84Q95.71 101.15 95.36 100.88Q95.01 100.61 94.53 100.61Q93.80 100.61 93.36 101.19Q92.92 101.77 92.92 102.72Q92.92 103.64 93.36 104.21Q93.80 104.78 94.53 104.78Q95.05 104.78 95.41 104.49Q95.77 104.21 95.91 103.61H97.45Q97.27 104.87 96.45 105.50Q95.62 106.13 94.53 106.13Z"/><path fill="#121212" d="M232.45 100.43V35.44H241.63V62.78H279.09V35.44H288.27V100.43H279.09V70.68H241.63V100.43ZM322.81 101.40Q315.31 101.40 309.76 98.31Q304.21 95.21 301.15 89.59Q298.09 83.98 298.09 76.35Q298.09 68.72 301.12 63.10Q304.14 57.49 309.73 54.41Q315.32 51.33 322.82 51.33Q330.46 51.33 335.96 54.37Q341.46 57.42 344.48 63.04Q347.49 68.66 347.49 76.36Q347.49 83.98 344.48 89.64Q341.46 95.30 335.92 98.35Q330.38 101.40 322.81 101.40ZM322.81 93.56Q330.27 93.56 334.39 89.01Q338.50 84.47 338.50 76.35Q338.50 68.16 334.39 63.66Q330.27 59.16 322.81 59.16Q315.39 59.16 311.24 63.69Q307.08 68.22 307.08 76.34Q307.08 84.47 311.24 89.01Q315.39 93.56 322.81 93.56ZM357.92 119.69V61.60H353.27V52.29H362.90V59.67H366.27Q368.71 55.61 372.85 53.47Q377.00 51.33 382.84 51.33Q390.08 51.33 395.15 54.37Q400.22 57.41 402.90 63.02Q405.58 68.64 405.58 76.36Q405.58 84.08 402.95 89.71Q400.32 95.33 395.39 98.36Q390.47 101.40 383.61 101.40Q377.90 101.40 373.67 99.13Q369.44 96.85 367.23 92.65H366.91V119.69ZM381.45 93.69Q388.69 93.69 392.64 89.12Q396.60 84.55 396.60 76.36Q396.60 68.18 392.64 63.60Q388.69 59.03 381.45 59.03Q374.87 59.03 370.89 62.79Q366.91 66.56 366.91 72.86V79.86Q366.91 86.17 370.89 89.93Q374.87 93.69 381.45 93.69ZM414.43 100.43V33.03H423.41V100.43ZM435.45 100.43V52.29H444.44V100.43ZM439.95 45.69Q437.68 45.69 436.09 44.11Q434.50 42.52 434.50 40.25Q434.50 37.99 436.09 36.40Q437.68 34.81 439.94 34.81Q442.21 34.81 443.80 36.40Q445.38 37.98 445.38 40.25Q445.38 42.51 443.80 44.10Q442.21 45.69 439.95 45.69ZM468.00 100.43Q464.18 100.43 462.05 98.48Q459.92 96.53 459.92 92.60V59.22H450.94V52.29H460.02V37.84H468.81V52.29H481.46V59.22H468.91V93.37H482.71V100.43ZM511.09 101.40Q503.59 101.40 498.05 98.31Q492.52 95.22 489.51 89.53Q486.50 83.84 486.50 76.20Q486.50 68.56 489.49 62.98Q492.49 57.41 497.93 54.37Q503.38 51.33 510.72 51.33Q517.71 51.33 522.80 54.13Q527.89 56.93 530.69 62.06Q533.49 67.20 533.49 74.24V77.89H495.12Q495.55 85.43 499.77 89.62Q503.99 93.82 511.01 93.82Q515.96 93.82 519.33 91.78Q522.69 89.73 523.97 85.78H533.09Q531.31 93.20 525.51 97.30Q519.72 101.40 511.09 101.40ZM495.49 71.34H524.82Q524.23 65.34 520.50 62.05Q516.77 58.77 510.67 58.77Q504.59 58.77 500.59 62.06Q496.59 65.35 495.49 71.34Z"/></svg> diff --git a/packages/website/src/components/alternative-page.tsx b/packages/website/src/components/alternative-page.tsx index ec583fefaf..3322f869e1 100644 --- a/packages/website/src/components/alternative-page.tsx +++ b/packages/website/src/components/alternative-page.tsx @@ -32,7 +32,9 @@ function AlternativePageContent({ slug }: { slug: string }) { return ( <SiteShell width="default"> - <DocsMarkdown>{page.content}</DocsMarkdown> + <div className="comparison-prose"> + <DocsMarkdown>{page.content}</DocsMarkdown> + </div> </SiteShell> ); } diff --git a/packages/website/src/components/breadcrumbs.tsx b/packages/website/src/components/breadcrumbs.tsx new file mode 100644 index 0000000000..240f34047b --- /dev/null +++ b/packages/website/src/components/breadcrumbs.tsx @@ -0,0 +1,32 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronRight } from "lucide-react"; + +export interface BreadcrumbItem { + label: string; + /** Internal path. The last item is the current page and has no href. */ + href?: string; +} + +export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) { + return ( + <nav aria-label="Breadcrumb" className="not-prose mb-6"> + <ol className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> + {items.map((item, index) => { + const last = index === items.length - 1; + return ( + <li key={`${item.href ?? ""}#${item.label}`} className="flex items-center gap-2"> + {index > 0 && <ChevronRight size={14} className="text-border" />} + {item.href && !last ? ( + <Link to={item.href} className="hover:text-foreground transition-colors"> + {item.label} + </Link> + ) : ( + <span className={last ? "text-foreground" : undefined}>{item.label}</span> + )} + </li> + ); + })} + </ol> + </nav> + ); +} diff --git a/packages/website/src/components/docs-breadcrumbs.tsx b/packages/website/src/components/docs-breadcrumbs.tsx index 7b82e7b4fb..0da4f4472a 100644 --- a/packages/website/src/components/docs-breadcrumbs.tsx +++ b/packages/website/src/components/docs-breadcrumbs.tsx @@ -1,5 +1,5 @@ -import { Link } from "@tanstack/react-router"; -import { ChevronRight } from "lucide-react"; +import { useMemo } from "react"; +import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; import { type Doc, type DocsNavNode, getDocBreadcrumbGroups } from "~/docs"; interface DocsBreadcrumbsProps { @@ -8,27 +8,13 @@ interface DocsBreadcrumbsProps { } export function DocsBreadcrumbs({ doc, tree }: DocsBreadcrumbsProps) { - const groups = getDocBreadcrumbGroups(doc, tree); - - return ( - <nav aria-label="Breadcrumb" className="not-prose mb-6"> - <ol className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> - <li> - <Link to="/docs" className="hover:text-foreground transition-colors"> - Docs - </Link> - </li> - {groups.map((group) => ( - <li key={group.label} className="flex items-center gap-2"> - <ChevronRight size={14} className="text-border" /> - <span>{group.label}</span> - </li> - ))} - <li className="flex items-center gap-2"> - <ChevronRight size={14} className="text-border" /> - <span className="text-foreground">{doc.frontmatter.nav}</span> - </li> - </ol> - </nav> + const items = useMemo<BreadcrumbItem[]>( + () => [ + { label: "Docs", href: "/docs" }, + ...getDocBreadcrumbGroups(doc, tree).map((group) => ({ label: group.label })), + { label: doc.frontmatter.nav }, + ], + [doc, tree], ); + return <Breadcrumbs items={items} />; } diff --git a/packages/website/src/components/docs-source-footer.tsx b/packages/website/src/components/docs-source-footer.tsx index 69a9711e80..2b5ea3f131 100644 --- a/packages/website/src/components/docs-source-footer.tsx +++ b/packages/website/src/components/docs-source-footer.tsx @@ -1,3 +1,4 @@ +import { ExternalLink } from "lucide-react"; import type { Doc } from "~/docs"; const GITHUB_BASE_URL = "https://github.com/getpaseo/paseo/blob/main"; @@ -6,9 +7,15 @@ export function DocsSourceFooter({ doc }: { doc: Doc }) { const sourceUrl = `${GITHUB_BASE_URL}/${doc.sourcePath}`; return ( - <footer className="docs-source-footer"> - <a href={sourceUrl} target="_blank" rel="noreferrer"> - View this page on GitHub + <footer className="mt-12 border-t border-white/10 py-8 text-sm"> + <a + href={sourceUrl} + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1.5 font-medium text-white/60 underline-offset-4 transition-colors hover:text-foreground hover:underline" + > + View on GitHub + <ExternalLink aria-hidden="true" className="size-3.5" /> </a> </footer> ); diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index ce68595980..ff0964b751 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -1,15 +1,15 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; import * as React from "react"; import { ArrowRight, Blocks, Bot, - BookOpen, Braces, - Coffee, Compass, ExternalLink, Gem, GitFork, + LayoutPanelLeft, Laptop, Lock, Merge, @@ -18,6 +18,7 @@ import { Smartphone, Terminal, Users, + Workflow, type LucideIcon, } from "lucide-react"; import { @@ -131,13 +132,13 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { <TurnkeySection /> <AutomationSection /> <ExtensibleSection /> - <FAQ /> - <PhilosophySection /> + <SponsorsSection placement="page" /> <SponsorSection /> - <SponsorsSection /> + <PhilosophySection /> + <FAQ /> </div> </main> - <SiteFooter /> + <SiteFooter sponsors={false} /> </div> </CursorFieldProvider> ); @@ -467,46 +468,35 @@ function TurnkeySection() { title="Run it anywhere" description="Use Paseo locally, from another machine, or with a team" > - <div className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]"> - <div className="flex flex-col gap-6 border-b border-white/10 p-6 sm:flex-row sm:items-center sm:justify-between md:p-8"> - <div className="flex items-start gap-4"> - <div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground"> - <Monitor className="h-6 w-6" strokeWidth={1.5} /> - </div> - <div className="space-y-0.5"> - <h3 className="text-xl font-medium text-white/90">Desktop app</h3> - <p className="max-w-lg text-sm leading-relaxed text-white/50"> - The one click experience, download the app and it just works - </p> - </div> - </div> - </div> - - <div className="p-6 md:p-8"> - <div className="grid gap-4 md:grid-cols-3"> - <TurnkeyExtensionCard - icon={Smartphone} - title="Mobile and web" - description="Connect to the same workspaces from any client" - ctaHref="/download" - ctaLabel="Download" - /> - <TurnkeyExtensionCard - icon={Laptop} - title="Remote machines" - description="Run Paseo on a home lab, or a cloud machine" - ctaHref="/docs#server--cli" - ctaLabel="Docs" - /> - <TurnkeyExtensionCard - icon={Users} - title="Teams and triggers" - description="Share access or start work from GitHub, Slack, and Discord" - ctaHref="/hub" - ctaLabel="Paseo Hub" - showIntegrationIcons - /> - </div> + <div className="space-y-8"> + <ProductLead + icon={Monitor} + title="Desktop app" + description="The one click experience, download the app and it just works, no server required" + /> + <div className="grid gap-4 md:grid-cols-3"> + <TurnkeyExtensionCard + icon={Smartphone} + title="Mobile" + description="Full client and native, not a WebView" + ctaHref="/download" + ctaLabel="Download" + /> + <TurnkeyExtensionCard + icon={Laptop} + title="Remote machines" + description="Run Paseo on a home lab, or a cloud machine" + ctaHref="/docs#server--cli" + ctaLabel="Docs" + /> + <TurnkeyExtensionCard + icon={Users} + title="Teams and triggers" + description="Share access or start work from GitHub, Slack, and Discord" + ctaHref="/hub" + ctaLabel="Paseo Hub" + showIntegrationIcons + /> </div> </div> </FeatureSection> @@ -529,20 +519,8 @@ function TurnkeyExtensionCard({ showIntegrationIcons?: boolean; }) { return ( - <div className="flex min-h-48 flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5"> - <div className="mb-5 flex items-center gap-3 text-muted-foreground"> - <Icon className="h-5 w-5" strokeWidth={1.5} /> - {showIntegrationIcons ? ( - <> - <GitHubIcon className="h-4 w-4" /> - <SlackIcon className="h-4 w-4" /> - <DiscordIcon className="h-4 w-4" /> - </> - ) : null} - </div> - <h3 className="font-medium text-white/85">{title}</h3> - <p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p> - <div className="mt-auto pt-5"> + <FeatureCard icon={Icon} title={title} description={description}> + <div className="flex items-center justify-between"> <a href={ctaHref} className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-2.5 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90" @@ -550,7 +528,70 @@ function TurnkeyExtensionCard({ {ctaLabel} <ArrowRight className="h-3.5 w-3.5" /> </a> + {showIntegrationIcons ? INTEGRATION_ICONS : null} </div> + </FeatureCard> + ); +} + +const INTEGRATION_ICONS = ( + <span className="flex items-center gap-2 text-extra-muted-foreground"> + <GitHubIcon className="h-4 w-4" /> + <SlackIcon className="h-4 w-4" /> + <DiscordIcon className="h-4 w-4" /> + </span> +); + +/** The lead product of a section: an icon tile and title on the page background. */ +function ProductLead({ + icon: Icon, + title, + description, + actions, +}: { + icon: LucideIcon; + title: string; + description?: string; + actions?: React.ReactNode; +}) { + return ( + <div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-center gap-4"> + <div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground"> + <Icon className="h-6 w-6" strokeWidth={1.5} /> + </div> + <div className="space-y-0.5"> + <h3 className="text-xl font-medium text-white/90">{title}</h3> + {description ? ( + <p className="text-sm leading-relaxed text-white/50">{description}</p> + ) : null} + </div> + </div> + {actions ? <div className="flex shrink-0 items-center gap-3">{actions}</div> : null} + </div> + ); +} + +/** A small card: icon and title on one line, the blurb below, an optional action last. */ +function FeatureCard({ + icon: Icon, + title, + description, + children, +}: { + icon: LucideIcon; + title: string; + description: string; + children?: React.ReactNode; +}) { + return ( + <div className="flex flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5"> + <div className="flex items-center gap-3"> + <Icon className="h-5 w-5 shrink-0 text-extra-muted-foreground" strokeWidth={1.5} /> + <h3 className="font-medium text-white/85">{title}</h3> + </div> + <p className="mt-3 text-sm leading-relaxed text-white/45">{description}</p> + {children ? <div className="mt-auto pt-5">{children}</div> : null} </div> ); } @@ -792,93 +833,79 @@ function SdkAutomationExample() { function ExtensibleSection() { return ( - <FeatureSection title="Make it yours" description="Extend Paseo to work just the way you want"> - <div className="grid gap-4 md:grid-cols-2"> - <ExtensibleCard - icon={Puzzle} - title="Plugins" - description="Plugins can add server-side functionality and modify the client with custom components. They work across all clients, including mobile" - links={PLUGIN_CARD_LINKS} - /> - <ExtensibleCard - icon={GitFork} - title="Fork the repo" - description="Paseo is licensed under Apache 2.0. You can inspect the implementation, fork the project, and adapt it to your workflow or organization" - links={FORK_CARD_LINKS} - /> + <FeatureSection + title="Extend it with plugins" + description="Add new panels and functionality that work in desktop, web and mobile" + > + <div className="space-y-8"> + <ProductLead icon={Puzzle} title="Plugins" actions={PLUGIN_ACTIONS} /> + <div className="grid gap-4 md:grid-cols-3"> + {PLUGIN_CAPABILITIES.map((capability) => ( + <FeatureCard key={capability.title} {...capability} /> + ))} + </div> + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> + <p className="text-sm text-white/45">Paseo is Apache 2.0.</p> + <a + href="https://github.com/getpaseo/paseo" + target="_blank" + rel="noopener noreferrer" + className="inline-flex shrink-0 items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground" + > + <GitFork className="h-4 w-4" strokeWidth={1.5} /> + Fork the repo + <ExternalLink className="h-3.5 w-3.5 opacity-70" /> + </a> + </div> </div> </FeatureSection> ); } -interface ExtensibleCardLink { - href: string; - label: string; - icon: React.ComponentType<{ className?: string }>; - external?: boolean; - /** Accent links stand out without hover, for destinations worth noticing. */ - accent?: boolean; -} +const PLUGINS_BROWSE = PLUGINS_LINKED + ? { href: "/plugins", external: false } + : { href: "https://paseo.cafe", external: true }; -const PLUGIN_CARD_LINKS: ReadonlyArray<ExtensibleCardLink> = [ - { href: "/docs/plugins", label: "Plugin documentation", icon: BookOpen }, - { - href: "https://paseo.cafe", - label: "Community plugins", - icon: Coffee, - external: true, - accent: true, - }, -]; +const PLUGIN_ACTIONS = ( + <> + <a + href={PLUGINS_BROWSE.href} + {...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} + className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90" + > + Browse plugins + <ArrowRight className="h-3.5 w-3.5" /> + </a> + <a + href="/docs/plugins" + className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10" + > + Plugin docs + </a> + </> +); -const FORK_CARD_LINKS: ReadonlyArray<ExtensibleCardLink> = [ - { - href: "https://github.com/getpaseo/paseo", - label: "View the repository", - icon: GitHubIcon, - external: true, - }, -]; - -function ExtensibleCard({ - icon: Icon, - title, - description, - links, -}: { +const PLUGIN_CAPABILITIES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string; - links: ReadonlyArray<ExtensibleCardLink>; -}) { - return ( - <div className="flex min-h-64 flex-col rounded-xl border border-white/10 bg-white/[0.025] p-6"> - <div className="mb-8 text-muted-foreground"> - <Icon className="h-6 w-6" strokeWidth={1.5} /> - </div> - <h3 className="text-lg font-medium text-white/85">{title}</h3> - <p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p> - <div className="mt-auto flex flex-col items-start gap-3 pt-6"> - {links.map((link) => ( - <a - key={link.href} - href={link.href} - {...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} - className={ - link.accent - ? "inline-flex items-center gap-2 text-sm text-emerald-300/85 transition-colors hover:text-emerald-200" - : "inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground" - } - > - <link.icon className="h-4 w-4" /> - {link.label} - {link.external ? <ExternalLink className="h-3.5 w-3.5 opacity-70" /> : null} - </a> - ))} - </div> - </div> - ); -} +}> = [ + { + icon: LayoutPanelLeft, + title: "UI", + description: "Screens, workspace panels, sidebar items, slash commands, and themes", + }, + { + icon: Workflow, + title: "Agent lifecycle", + description: "Change config and MCP servers, answer permissions, and follow up", + }, + { + icon: Bot, + title: "Providers", + description: "Add a coding agent as a provider, used like the built-in ones", + }, +]; const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string }> = [ { @@ -899,13 +926,12 @@ const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: { icon: Merge, title: "Unified", - description: - "Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, and more, all used the same way.", + description: "Every coding agent, used the same way. Switch providers any time.", }, { icon: Blocks, title: "Composable", - description: "Run agents on your laptop or a server, and connect from any device.", + description: "Run agents on a laptop or a server, and connect from any device.", }, { icon: Puzzle, @@ -920,18 +946,9 @@ function PhilosophySection() { title="Philosophy" description="What Paseo stands for, and what every feature is built on" > - <div className="grid gap-4 md:grid-cols-2"> + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {PRINCIPLES.map((principle) => ( - <div - key={principle.title} - className="rounded-xl border border-white/10 bg-white/[0.025] p-6" - > - <div className="mb-8 text-extra-muted-foreground"> - <principle.icon className="h-6 w-6" strokeWidth={1.5} /> - </div> - <h3 className="text-lg font-medium text-white/85">{principle.title}</h3> - <p className="mt-2 text-sm leading-relaxed text-white/45">{principle.description}</p> - </div> + <FeatureCard key={principle.title} {...principle} /> ))} </div> </FeatureSection> diff --git a/packages/website/src/components/mockup/titlebar.tsx b/packages/website/src/components/mockup/titlebar.tsx index 6f3b91032f..de99f4db76 100644 --- a/packages/website/src/components/mockup/titlebar.tsx +++ b/packages/website/src/components/mockup/titlebar.tsx @@ -64,7 +64,7 @@ export function TitleBar({ state }: { state: MockupStateId }) { {shipping ? ( <> <GithubGlyph size={13} /> - Merge PR (squash) + Merge PR </> ) : ( <> diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index ba092410da..7c34376dbc 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -1,19 +1,29 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; import { getAlternativePages } from "~/data/alternative-pages"; import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads"; +import { SponsorsSection } from "~/components/sponsorship"; interface SiteFooterProps { - width?: "default" | "prose"; + width?: "default" | "prose" | "wide"; + /** Pages that show the sponsors in their content turn the footer band off. */ + sponsors?: boolean; } -export function SiteFooter({ width = "default" }: SiteFooterProps) { - const widthClasses = - width === "prose" ? "max-w-prose p-6 md:p-12 md:pt-0" : "max-w-5xl p-6 md:p-20 md:pt-0"; +const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = { + prose: "max-w-prose p-6 md:p-12 md:pt-0", + default: "max-w-5xl p-6 md:p-20 md:pt-0", + wide: "max-w-7xl p-6 md:p-20 md:pt-0", +}; + +export function SiteFooter({ width = "default", sponsors = true }: 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`}> + {sponsors ? <SponsorsSection placement="footer" /> : null} <div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-5 gap-8 text-sm"> <div className="space-y-3"> - <p className="text-white/60 font-medium">Product</p> + <p className="text-extra-muted-foreground">Product</p> <div className="space-y-2"> <a href="/blog" @@ -27,6 +37,14 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { > Docs </a> + {PLUGINS_LINKED && ( + <a + href="/plugins" + className="block text-muted-foreground hover:text-foreground transition-colors" + > + Plugins + </a> + )} <a href="/changelog" className="block text-muted-foreground hover:text-foreground transition-colors" @@ -66,7 +84,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { </div> </div> <div className="space-y-3"> - <p className="text-white/60 font-medium">Agents</p> + <p className="text-extra-muted-foreground">Agents</p> <div className="space-y-2"> <a href="/claude-code" @@ -107,7 +125,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { </div> </div> <div className="space-y-3"> - <p className="text-white/60 font-medium">Alternatives</p> + <p className="text-extra-muted-foreground">Alternatives</p> <div className="space-y-2"> {alternatives.map((page) => ( <a @@ -121,7 +139,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { </div> </div> <div className="space-y-3"> - <p className="text-white/60 font-medium">Community</p> + <p className="text-extra-muted-foreground">Community</p> <div className="space-y-2"> <a href="https://discord.gg/jz8T2uahpH" @@ -150,7 +168,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { </div> </div> <div className="space-y-3"> - <p className="text-white/60 font-medium">Download</p> + <p className="text-extra-muted-foreground">Download</p> <div className="space-y-2"> <a href={appStoreUrl} diff --git a/packages/website/src/components/site-header.tsx b/packages/website/src/components/site-header.tsx index 3cb62ac09d..fc70e11f39 100644 --- a/packages/website/src/components/site-header.tsx +++ b/packages/website/src/components/site-header.tsx @@ -1,3 +1,4 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; import "~/styles.css"; import { DiscordIcon, GitHubIcon } from "~/components/brand-icons"; import { useStars } from "~/routes/__root"; @@ -11,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" diff --git a/packages/website/src/components/site-shell.tsx b/packages/website/src/components/site-shell.tsx index 2bbd1f89a4..2c0a38f795 100644 --- a/packages/website/src/components/site-shell.tsx +++ b/packages/website/src/components/site-shell.tsx @@ -4,16 +4,21 @@ import { SiteHeader } from "~/components/site-header"; interface SiteShellProps { children: ReactNode; - width: "default" | "prose"; + width: "default" | "prose" | "wide"; } +const MAIN_CLASSES: Record<SiteShellProps["width"], string> = { + prose: "max-w-prose p-6 md:p-12 mx-auto", + default: "max-w-5xl p-6 md:p-20 mx-auto", + wide: "max-w-7xl p-6 md:p-20 mx-auto", +}; + export function SiteShell({ children, width }: SiteShellProps) { - const mainClasses = - width === "prose" ? "max-w-prose p-6 md:p-12 mx-auto" : "max-w-5xl p-6 md:p-20 mx-auto"; + const mainClasses = MAIN_CLASSES[width]; return ( <div className="min-h-screen bg-background"> <main className={mainClasses}> - <div className="mb-12"> + <div className="mb-20"> <SiteHeader /> </div> {children} diff --git a/packages/website/src/components/sponsorship.tsx b/packages/website/src/components/sponsorship.tsx index 449742bffb..d82645a998 100644 --- a/packages/website/src/components/sponsorship.tsx +++ b/packages/website/src/components/sponsorship.tsx @@ -1,14 +1,15 @@ -import { ArrowRight, Check, ExternalLink } from "lucide-react"; +import { ArrowRight, Check, ExternalLink, Heart } from "lucide-react"; import { BuyMeACoffeeIcon, GitHubIcon, OpenCollectiveIcon } from "~/components/brand-icons"; import { BUY_ME_A_COFFEE_URL, GITHUB_SPONSORS_URL, - HOMEPAGE_SPONSORS, - HOMEPAGE_SPOT_PRICE, OPEN_COLLECTIVE_URL, + SPONSORS, SPONSOR_CONTACT_EMAIL, SPONSOR_SPOT_CHECKOUT_URL, + SPONSOR_SPOT_PRICE, openSpotCount, + sponsorHref, } from "~/data/sponsors"; function SectionHeading({ @@ -53,9 +54,15 @@ const MAINTAINER_LINK = ( export function FounderNote() { return ( <div className="max-w-2xl space-y-5 leading-relaxed text-white/70"> + <p>Paseo is an independent project used by tens of thousands of developers daily.</p> <p> - I build Paseo on my own. There are no investors, no board and no company behind it, and I - have turned down funding offers to keep it that way. + It is maintained by one person, full time, and shaped by the community. There are no + investors, no board and no company behind it. I have turned down funding offers to keep it + that way. + </p> + <p> + The community helps Paseo grow through feedback, testing, bug reports, code contributions + and plugins. Your sponsorship helps me keep improving and maintaining it. </p> <p> A tool that sits between you and your code, your keys and your machines has to stay neutral, @@ -67,7 +74,7 @@ export function FounderNote() { <a href="/hub" className="underline hover:text-white/90"> Paseo Hub </a> - , an optional hosted service. Your support is what lets me work on Paseo full time. + , an optional hosted service. Your support is what lets me keep working on it. </p> <p className="text-white/50">{MAINTAINER_LINK}, maintainer</p> </div> @@ -79,7 +86,6 @@ interface BackingOption { name: string; icon: React.ComponentType<{ className?: string }>; external: boolean; - primary?: boolean; } const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [ @@ -88,7 +94,6 @@ const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [ name: "GitHub Sponsors", icon: GitHubIcon, external: true, - primary: true, }, { href: OPEN_COLLECTIVE_URL, @@ -127,11 +132,7 @@ export function BackingOptions() { key={option.href} href={option.href} {...(option.external ? { target: "_blank", rel: "noopener noreferrer" } : {})} - className={`flex items-center gap-4 rounded-xl border p-4 transition-colors ${ - option.primary - ? "border-white/25 bg-white/[0.06] hover:border-white/40 hover:bg-white/[0.08]" - : "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]" - }`} + className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] p-4 transition-colors hover:border-white/20 hover:bg-white/[0.05]" > <option.icon className="h-6 w-6 shrink-0 text-white/60" /> <p className="flex items-center gap-1.5 font-medium text-white"> @@ -148,30 +149,11 @@ export function BackingOptions() { ); } -function SponsorLogoRow() { - return ( - <ul className="grid grid-cols-2 gap-4"> - {HOMEPAGE_SPONSORS.map((sponsor) => ( - <li key={sponsor.href}> - <a - href={sponsor.href} - target="_blank" - rel="noopener noreferrer sponsored" - className="flex h-32 items-center justify-center rounded-xl border border-white/10 bg-white/[0.02] px-6 transition-colors hover:border-white/20 hover:bg-white/[0.05]" - > - <img src={sponsor.logo} alt={sponsor.name} className="max-h-10 max-w-full opacity-80" /> - </a> - </li> - ))} - </ul> - ); -} - const SPOT_FEATURES: ReadonlyArray<string> = [ - "Your logo on the homepage of paseo.sh, around 70,000 visitors a month", + "Your logo on the paseo.sh homepage and docs, around 70,000 visitors a month", "Your logo in the GitHub README, around 30,000 visitors a month", "An announcement in the Discord, around 2,000 members", - "A mention in the changelog", + "An announcement from me on X", ]; export function SponsorSpotSection() { @@ -181,11 +163,11 @@ export function SponsorSpotSection() { <SectionHeading as="h2" title="Sponsor as a company" - description="If your team relies on Paseo, a monthly sponsorship funds its development directly. As a thank you, your logo goes on the homepage and in the README." + description="Support Paseo financially and get visibility for your company" /> <div className="space-y-8"> <div className="flex items-end gap-2"> - <span className="text-4xl font-medium tracking-tight">{HOMEPAGE_SPOT_PRICE}</span> + <span className="text-4xl font-medium tracking-tight">{SPONSOR_SPOT_PRICE}</span> <span className="mb-1 text-sm text-white/45">a month</span> </div> <ul className="grid max-w-2xl gap-3 text-white/70"> @@ -237,18 +219,19 @@ export function SponsorSection() { <SectionHeading as="h2" title="Sponsor Paseo" /> <div className="space-y-10"> <div className="max-w-2xl space-y-5 leading-relaxed text-white/70"> + <p>Paseo is an independent project used by tens of thousands of developers daily.</p> <p> - I build Paseo on my own, with no investors and no company behind it, and I have turned - down funding offers to keep it that way. A tool that sits between you and your code, - your keys and your machines has to stay neutral, and funding creates pressure to - monetize. + It is maintained by one person, full time, and shaped by the community. There are no + investors and no company behind it. I have turned down funding to keep it that way. A + tool that sits between you and your code, your keys and your machines has to stay + neutral, and funding creates pressure to monetize. </p> <p> Paseo is self-funded through sponsorship and{" "} <a href="/hub" className="underline hover:text-white/90"> Paseo Hub </a> - , and your support is what lets me work on it full time. + . Your support is what lets me keep working on it. </p> <p className="text-white/50">{MAINTAINER_LINK}, maintainer</p> </div> @@ -258,23 +241,78 @@ export function SponsorSection() { ); } -/** Homepage: the companies sponsoring Paseo. Renders nothing until there is one. */ -export function SponsorsSection() { - if (HOMEPAGE_SPONSORS.length === 0) return null; +const SPONSORS_PLACEMENTS = { + page: { + section: "space-y-10", + heading: "gap-2.5 text-3xl font-medium tracking-tight", + heart: "size-6", + link: "", + logo: "h-14", + description: "mt-2 text-sm text-white/55", + button: "h-12 px-5 text-sm", + }, + footer: { + section: "space-y-5 border-t border-white/10 py-8", + heading: "gap-1.5 text-sm text-extra-muted-foreground", + heart: "size-3", + link: "opacity-85 transition-opacity hover:opacity-100", + logo: "h-10", + description: "mt-1.5 text-xs text-white/45", + button: "h-9 px-3.5 text-sm", + }, +} as const; + +interface SponsorsSectionProps { + /** `page` is a homepage section; `footer` is the band above other pages' footers. */ + placement: keyof typeof SPONSORS_PLACEMENTS; +} + +/** + * The companies sponsoring Paseo: one monochrome logo per sponsor and, while + * spots are open, a quiet invitation. Every page shows it once. + */ +export function SponsorsSection({ placement }: SponsorsSectionProps) { + const style = SPONSORS_PLACEMENTS[placement]; + const open = openSpotCount(); return ( - <section> - <SectionHeading as="h2" title="Sponsors" /> - <div className="space-y-4"> - <SponsorLogoRow /> - {openSpotCount() > 0 ? ( - <a - href="/sponsor#spot" - className="inline-block text-sm text-white/50 underline transition-colors hover:text-white/80" - > - Become a sponsor - </a> + <section aria-labelledby="sponsors-heading" className={style.section}> + <h2 id="sponsors-heading" className={`flex items-center ${style.heading}`}> + <Heart + aria-hidden="true" + className={`fill-rose-300/80 text-rose-300/80 ${style.heart}`} + strokeWidth={1.5} + /> + Sponsors + </h2> + <ul className="flex flex-wrap items-center gap-x-14 gap-y-8"> + {SPONSORS.map((sponsor) => ( + <li key={sponsor.url}> + <a + href={sponsorHref(sponsor, "website")} + target="_blank" + rel="noopener noreferrer" + className={`block ${style.link}`} + > + <img + src={sponsor.logo.onDark} + alt={sponsor.name} + className={`${style.logo} w-auto`} + /> + </a> + <p className={style.description}>{sponsor.description}</p> + </li> + ))} + {open > 0 ? ( + <li> + <a + href="/sponsor#spot" + className={`inline-flex items-center rounded-md border border-dashed border-white/15 text-white/45 transition-colors hover:border-white/30 hover:text-white/75 ${style.button}`} + > + Become a sponsor + </a> + </li> ) : null} - </div> + </ul> </section> ); } diff --git a/packages/website/src/content/alternatives/claude-desktop.md b/packages/website/src/content/alternatives/claude-desktop.md index 0d607daea7..13a403301c 100644 --- a/packages/website/src/content/alternatives/claude-desktop.md +++ b/packages/website/src/content/alternatives/claude-desktop.md @@ -9,7 +9,7 @@ order: 55 Claude Desktop is Anthropic's app for Claude Chat, Cowork, and Claude Code on macOS, Windows, and Linux. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -45,7 +45,7 @@ Both Claude Desktop and Paseo are available on macOS, Windows, and Linux. ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Claude has iOS and Android apps. Dispatch can start local Claude Code work through an active Claude Desktop host or start a cloud session on Anthropic's infrastructure. @@ -84,7 +84,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase" Both tools support parallel coding sessions, including Git worktrees. -Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions. +Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions. ## Voice @@ -94,25 +94,31 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel ## Comparison -| | Paseo | Claude Desktop | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ | -| License | Open source (Apache-2.0) | Not published as open source | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile coding workflow | Native Paseo workspace on iOS and Android | Dispatch and Cowork in Claude mobile | -| Coding agents | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code | -| Product account required | No | Claude account | -| Cloud agent | Cloud waitlist | Claude Cowork and remote sessions | -| Required cloud connection | No | Claude account and services | -| Machines you control | Laptop, workstation, VM, server, or home lab | Local machine or SSH host | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | No | -| Split panes and tabs | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser / preview | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | PR monitoring and merge workflows | -| CLI | Run, `--host`, ls, send, schedule, loop | Claude Code CLI | -| Remote transport | Direct connection or end-to-end encrypted relay | Anthropic Remote and cloud services | -| Application plugins | Server code and native client components | No | -| Self-hosted control plane | Daemon, web client, and relay | No | +| | Paseo | Claude Desktop | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| License | Open source (Apache-2.0) | Not published as open source | +| Account required | No | Claude account | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Claude app (Dispatch and Cowork) | +| CLI | Yes (everything the app does) | Yes (Claude Code CLI) | +| Remote machines | Yes (install the daemon anywhere) | Yes (SSH host, Anthropic-managed cloud sessions) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Anthropic Remote | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | Yes | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | Yes (file editor) | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | PR monitoring and merge workflows | +| In-app browser | Yes (element picker, agent browser tools) | Yes (live app preview) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | Yes (scheduled tasks) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Yes (in Claude's mobile and app surfaces) | +| Telemetry | None | - | See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Paseo vs Conductor](/alternatives/conductor). diff --git a/packages/website/src/content/alternatives/codex-app.md b/packages/website/src/content/alternatives/codex-app.md index 36a3b6935b..7f985af7f8 100644 --- a/packages/website/src/content/alternatives/codex-app.md +++ b/packages/website/src/content/alternatives/codex-app.md @@ -9,7 +9,7 @@ order: 54 OpenAI provides Codex through the ChatGPT desktop app on macOS, Windows, and Linux, with local, worktree, cloud, and remote workflows. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -45,7 +45,7 @@ OpenAI's desktop app and Paseo are available on macOS, Windows, and Linux. ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. ChatGPT Remote can start and continue Codex chats, approve actions, and review outputs from iOS and Android through a connected desktop host. @@ -55,7 +55,7 @@ Both tools support Git worktrees for parallel work. Codex App creates Codex-managed worktrees under `$CODEX_HOME/worktrees` and supports local environment setup scripts and project actions through `.codex` configuration. -Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web.fix-auth.my-app.localhost` so parallel services don't fight for ports. +Paseo creates worktrees under `$PASEO_HOME/worktrees`, runs setup and teardown hooks from `paseo.json`, and gives each worktree its own dev server URLs like `web--fix-auth--my-app.localhost` so parallel services don't fight for ports. ## GitHub and review @@ -88,23 +88,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe ## Comparison -| | Paseo | Codex App | -| ------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------- | -| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile coding workflow | Native Paseo workspace on iOS and Android | ChatGPT Remote on iOS and Android | -| Providers | Codex, Claude Code, OpenCode, Pi + 30+ via ACP catalog + custom | Codex | -| Product account required | No | OpenAI account | -| Required Paseo cloud connection | No | Not applicable | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | No | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Commit, push, PR | -| CLI | Run, `--host`, ls, send, schedule, loop | Codex CLI | -| Remote transport | Direct connection or end-to-end encrypted relay | OpenAI Remote | -| Application plugins | Server code and native client components | No | -| Voice | Dictation and realtime voice | Dictation | -| Self-hosted control plane | Daemon, web client, and relay | No | +| | Paseo | Codex App | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| License | Open source (Apache-2.0) | Codex CLI is open source; application is proprietary | +| Account required | No | OpenAI account | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | ChatGPT app (Remote) | +| CLI | Yes (everything the app does) | Yes (Codex CLI) | +| Remote machines | Yes (install the daemon anywhere) | Yes (connected host, OpenAI-managed cloud) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | OpenAI Remote | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Codex | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | Commit, push, PR | +| In-app browser | Yes (element picker, agent browser tools) | Yes | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Dictation | +| Telemetry | None | - | See also: [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [all supported providers](/agents). diff --git a/packages/website/src/content/alternatives/conductor.md b/packages/website/src/content/alternatives/conductor.md index 9c0a30af34..553d4e4d28 100644 --- a/packages/website/src/content/alternatives/conductor.md +++ b/packages/website/src/content/alternatives/conductor.md @@ -9,7 +9,7 @@ order: 50 Conductor is a proprietary macOS app for running Claude Code, Codex, Cursor, and OpenCode in parallel Git worktrees and managed cloud workspaces. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -67,11 +67,11 @@ Conductor lists its API as a Pro feature but does not document a user-facing CLI Both tools isolate parallel agents in git worktrees. -Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web.fix-auth.my-app.localhost` and `web.add-search.my-app.localhost` instead of port collisions. +Paseo also gives each worktree its own dev server URL. Two agents running their dev servers at the same time get `web--fix-auth--my-app.localhost` and `web--add-search--my-app.localhost` instead of port collisions. ## Mobile -Paseo ships native iOS and Android apps today. Conductor lists its mobile app as coming soon under the Pro plan. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Conductor lists its mobile app as coming soon under the Pro plan. ## Voice @@ -79,21 +79,31 @@ Paseo supports local speech-to-text and text-to-speech. Conductor does not curre ## Comparison -| | Paseo | Conductor | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------ | -| License | Open source (Apache-2.0) | Closed source | -| Platforms | macOS, Linux, Windows | macOS only | -| Native mobile | iOS, Android | Coming soon under Pro | -| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex, Cursor, OpenCode | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | — | -| Split panes and tabs | Yes | — | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | — | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes | -| CLI | Run, `--host`, ls, send, schedule, loop | — | -| Application plugins | Server code and native client components | No | -| Local voice | Yes | Not documented | -| Self-hosted daemon | Yes | — | +| | Paseo | Conductor | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------- | +| License | Open source (Apache-2.0) | Closed source | +| Account required | No | Yes | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS only) | +| Mobile app | Yes (native, full parity with desktop) | Coming soon under Pro | +| CLI | Yes (everything the app does) | API under Pro | +| Remote machines | Yes (install the daemon anywhere) | No (managed cloud workspaces under Pro) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | No | +| SSH access | Yes | No | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex, Cursor, OpenCode | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | - | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub | +| In-app browser | Yes (element picker, agent browser tools) | - | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | - | +| Telemetry | None | - | See also: [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/content/alternatives/happy-coder.md b/packages/website/src/content/alternatives/happy-coder.md index b92a3f651e..0c5bbd1b32 100644 --- a/packages/website/src/content/alternatives/happy-coder.md +++ b/packages/website/src/content/alternatives/happy-coder.md @@ -9,7 +9,7 @@ order: 53 Happy Coder connects Claude Code and Codex sessions across its macOS, web, iOS, and Android apps. It wraps the agent CLI on your laptop and syncs sessions over an end-to-end encrypted relay. Open source under MIT. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -37,7 +37,7 @@ Paseo's app handles commit, push, opening PRs, watching checks and reviews, and ## Mobile -Both tools ship native iOS and Android apps. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. Happy Coder also ships native iOS and Android apps. ## Providers @@ -53,7 +53,7 @@ Happy Coder does not document an application extension API for adding both serve ## Worktrees and services -Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for the same port. +Paseo runs each agent in its own git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for the same port. Happy Coder can start an agent in a selected machine path, including an existing Git worktree. It does not currently create or manage the worktree lifecycle. @@ -79,21 +79,31 @@ Both products support voice interaction. Paseo can run speech-to-text and text-t ## Comparison -| | Paseo | Happy Coder | -| ---------------------------- | --------------------------------------------------------------------------------------- | --------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop app | macOS, Linux, Windows | macOS | -| Native mobile | iOS, Android | iOS, Android | -| Architecture | Daemon owns agent lifecycle | Wraps the agent CLI | -| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Claude Code, Codex | -| Split workspace | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser / preview | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | — | -| Managed Git worktrees | Yes | Existing worktree paths | -| Per-worktree dev server URLs | Yes | — | -| CLI | Run, `--host`, ls, send, schedule, loop | Launch and control sessions | -| Application plugins | Server code and native client components | No | -| Voice | Local or configured cloud speech | Yes | +| | Paseo | Happy Coder | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS) | +| Mobile app | Yes (native, full parity with desktop) | Yes (native) | +| CLI | Yes (everything the app does) | Yes (launch and control sessions) | +| Remote machines | Yes (install the daemon anywhere) | - | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (end-to-end encrypted) | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Claude Code, Codex | +| Parallel agents | Yes (isolated worktrees, across machines) | Existing worktree paths, no worktree management | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (wraps the agent CLI) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | - | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | - | +| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | - | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Yes | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/openchamber.md b/packages/website/src/content/alternatives/openchamber.md index f71c02ed46..bec7765588 100644 --- a/packages/website/src/content/alternatives/openchamber.md +++ b/packages/website/src/content/alternatives/openchamber.md @@ -51,26 +51,35 @@ OpenChamber's CLI runs and manages its server, remote access, and updates. Its A ## Mobile and voice -Both tools provide iOS and Android clients and support dictation and spoken replies. Paseo uses a React Native mobile interface. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView. +Both tools provide iOS and Android clients and support dictation and spoken replies. The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. OpenChamber packages its web interface with Capacitor and adds native integrations around the WebView. ## Comparison -| | Paseo | OpenChamber | -| ---------------------------- | --------------------------------------------------------------------------------------- | -------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Mobile implementation | React Native | Capacitor WebView | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode | -| Application plugins | Server code and native client components | No | -| Split panes and tabs | Yes | Workspace views | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Issue, PR, checks, review, merge | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Preview and port detection | -| Automation | CLI, SDK, MCP | Server CLI, Agent Control Tool | -| Schedules | Yes | Yes | -| Voice | Local dictation and realtime voice | Dictation and spoken replies | -| Self-hosted daemon | Yes | Yes | +| | Paseo | OpenChamber | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Yes (Capacitor WebView) | +| CLI | Yes (everything the app does) | Yes (runs and manages the server) | +| Remote machines | Yes (install the daemon anywhere) | Yes (server on another machine) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees, multi-run comparisons) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (Agent Control Tool, OpenCode agents only) | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub (issue, PR, checks, review, merge) | +| In-app browser | Yes (element picker, agent browser tools) | Yes (previews) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Preview and port detection | +| Schedules and heartbeats | Yes | Yes (schedules) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | Dictation and spoken replies | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/content/alternatives/opencode-desktop.md b/packages/website/src/content/alternatives/opencode-desktop.md index 5321dd3ae7..d80d969aa5 100644 --- a/packages/website/src/content/alternatives/opencode-desktop.md +++ b/packages/website/src/content/alternatives/opencode-desktop.md @@ -9,7 +9,7 @@ order: 56 OpenCode Desktop is the desktop app for OpenCode. It is available in beta for macOS, Windows, and Linux. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -43,7 +43,7 @@ Paseo ships on macOS, Linux, and Windows. OpenCode provides beta desktop builds ## Mobile -Paseo ships native iOS and Android apps with the same agent workflow as the desktop app. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. OpenCode Desktop is a desktop app. OpenCode also has web and share-link workflows, but not a native mobile app. @@ -78,7 +78,7 @@ paseo schedule create --cron "0 9 * * 1" "audit the codebase" ## Worktrees and services -Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web.fix-auth.my-app.localhost`, so parallel agents don't fight for ports. +Paseo runs each agent in its own Git worktree. Each worktree gets its own dev server URL like `web--fix-auth--my-app.localhost`, so parallel agents don't fight for ports. OpenCode supports multi-session work on the same project. If you want worktree isolation around OpenCode sessions, Paseo can provide that by launching OpenCode inside Paseo workspaces. @@ -94,23 +94,31 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe ## Comparison -| | Paseo | OpenCode Desktop | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Native mobile | iOS, Android | No | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | OpenCode | -| Multi-model support | Through supported agent harnesses | Through OpenCode providers | -| Git worktrees | Yes | No built-in worktree manager | -| Per-worktree dev server URLs | Yes | No | -| Split panes and tabs | Yes | Desktop sessions | -| In-app terminal | Yes | OpenCode terminal workflow | -| In-app browser | Yes | No | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | GitHub integration | -| CLI | Run, `--host`, ls, send, schedule, loop | OpenCode CLI | -| MCP server for orchestration | Yes | MCP support inside OpenCode | -| Application plugins | Server code and native client components | No | -| Local voice | Yes | No | -| Self-hosted daemon | Yes | OpenCode server / local runtime | +| | Paseo | OpenCode Desktop | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | - | +| Desktop app | Yes (one click install, daemon bundled) | Yes (beta, macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | No | +| CLI | Yes (everything the app does) | Yes (OpenCode CLI) | +| Remote machines | Yes (install the daemon anywhere) | OpenCode server | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | - | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | OpenCode (many model providers) | +| Parallel agents | Yes (isolated worktrees, across machines) | Multiple sessions, no built-in worktree manager | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | - | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | MCP support inside OpenCode | +| Editor | Yes | - | +| Terminals | Yes | OpenCode terminal workflow | +| Diff review | Yes (comments go to the agent) | - | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab integrations | +| In-app browser | Yes (element picker, agent browser tools) | No | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | No | +| Schedules and heartbeats | Yes | - | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | No | +| Telemetry | None | - | See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/orca.md b/packages/website/src/content/alternatives/orca.md index d47ce3c218..c939d49645 100644 --- a/packages/website/src/content/alternatives/orca.md +++ b/packages/website/src/content/alternatives/orca.md @@ -1,6 +1,6 @@ --- -title: Orca ADE Alternative With Agent Chat, a No-Account Relay, and Plugins -description: Paseo is an Orca alternative for developers who want a chat-first interface, a daemon they can reach from any client, and plugins that run on the server and every client. +title: Orca Alternative for Agentic Development +description: Paseo is an open source agentic development environment with parallel worktrees, an editor, terminals, diffs, pull requests, and a built-in browser. nav: Orca order: 57 --- @@ -9,7 +9,7 @@ order: 57 Orca is an MIT-licensed desktop app for running terminal coding agents in parallel Git worktrees. It includes a mobile companion app, SSH worktrees, remote Orca servers, an embedded browser, and a CLI. -Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0). +Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0. ![Paseo desktop and mobile app](/hero-mockup.png) @@ -17,13 +17,13 @@ Paseo is an app for orchestrating coding agents, with native clients on desktop, Orca is built around terminals. Each agent runs in a terminal pane, and an experimental Chat UI can show supported agents as a chat transcript. Orca ships a broad set of built-in tools around those terminals. -Paseo is built around agent chat. It keeps a small core for starting agents, following their work, giving direction, and reviewing results, and adds everything else through plugins that run on the daemon and every client. +Paseo is built around agent chat. The app ships what every developer uses: editor, terminals, diffs, pull requests, and a browser. Features only some teams need, like issue boards or cloud VMs, are plugins you install, so nothing you do not use is in the way. Orca is made by Stably AI, a venture-funded company (Y Combinator). Paseo is independent and open source. ## Architecture and remote machines -The Paseo daemon runs as its own process. Desktop, web, mobile, and CLI clients connect to it directly, over SSH, or through the optional end-to-end encrypted relay. Run the daemon on your laptop, a VM, a home server, or in Docker, and connect to any of them from any client. See [connectivity](/docs/connectivity). +The Paseo desktop app starts and manages a local daemon automatically. That daemon owns the agents and workspaces, so other clients can connect to the same development environment. Desktop, web, mobile, and CLI clients connect to it directly, over SSH, or through the optional end-to-end encrypted relay. Run the daemon on your laptop, a VM, a home server, or in Docker, and connect to any of them from any client. See [connectivity](/docs/connectivity). Orca runs inside its desktop app by default and reaches other machines in two ways. SSH worktrees keep the Orca runtime on your laptop and run selected worktrees and terminals on a remote host. Remote Orca Servers, in beta, run Orca desktop or `orca serve` on another machine and let clients pair to it. Orca's documentation recommends Tailscale or another private network for Remote Orca Servers. @@ -31,7 +31,7 @@ Orca runs inside its desktop app by default and reaches other machines in two wa Both tools have iOS and Android apps. -Paseo ships native apps on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale. +The mobile app is the full app, native on iOS and Android, with full feature parity with desktop. They are on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale. Orca's mobile companion is in beta, on the App Store for iOS and as an APK download for Android. It can reply to agents, open sessions as a terminal or Chat UI, commit changes, and create workspaces on the paired desktop. Orca Relay, also in beta, connects the phone when it is not on the same network as the desktop and requires signing in to an Orca account. Without Orca Relay, the phone needs a LAN or Tailscale path to the desktop. @@ -51,7 +51,7 @@ Paseo runs Claude Code, Codex, OpenCode, Pi, Antigravity, and Muse Code through [Paseo plugins](/docs/plugins) extend Paseo itself. A plugin can add workspace panels, sidebar items, Command Center items, slash commands, composer pills, settings screens, themes, attachment sources, and agent providers. Its server code runs beside the daemon, and its client code runs in every connected client: desktop, browser, iOS, and Android. -Community plugins are listed at [paseo.cafe](https://paseo.cafe). +Community plugins are listed at [paseo.sh/plugins](/plugins). Orca has an experimental plugin system that is off by default. Orca plugins can add desktop panels rendered in a sandboxed frame, commands, keybindings, language packs, VM recipes, and agent profiles, and can be installed from git marketplaces. Plugin workers run on the desktop computer, and Orca does not document plugin surfaces in its mobile app. @@ -59,13 +59,13 @@ Orca has an experimental plugin system that is off by default. Orca plugins can Orca includes many tools in the core app: a Monaco code editor, Design Mode for sending page elements from its browser to an agent, computer use, Linear and Jira boards, agent account switching with usage tracking, and per-workspace cloud VM recipes. If you want these in one app without extensions, Orca provides them. -Paseo keeps a smaller core. A capability that serves a specialized workflow is meant to be a plugin, so the default app stays short to learn and each plugin can be maintained on its own schedule. +Paseo includes an editor, terminals, diff review with comments that go to the agent, pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg, a browser with an element picker and agent browser tools, plan usage, schedules, and heartbeats. Boards and cloud VMs are what a plugin adds. ## Workspaces and review Both tools provide Git worktrees, split panes, terminals, diff review, an in-app browser, pull-request workflows, and scheduled agent runs. -Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. Orca adds diff annotations that you send back to the agent, port forwarding for SSH worktrees, and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub. +Paseo has diff review with comments that go to the agent and opens pull requests on GitHub, GitLab, Gitea, Forgejo, and Codeberg. It also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports. Orca has port forwarding for SSH worktrees and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub. ## Automation @@ -89,24 +89,31 @@ Orca does not require an account for local use. Orca Relay and shared artifacts ## Comparison -| | Paseo | Orca | -| ---------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------- | -| License | Open source (Apache-2.0) | Open source (MIT) | -| Funding | Independent | Venture-funded (Y Combinator) | -| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows | -| Native mobile | iOS, Android | iOS, Android APK (beta) | -| Main interface | Agent chat | Terminal, with experimental Chat UI | -| Remote machines | Daemon on any machine, direct, SSH, or relay | SSH worktrees, Remote Orca Server | -| Relay | Optional, end-to-end encrypted, no account | Beta, Orca account required | -| Agent harnesses | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code + 30+ via ACP catalog + custom | Any CLI agent in a terminal | -| Application plugins | Server code and native client components on every client | Experimental, desktop only | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes, with Design Mode | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes, plus GitLab, Bitbucket, and more | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Port forwarding for SSH worktrees | -| Automation | CLI, SDK, MCP | CLI, automations | -| Voice | Local dictation and realtime voice | Dictation | -| Telemetry | None | Anonymous usage data, opt-out | +| | Paseo | Orca | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| License | Open source (Apache-2.0) | Open source (MIT) | +| Account required | No | No for local use, Orca account for relay | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, Linux, Windows) | +| Mobile app | Yes (native, full parity with desktop) | Yes (companion app, beta) | +| CLI | Yes (everything the app does) | Yes (worktrees, browser, automations) | +| Remote machines | Yes (install the daemon anywhere) | Yes (SSH worktrees, Remote Orca Server in beta) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Yes (beta, Orca account required) | +| Direct network access | Yes (LAN, Tailscale, VPN) | Yes (LAN, Tailscale) | +| SSH access | Yes | Yes (SSH worktrees) | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | Any CLI agent in a terminal, 30+ listed | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (main interface, experimental Chat UI) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (agents call the Orca CLI through installed skills) | +| Editor | Yes | Yes (Monaco) | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub, GitLab, Bitbucket, Azure DevOps, Gitea | +| In-app browser | Yes (element picker, agent browser tools) | Yes (Design Mode) | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port forwarding for SSH worktrees | +| Schedules and heartbeats | Yes | Yes (scheduled agent runs) | +| Plan usage | Yes | Yes (usage tracking, account switching) | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | Experimental, desktop only | +| Voice | Yes (local dictation, realtime voice) | Dictation | +| Telemetry | None | Anonymous usage data, opt-out | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber). diff --git a/packages/website/src/content/alternatives/superset.md b/packages/website/src/content/alternatives/superset.md index 3dc33113ce..30877bc79e 100644 --- a/packages/website/src/content/alternatives/superset.md +++ b/packages/website/src/content/alternatives/superset.md @@ -51,7 +51,7 @@ Both tools provide Git worktrees, split panes, terminals, diff review, an in-app Paseo gives supported providers a structured chat interface with modes, slash commands, tool calls, and file attachments. Superset can run any CLI agent in terminal panes and adds lifecycle status for supported agents. -Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. +Paseo also gives each worktree its own service URL, such as `web--fix-auth--my-app.localhost`, so parallel development servers do not compete for ports. ## Automation @@ -65,23 +65,31 @@ Superset has a free individual tier. Team features, remote access, and integrati ## Comparison -| | Paseo | Superset | -| ---------------------------- | ---------------------------------------- | -------------------------------------- | -| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) | -| Desktop platforms | macOS, Linux, Windows | macOS, experimental Linux | -| Native mobile | iOS, Android | Coming soon | -| Account required | No | Yes, with GitHub sign-in | -| Agent harnesses | Native, ACP, and custom CLI | CLI agents | -| Application plugins | Server code and native client components | No | -| Split panes and tabs | Yes | Yes | -| In-app terminal | Yes | Yes | -| In-app browser | Yes | Yes | -| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes | -| Git worktrees | Yes | Yes | -| Per-worktree dev server URLs | Yes | Port detection | -| Automation | CLI, SDK, MCP | CLI, SDK, MCP | -| Schedules | Yes | Yes | -| Local voice | Dictation and realtime voice | No documented voice support | -| Self-hosted daemon | Yes | Host server | +| | Paseo | Superset | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| License | Open source (Apache-2.0) | Source-available (Elastic License 2.0) | +| Account required | No | Yes, with GitHub sign-in | +| Desktop app | Yes (one click install, daemon bundled) | Yes (macOS, experimental Linux) | +| Mobile app | Yes (native, full parity with desktop) | Coming soon | +| CLI | Yes (everything the app does) | Yes (plus SDK and MCP server) | +| Remote machines | Yes (install the daemon anywhere) | Yes (host server, remote access through Superset cloud on paid plans) | +| Built-in relay | Yes (opt-in, end-to-end encrypted, no account) | Superset cloud services (paid plans) | +| Direct network access | Yes (LAN, Tailscale, VPN) | - | +| SSH access | Yes | - | +| Providers | Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, 30+ more | CLI agents, custom terminal agents | +| Parallel agents | Yes (isolated worktrees, across machines) | Yes (Git worktrees) | +| Terminal agents | Yes (run any agent in a terminal, get notified when it finishes) | Yes (lifecycle status for supported agents) | +| Agent orchestration | Yes (agents create worktrees and launch other agents, across providers) | Yes (CLI, SDK, and MCP server create workspaces and launch agents) | +| Editor | Yes | - | +| Terminals | Yes | Yes | +| Diff review | Yes (comments go to the agent) | Yes | +| Pull requests in app | GitHub, GitLab, Gitea, Forgejo, Codeberg | GitHub | +| In-app browser | Yes (element picker, agent browser tools) | Yes | +| Per-worktree dev server URLs | Yes (`web--fix-auth--my-app.localhost`) | Port detection | +| Schedules and heartbeats | Yes | Yes (schedules) | +| Plan usage | Yes | - | +| Plugins | Yes (new screens, panels, agent hooks, and providers, one plugin runs on desktop, web, and mobile) | No | +| Voice | Yes (local dictation, realtime voice) | - | +| Telemetry | None | - | See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder). diff --git a/packages/website/src/data/sponsors.test.ts b/packages/website/src/data/sponsors.test.ts new file mode 100644 index 0000000000..f586cb9557 --- /dev/null +++ b/packages/website/src/data/sponsors.test.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { SPONSORS, sponsorHref } from "./sponsors"; + +const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); +const WEBSITE_PUBLIC = fileURLToPath(new URL("../../public", import.meta.url)); +const READMES = ["README.md", "README.zh-CN.md", "README.ja.md", "README.ko.md"]; +const README_SPONSOR_CELLS = 3; +const SPONSOR_PAGE_URL = "https://paseo.sh/sponsor"; + +describe("sponsorHref", () => { + it("tags the link with the placement it is shown in", () => { + const sponsor = { + name: "Acme", + url: "https://acme.dev", + logo: { onDark: "", onLight: "" }, + description: "", + }; + + expect(sponsorHref(sponsor, "website")).toBe( + "https://acme.dev/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor", + ); + expect(sponsorHref(sponsor, "x")).toBe( + "https://acme.dev/?utm_source=paseo&utm_medium=x&utm_campaign=sponsor", + ); + }); +}); + +function readmeSponsorCells(readme: string) { + const text = readFileSync(`${REPO_ROOT}${readme}`, "utf8"); + const table = text.slice(text.indexOf("<!-- Sponsors:")).split("</table>")[0]; + return [...table.matchAll(/<td[^>]*>(.*?)<\/td>/g)].map(([, cell]) => { + const logo = cell.match( + /^<a href="([^"]+)"><picture><source media="\(prefers-color-scheme: dark\)" srcset="([^"]+)"><img src="([^"]+)"[^>]*><\/picture><\/a><br><sub>([^<]+)<\/sub>$/, + ); + if (logo) { + const [, href, onDark, onLight, description] = logo; + return { href, onDark, onLight, description }; + } + return { invite: cell.match(/^<a href="([^"]+)">/)?.[1] }; + }); +} + +describe("SPONSORS", () => { + it.each(SPONSORS)("ships both logo inks for $name", (sponsor) => { + expect(existsSync(`${WEBSITE_PUBLIC}${sponsor.logo.onDark}`)).toBe(true); + expect(existsSync(`${WEBSITE_PUBLIC}${sponsor.logo.onLight}`)).toBe(true); + }); + + it.each(READMES)("fill the sponsors table in %s, followed by invitations", (readme) => { + const openCells = Math.max(0, README_SPONSOR_CELLS - SPONSORS.length); + + expect(readmeSponsorCells(readme)).toEqual([ + ...SPONSORS.map((sponsor) => ({ + href: sponsorHref(sponsor, "readme"), + onDark: `packages/website/public${sponsor.logo.onDark}`, + onLight: `packages/website/public${sponsor.logo.onLight}`, + description: sponsor.description, + })), + ...Array.from({ length: openCells }, () => ({ invite: SPONSOR_PAGE_URL })), + ]); + }); +}); diff --git a/packages/website/src/data/sponsors.ts b/packages/website/src/data/sponsors.ts index 22466c09d1..94d1e4b51a 100644 --- a/packages/website/src/data/sponsors.ts +++ b/packages/website/src/data/sponsors.ts @@ -1,31 +1,59 @@ /** - * Homepage sponsor spots. This file is the inventory: the site shows one open - * placeholder for every spot not listed in `HOMEPAGE_SPONSORS`. + * Company sponsors. This file is the inventory: every page of the site shows + * these logos above the footer, and the open spots invite the next sponsor. * * Approving a sponsor means three edits, or the site promises something it does * not deliver: * - * 1. Add the entry below and drop its logo in `public/sponsors/`. - * 2. Add the logo under the `## Sponsors` marker in `README.md`, - * `README.zh-CN.md`, `README.ja.md` and `README.ko.md`, in this same order. + * 1. Add the entry below and drop both logo inks in `public/sponsors/`. + * 2. In the sponsors table after the intro of `README.md`, `README.zh-CN.md`, + * `README.ja.md` and `README.ko.md`, replace a "become a sponsor" cell with + * the logo and its description, in this same order, linked to `sponsorHref(sponsor, "readme")` + * with both inks referenced from `packages/website/public`, so the README + * renders from the repository and never waits on a site deploy. + * `sponsors.test.ts` fails until they match. * 3. Remove all of it when the subscription ends. * - * The Stripe payment link caps itself at `HOMEPAGE_SPOT_COUNT` completed + * The Stripe payment link caps itself at `SPONSOR_SPOT_COUNT` completed * checkouts, and a checkout you decline and refund still counts against that * cap. When you refund one, raise the link's completed-session limit by one, or * the page will offer a spot the link refuses to sell. */ -export interface HomepageSponsor { +export interface Sponsor { name: string; - href: string; - /** Path under `public/`, e.g. `/sponsors/acme.svg`. Keep logos monochrome white. */ - logo: string; + /** The sponsor's own URL, without tracking. Links go through `sponsorHref`. */ + url: string; + /** Paths under `public/`, one per ground. Use the sponsor's official lockup. */ + logo: { onDark: string; onLight: string }; + /** What the product is, in a few words from the sponsor's own site. Shown under the logo. */ + description: string; } -export const HOMEPAGE_SPONSORS: ReadonlyArray<HomepageSponsor> = []; +export const SPONSORS: ReadonlyArray<Sponsor> = [ + { + name: "Hoplite", + url: "https://hoplite.sh", + logo: { + onDark: "/sponsors/hoplite-on-dark.svg", + onLight: "/sponsors/hoplite-on-light.svg", + }, + description: "Cloud coding agents", + }, +]; -export const HOMEPAGE_SPOT_COUNT = 4; -export const HOMEPAGE_SPOT_PRICE = "$500"; +/** Where a sponsor link is shown. Becomes the link's `utm_medium`. */ +export type SponsorPlacement = "website" | "readme" | "discord" | "x"; + +export function sponsorHref(sponsor: Sponsor, placement: SponsorPlacement): string { + const url = new URL(sponsor.url); + url.searchParams.set("utm_source", "paseo"); + url.searchParams.set("utm_medium", placement); + url.searchParams.set("utm_campaign", "sponsor"); + return url.toString(); +} + +export const SPONSOR_SPOT_COUNT = 4; +export const SPONSOR_SPOT_PRICE = "$500"; /** Stripe Payment Link for the monthly spot subscription. */ export const SPONSOR_SPOT_CHECKOUT_URL = "https://buy.stripe.com/8x24gBczR7LNaokcve2sM00"; @@ -38,5 +66,5 @@ export const OPEN_COLLECTIVE_URL = export const BUY_ME_A_COFFEE_URL = "https://buymeacoffee.com/paseo"; export function openSpotCount(): number { - return Math.max(0, HOMEPAGE_SPOT_COUNT - HOMEPAGE_SPONSORS.length); + return Math.max(0, SPONSOR_SPOT_COUNT - SPONSORS.length); } diff --git a/packages/website/src/llms.ts b/packages/website/src/llms.ts index 20e414f9dd..32a1f6dcac 100644 --- a/packages/website/src/llms.ts +++ b/packages/website/src/llms.ts @@ -6,13 +6,15 @@ const SITE_URL = "https://paseo.sh"; const PRODUCT_PREAMBLE = `# Paseo -> Mobile and desktop app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. +> Open source agentic development environment for desktop, mobile, web, and CLI. -Paseo is an open source application that lets you run AI coding agents on your own machine and drive them from your phone, desktop, browser, or terminal. Your code stays local — Paseo connects directly to your real development environment instead of running agents in someone else's cloud. +Paseo is a full agentic development environment. For agent-driven development, it replaces your IDE: run coding agents, edit files, review diffs and pull requests, run tests in terminals, and check your app in the built-in desktop browser. Arrange agents, files, terminals, and browser tabs in split panes within a workspace. -A self-hosted daemon manages agent lifecycle, exposes a WebSocket API, and ships with an MCP server so other agents can talk to it. Native apps for iOS, Android, macOS, Windows, Linux, and the web let you launch sessions, watch them work, review diffs, and ship from anywhere. A Docker-style CLI ("paseo run", "paseo ls", "paseo logs", "paseo wait") gives you scripting access. An end-to-end encrypted relay lets the mobile app reach your daemon over the public internet without exposing it. +Run many agents in parallel, each in its own git worktree and branch. Choose a provider and model for each task, inspect the results, and decide what to merge. Agents can also create worktrees, launch other agents, and communicate with them through Paseo's orchestration tools. -Paseo supports every major coding agent: Claude Code, Codex, GitHub Copilot, OpenCode, Antigravity, Muse Code, Cursor, Gemini, Cline, Goose, Amp, Aider, and 30+ others. Each agent runs as its own process; Paseo handles I/O, persistence, git worktree isolation, schedules, and skills. +Agents run on your own machines with your existing tools, configuration, and credentials. Work from desktop, the full native iOS and Android app, a web browser, or the CLI. The desktop app starts its local daemon automatically. For remote work, run the daemon on another machine and connect directly or through the optional end-to-end encrypted relay. + +Paseo supports Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Muse Code, and additional ACP-compatible agents. The CLI, TypeScript SDK, and MCP tools expose agent and workspace operations for automation. Plugins extend providers, workflows, and the app's interface. Distribution: native apps for Mac, Windows, Linux, iOS, and Android; web app; Homebrew; npm. Source: Apache-2.0 at https://github.com/getpaseo/paseo. Marketing site: https://paseo.sh. `; diff --git a/packages/website/src/meta.ts b/packages/website/src/meta.ts index a604f256f9..330b83611f 100644 --- a/packages/website/src/meta.ts +++ b/packages/website/src/meta.ts @@ -1,6 +1,6 @@ const SITE_ORIGIN = "https://paseo.sh"; -export function pageMeta(title: string, description: string, path: string) { +export function pageMeta(title: string, description: string, path: string, image?: string) { const url = `${SITE_ORIGIN}${path}`; return { meta: [ @@ -9,6 +9,12 @@ export function pageMeta(title: string, description: string, path: string) { { property: "og:title", content: title }, { property: "og:description", content: description }, { property: "og:url", content: url }, + ...(image + ? [ + { property: "og:image", content: image }, + { name: "twitter:image", content: image }, + ] + : []), { name: "twitter:title", content: title }, { name: "twitter:description", content: description }, ], diff --git a/packages/website/src/plugins/author-link.tsx b/packages/website/src/plugins/author-link.tsx new file mode 100644 index 0000000000..3a2be803f3 --- /dev/null +++ b/packages/website/src/plugins/author-link.tsx @@ -0,0 +1,52 @@ +import { Link } from "@tanstack/react-router"; +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; + +export function AuthorAvatar({ author, size }: { author: Author; size: keyof typeof SIZE_CLASS }) { + const url = authorAvatarUrl(author); + const className = `flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/10 bg-white/[0.06] font-medium text-white/60 ${SIZE_CLASS[size]}`; + if (url) { + return ( + <span className={className}> + <img src={url} alt="" loading="lazy" className="h-full w-full object-cover" /> + </span> + ); + } + return ( + <span aria-hidden className={className}> + {author.name.charAt(0).toUpperCase()} + </span> + ); +} + +const DEFAULT_CLASS = + "inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"; + +/** Link to the author's plugin list. Renders avatar and name unless children are given. */ +export function AuthorLink({ + author, + className = DEFAULT_CLASS, + children, +}: { + author: Author; + className?: string; + children?: ReactNode; +}) { + const params = useMemo(() => ({ owner: author.username }), [author.username]); + return ( + <Link to="/plugins/$owner" params={params} className={className}> + {children ?? ( + <> + <AuthorAvatar author={author} size="sm" /> + {author.name} + </> + )} + </Link> + ); +} diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx new file mode 100644 index 0000000000..38b6393662 --- /dev/null +++ b/packages/website/src/plugins/browse-page.tsx @@ -0,0 +1,250 @@ +import { useMemo } from "react"; +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, + type CategorySlug, + getCategory, + getPluginsInCategory, + mostInstalled, + newestFirst, + type Plugin, + recentlyUpdated, + searchPlugins, + updatedAgo, +} from "./registry"; +import { WindowSwitch } from "./window-switch"; + +const NAV_BASE = + "flex items-center justify-between gap-3 rounded-md px-2.5 py-1.5 text-sm transition-colors"; +const NAV_ON = `${NAV_BASE} bg-white/[0.06] text-white`; +const NAV_OFF = `${NAV_BASE} text-muted-foreground hover:text-foreground`; +const CHIP_BASE = + "inline-flex flex-shrink-0 items-center rounded-full border px-3 py-1.5 text-xs transition-colors"; +const CHIP_ON = `${CHIP_BASE} border-white/20 bg-white/[0.07] text-white`; +const CHIP_OFF = `${CHIP_BASE} border-white/10 text-muted-foreground`; +const TAB_BASE = "-mb-px border-b pb-2 text-sm transition-colors"; +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"; + +/** All plugins or one category: sidebar, Most installed / Newest / Recently updated tabs, and the card grid. */ +export function BrowsePage({ + plugins, + installs, + now, + query, +}: { + plugins: Plugin[]; + installs: Record<string, InstallCounts>; + now: string; + query: BrowseQuery; +}) { + const category = query.category ? getCategory(query.category) : null; + 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} live className="mb-4 lg:mb-6" /> + <CategoryNav plugins={matches} query={query} /> + </aside> + <div className="min-w-0 flex-1"> + <div className="mb-6 flex items-baseline justify-between gap-4"> + <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"> + {results.length} + </span> + </h1> + {query.q && ( + <a href={clearHref} className={CLEAR_CLASS}> + Clear + </a> + )} + </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; + chip: string; + /** Absent for all plugins. */ + slug?: CategorySlug; + count: number; +} + +/** Category sidebar on wide screens, a row of chips on narrow ones. Counts follow the search. */ +function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery }) { + const entries: NavEntry[] = [ + { + href: browseHref({ ...query, category: undefined }), + label: "All plugins", + chip: "All", + count: plugins.length, + }, + ...CATEGORIES.map((category) => ({ + href: browseHref({ ...query, category: category.slug }), + label: category.label, + chip: category.label, + slug: category.slug, + count: getPluginsInCategory(plugins, category.slug).length, + })), + ]; + return ( + <> + <nav aria-label="Categories" className="-ml-2.5 hidden lg:block"> + {entries.map((entry, index) => ( + <div key={entry.label}> + <a + href={entry.href} + aria-current={query.category === entry.slug ? "page" : undefined} + className={query.category === entry.slug ? NAV_ON : NAV_OFF} + > + {entry.label} + <span className="text-xs tabular-nums text-extra-muted-foreground"> + {entry.count} + </span> + </a> + {index === 0 && <div className="my-3 h-px bg-white/10" />} + </div> + ))} + <div className="my-3 h-px bg-white/10" /> + <div className="flex flex-col gap-1.5 px-2.5"> + <ContributeLinks /> + </div> + </nav> + <nav aria-label="Categories" className="-mx-6 flex gap-2 overflow-x-auto px-6 pb-1 lg:hidden"> + {entries.map((entry) => ( + <a + key={entry.label} + href={entry.href} + aria-current={query.category === entry.slug ? "page" : undefined} + className={query.category === entry.slug ? CHIP_ON : CHIP_OFF} + > + {entry.chip} + </a> + ))} + </nav> + </> + ); +} + +function SortTabs({ query }: { query: BrowseQuery }) { + const windowHrefs = useMemo( + () => ({ + week: browseHref({ ...query, window: "week" }), + month: browseHref({ ...query, window: "month" }), + all: browseHref({ ...query, window: "all" }), + }), + [query], + ); + const windowSwitch = query.sort === "installs" && ( + <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 + href={browseHref({ ...query, sort: "installs" })} + aria-current={query.sort === "installs" ? "page" : undefined} + className={query.sort === "installs" ? TAB_ON : TAB_OFF} + > + Most installed + </a> + <a + href={browseHref({ ...query, sort: "new" })} + aria-current={query.sort === "new" ? "page" : undefined} + className={query.sort === "new" ? TAB_ON : TAB_OFF} + > + 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/categories.ts b/packages/website/src/plugins/categories.ts new file mode 100644 index 0000000000..96fc836bf4 --- /dev/null +++ b/packages/website/src/plugins/categories.ts @@ -0,0 +1,52 @@ +export const CATEGORIES = [ + { + slug: "daemon-management", + label: "Daemon management", + description: "Manage hosts, daemon jobs, resources, approvals, and MCP servers.", + }, + { + slug: "themes", + label: "Themes", + description: "Change the app color scheme.", + }, + { + slug: "providers", + label: "Providers", + description: + "Connect coding agents, ACP servers, and model routers, with account and quota fallback.", + }, + { + slug: "orchestration", + label: "Orchestration", + description: "Coordinate agents, delegate work, and send messages across hosts.", + }, + { + slug: "git", + label: "Git", + description: "Work with branches, worktrees, pull requests, stacks, diffs, and reviews.", + }, + { + slug: "workspaces", + label: "Workspaces", + description: "Add workspace panels and tabs for boards, browsers, documents, and terminals.", + }, + { + slug: "sidebar", + label: "Sidebar", + description: "Add sidebar items and panels for usage, notifications, pricing, and links.", + }, + { + slug: "extras", + label: "Extras", + description: "Add presence, pets, puzzles, wellbeing reminders, and demos.", + }, + { + slug: "utils", + label: "Utils", + description: + "Extend the composer and timeline with commands, prompts, skills, and small helpers.", + }, +] as const; + +export type CategorySlug = (typeof CATEGORIES)[number]["slug"]; +export type Category = (typeof CATEGORIES)[number]; diff --git a/packages/website/src/plugins/contribute-links.tsx b/packages/website/src/plugins/contribute-links.tsx new file mode 100644 index 0000000000..705615770d --- /dev/null +++ b/packages/website/src/plugins/contribute-links.tsx @@ -0,0 +1,57 @@ +import { DOCS_URL, SUBMIT_URL } from "./links"; + +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"; + +/** "Read the docs" and "Submit a plugin", as two quiet links. */ +export function ContributeLinks() { + return ( + <> + <a href={DOCS_URL} className={LINK_CLASS}> + Read the docs + </a> + <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/index.ts b/packages/website/src/plugins/index.ts new file mode 100644 index 0000000000..d0c892ed8c --- /dev/null +++ b/packages/website/src/plugins/index.ts @@ -0,0 +1,2 @@ +export * from "./registry"; +export { getRegistry, getRegistryPlugin } from "./loader"; 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 new file mode 100644 index 0000000000..78b4294639 --- /dev/null +++ b/packages/website/src/plugins/installs.ts @@ -0,0 +1,93 @@ +// Install counts live in the website KV. The all-time counter predates the daily record and +// stays the "All time" number; the daily record keeps the last 60 days for the shorter windows. +export type InstallWindow = "week" | "month" | "all"; +export type InstallCounts = Record<InstallWindow, number>; +/** UTC day (YYYY-MM-DD) to installs that day. */ +export type DailyInstalls = Record<string, number>; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DAYS_KEPT = 60; +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([ + cache.put(totalKey(id), String(Number(total ?? 0) + 1)), + cache.put(dailyKey(id), JSON.stringify(addInstall(daily, now))), + ]); +} + +export async function readInstallCounts( + cache: KVNamespace | null, + ids: string[], + now: Date, +): Promise<Record<string, InstallCounts>> { + const entries = await Promise.all( + ids.map(async (id) => { + if (!cache) return [id, countInstalls({}, 0, now)] as const; + const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]); + return [id, countInstalls(daily, Number(total ?? 0), now)] as const; + }), + ); + return Object.fromEntries(entries); +} + +export function addInstall(daily: DailyInstalls, now: Date): DailyInstalls { + const oldest = utcDay(now, DAYS_KEPT - 1); + const kept = Object.fromEntries(Object.entries(daily).filter(([day]) => day >= oldest)); + const today = utcDay(now, 0); + kept[today] = (kept[today] ?? 0) + 1; + return kept; +} + +export function countInstalls(daily: DailyInstalls, total: number, now: Date): InstallCounts { + return { + week: installsSince(daily, utcDay(now, WINDOW_DAYS.week - 1)), + month: installsSince(daily, utcDay(now, WINDOW_DAYS.month - 1)), + all: total, + }; +} + +function installsSince(daily: DailyInstalls, oldest: string): number { + return Object.entries(daily).reduce( + (sum, [day, count]) => (day >= oldest ? sum + count : sum), + 0, + ); +} + +function utcDay(now: Date, daysBefore: number): string { + return new Date(now.getTime() - daysBefore * DAY_MS).toISOString().slice(0, 10); +} + +async function readDaily(cache: KVNamespace, id: string): Promise<DailyInstalls> { + const value: unknown = await cache.get(dailyKey(id), { type: "json" }); + if (typeof value !== "object" || value === null) return {}; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, number] => typeof entry[1] === "number", + ), + ); +} diff --git a/packages/website/src/plugins/launch.ts b/packages/website/src/plugins/launch.ts new file mode 100644 index 0000000000..bfe0d00385 --- /dev/null +++ b/packages/website/src/plugins/launch.ts @@ -0,0 +1 @@ +export const PLUGINS_LINKED = true; diff --git a/packages/website/src/plugins/links.ts b/packages/website/src/plugins/links.ts new file mode 100644 index 0000000000..18eab1535f --- /dev/null +++ b/packages/website/src/plugins/links.ts @@ -0,0 +1,48 @@ +import type { CategorySlug } from "./categories"; +import type { InstallWindow } from "./installs"; + +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" | "updated"; +export interface BrowseQuery { + /** Absent for all plugins. */ + category?: CategorySlug; + sort: BrowseSort; + window: InstallWindow; + /** Search term; absent when not searching. */ + q?: string; +} + +export const DEFAULT_WINDOW: InstallWindow = "week"; + +export function parseWindow(value: unknown): InstallWindow | undefined { + return value === "month" || value === "all" ? value : undefined; +} +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 === "updated" ? value : undefined; +} + +export function pluginHref(id: string): string { + return `/plugins/${id}`; +} +export function categoryHref(category: CategorySlug): string { + return `/plugins/category/${category}`; +} +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 !== "installs") params.set("sort", sort); + else if (window !== DEFAULT_WINDOW) params.set("window", window); + const search = params.toString(); + return search ? `${path}?${search}` : path; +} +/** The directory's Most installed section in a time window. */ +export function mostInstalledHref(window: InstallWindow): string { + return `/plugins${window === DEFAULT_WINDOW ? "" : `?window=${window}`}#most-installed`; +} diff --git a/packages/website/src/plugins/loader.ts b/packages/website/src/plugins/loader.ts new file mode 100644 index 0000000000..ebc9bf8272 --- /dev/null +++ b/packages/website/src/plugins/loader.ts @@ -0,0 +1,30 @@ +import { createServerFn } from "@tanstack/react-start"; +import { env } from "cloudflare:workers"; +import { getWebsiteCacheContext } from "~/cloudflare-cache"; +import { + loadInstallCounts, + loadRegistryIndex, + loadRegistryPlugin, + type RegistryEnvironment, +} from "./published"; + +function registryBase(): string { + return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; +} +/** 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, + featured: index.featured, + installs, + now: new Date().toISOString(), + }; +}); +export const getRegistryPlugin = createServerFn({ method: "GET" }) + .validator((id: string) => id) + .handler(({ data }) => loadRegistryPlugin(registryBase(), data, getWebsiteCacheContext())); 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/not-found.tsx b/packages/website/src/plugins/not-found.tsx new file mode 100644 index 0000000000..f07a363b12 --- /dev/null +++ b/packages/website/src/plugins/not-found.tsx @@ -0,0 +1,21 @@ +import { Link } from "@tanstack/react-router"; +import { Breadcrumbs } from "~/components/breadcrumbs"; +import { SiteShell } from "~/components/site-shell"; + +const NOT_FOUND_CRUMBS = [{ label: "Plugins", href: "/plugins" }, { label: "Not found" }]; + +export function PluginsNotFound({ title, children }: { title: string; children: string }) { + return ( + <SiteShell width="default"> + <Breadcrumbs items={NOT_FOUND_CRUMBS} /> + <h1 className="text-3xl font-medium tracking-tight mb-4">{title}</h1> + <p className="text-lg text-white/70 leading-relaxed max-w-2xl"> + {children}{" "} + <Link to="/plugins" className="underline hover:text-white"> + Browse all plugins + </Link> + . + </p> + </SiteShell> + ); +} 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 new file mode 100644 index 0000000000..c46b9f553a --- /dev/null +++ b/packages/website/src/plugins/plugin-card.tsx @@ -0,0 +1,96 @@ +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 { 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 allowed image, or the plugin tile on a quiet backdrop when there is none. */ +function PluginShot({ plugin }: { plugin: Plugin }) { + const url = firstMediaImage(plugin); + return ( + <div + aria-hidden + className="aspect-[16/10] overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] transition-colors group-hover:border-white/20" + > + {url ? ( + <img + {...pluginCardScreenshot(plugin.id, url)} + alt="" + loading="lazy" + decoding="async" + className="h-full w-full object-cover object-left-top" + /> + ) : ( + <div className="flex h-full w-full items-center justify-center bg-[radial-gradient(circle_at_30%_20%,rgba(35,153,86,0.12),transparent_60%)]"> + <PluginTile plugin={plugin} size="lg" /> + </div> + )} + </div> + ); +} + +/** + * 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 ( + // Grid items size to their content by default; min-w-0 keeps the card in its column. + <a href={pluginHref(plugin.id)} className="group block min-w-0"> + <PluginShot plugin={plugin} /> + <div className="mt-3 flex items-center gap-2"> + <PluginTile plugin={plugin} size="xs" /> + <p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p> + </div> + <p className="mt-2 line-clamp-2 min-h-10 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> + ); +} + +/** Ranked row for the directory's Most installed list. */ +export function PluginRankRow({ + plugin, + rank, + installs, +}: { + plugin: Plugin; + rank: number; + installs: number; +}) { + return ( + <a + href={pluginHref(plugin.id)} + className="flex items-center gap-3 rounded-lg px-4 py-2.5 transition-colors hover:bg-white/[0.04]" + > + <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> + <p className="truncate text-xs text-extra-muted-foreground"> + {getCategory(plugin.categories[0])?.label} + </p> + </div> + <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 new file mode 100644 index 0000000000..400341d5a4 --- /dev/null +++ b/packages/website/src/plugins/plugin-search.tsx @@ -0,0 +1,112 @@ +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. 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, + 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 ?? ""); + // Back and Forward between results of the same page keep this box mounted; show their term. + useEffect( + () => + router.history.subscribe(({ location, action }) => { + if (action.type === "PUSH" || action.type === "REPLACE") return; + setTerm(parseSearchTerm(new URLSearchParams(location.search).get("q")) ?? ""); + }), + [router], + ); + const show = useCallback( + (next: string, { replace }: { replace: boolean }) => { + void navigate({ href: browseHref({ ...scope, q: next.trim() ? next : undefined }), replace }); + }, + [navigate, scope], + ); + const edit = useCallback( + (next: string) => { + setTerm(next); + if (live) show(next, { replace: Boolean(term.trim()) }); + }, + [live, show, term], + ); + // A term typed before the page hydrated is in the box but not in state; adopt it. Once hydrated, + // the box always matches state, so this does nothing. + useEffect(() => { + const typed = input.current?.value ?? ""; + if (typed !== term) edit(typed); + }, [edit, term]); + const handleChange = useCallback( + (event: ChangeEvent<HTMLInputElement>) => 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" + method="get" + action={browseHref({ ...scope, q: undefined, sort: "installs", window: DEFAULT_WINDOW })} + onSubmit={handleSubmit} + className={className} + > + {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} /> + )} + <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} + onChange={handleChange} + placeholder="Search plugins" + 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-8 text-sm text-foreground placeholder:text-extra-muted-foreground focus:border-white/20 focus:outline-none [&::-webkit-search-cancel-button]:appearance-none" + /> + {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 new file mode 100644 index 0000000000..5e2dc9c31b --- /dev/null +++ b/packages/website/src/plugins/plugin-tile.tsx @@ -0,0 +1,24 @@ +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; + +/** PNG icon with a letter tile when no icon is published. */ +export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeof SIZE_CLASS }) { + return ( + <div + 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]}`} + > + {pluginOverviewUrl(plugin.icon) ? ( + <img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" /> + ) : ( + plugin.name.charAt(0).toUpperCase() + )} + </div> + ); +} diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts new file mode 100644 index 0000000000..b57d16ec2a --- /dev/null +++ b/packages/website/src/plugins/published.ts @@ -0,0 +1,127 @@ +import { + PluginRegistryIdSchema, + PluginRegistryIndexSchema, + 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, recordClientInstall } from "./installs"; +export interface RegistryEnvironment { + PLUGINS_REGISTRY_URL?: string; + WEBSITE_CACHE?: KVNamespace; +} +async function documentAt(base: string, path: string): Promise<unknown> { + const response = await fetch(`${base.replace(/\/+$/, "")}/${path}`); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`Plugin registry returned ${response.status}`); + return response.json(); +} +export function loadRegistryIndex(base: string, context: WebsiteCacheContext) { + return getBlockingColdCache({ + context, + key: `plugins:index:v1:${base}`, + isValue: (value): value is ReturnType<typeof PluginRegistryIndexSchema.parse> => + PluginRegistryIndexSchema.safeParse(value).success, + fetchFresh: async () => PluginRegistryIndexSchema.parse(await documentAt(base, "index.json")), + }); +} +/** Install counts per window for every listed plugin, refreshed with the index cache. */ +export async function loadInstallCounts(base: string, context: WebsiteCacheContext) { + const index = await loadRegistryIndex(base, context); + const ids = index.plugins.map((plugin) => plugin.id); + return getBlockingColdCache({ + context, + key: `plugins:installs:v1:${base}`, + isValue: (value): value is Record<string, InstallCounts> => + typeof value === "object" && + value !== null && + Object.values(value).every((counts) => typeof counts?.all === "number"), + fetchFresh: () => readInstallCounts(context.cache, ids, new Date()), + }); +} +export async function loadRegistryPlugin(base: string, id: string, context: WebsiteCacheContext) { + if (!PluginRegistryIdSchema.safeParse(id).success) return null; + return getBlockingColdCache({ + context, + key: `plugins:detail:v1:${base}:${id}`, + isValue: (value): value is ReturnType<typeof PublishedPluginDetailSchema.parse> | null => + value === null || PublishedPluginDetailSchema.safeParse(value).success, + fetchFresh: async () => { + const raw = await documentAt(base, `plugins/${id}.json`); + if (raw === null) return null; + const plugin = PublishedPluginDetailSchema.parse(raw); + if (plugin.id !== id) throw new Error("Plugin registry returned a different ID"); + return plugin; + }, + }); +} +export async function handlePluginRegistryRequest( + request: Request, + env: RegistryEnvironment, + context: WebsiteCacheContext, +): Promise<Response | null> { + 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") { + const index = await loadRegistryIndex(base, context); + // IDs are validated owner/slug strings, so these paths contain no XML metacharacters. + const paths = new Set<string>(["/plugins", "/plugins/all"]); + for (const category of CATEGORIES) { + if (index.plugins.some((plugin) => plugin.categories.includes(category.slug))) + paths.add(`/plugins/category/${category.slug}`); + } + for (const plugin of index.plugins) { + paths.add(`/plugins/${plugin.id.split("/")[0]}`); + paths.add(`/plugins/${plugin.id}`); + } + const urls = [...paths].sort().map((path) => `<url><loc>https://paseo.sh${path}</loc></url>`); + return new Response( + `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join("")}</urlset>`, + { + headers: { + "content-type": "application/xml; charset=utf-8", + "cache-control": "public, max-age=300", + }, + }, + ); + } + if (url.pathname === "/api/plugins/installs") { + const index = await loadRegistryIndex(base, context); + const counts = await readInstallCounts( + context.cache, + index.plugins.map((plugin) => plugin.id), + new Date(), + ); + return Response.json( + Object.fromEntries(Object.entries(counts).map(([id, count]) => [id, count.all])), + ); + } + const match = + /^\/api\/plugins\/resolve\/([^/]+\/[^/]+)$/.exec(url.pathname) ?? + /^\/plugins\/([^/]+\/[^/]+)\.json$/.exec(url.pathname); + if (!match) return null; + const id = match[1]; + const plugin = await loadRegistryPlugin(base, id, context); + if (!plugin) return Response.json({ error: "Plugin not found" }, { status: 404 }); + if ( + request.headers.get("X-Paseo-Install") === "1" || + url.searchParams.get("intent") === "install" + ) { + const cache = context.cache; + 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 new file mode 100644 index 0000000000..d8f343b266 --- /dev/null +++ b/packages/website/src/plugins/registry.test.ts @@ -0,0 +1,352 @@ +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 { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published"; +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: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + installs: 42, +}; +describe("plugin registry", () => { + it("loads a directory and a detail from a static HTTP registry", async () => { + const index = { + schemaVersion: 1, + registry: { name: "Internal", url: "https://example.test" }, + categories: [], + plugins: [plugin], + featured: [plugin.id], + generatedAt: "2026-10-03", + }; + const detail = { ...plugin, readme: "# Example" }; + const server = createServer((request, response) => { + const value = request.url === "/index.json" ? index : detail; + response.end(JSON.stringify(value)); + }); + 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 address"); + const base = `http://127.0.0.1:${address.port}`; + const context = { cache: null, waitUntil: () => undefined }; + try { + expect(await loadRegistryIndex(base, context)).toEqual(index); + expect(await loadRegistryPlugin(base, plugin.id, context)).toEqual(detail); + await expect(loadRegistryPlugin(base, "acme/wrong", context)).rejects.toThrow("different ID"); + } finally { + await new Promise<void>((resolve) => server.close(() => resolve())); + } + }); + 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", + "acme/twin", + "acme/older", + ]); + const installs = { + "acme/example": { week: 1, month: 5, all: 9 }, + "acme/older": { week: 3, month: 3, all: 3 }, + "acme/twin": { week: 1, month: 1, all: 1 }, + }; + expect(mostInstalled([plugin, older, twin], installs, "week").map((p) => p.id)).toEqual([ + "acme/older", + "acme/example", + "acme/twin", + ]); + expect(mostInstalled([plugin, older, twin], installs, "month")[0].id).toBe("acme/example"); + }); + it("counts installs this week, this month, and all time from the daily record", async () => { + const cache = memoryKv(); + const day = (date: string) => new Date(`${date}T12:00:00.000Z`); + await cache.put("plugin-installs:acme/example", "40"); + await recordInstall(cache, "acme/example", day("2026-07-01")); + await recordInstall(cache, "acme/example", day("2026-09-10")); + await recordInstall(cache, "acme/example", day("2026-09-30")); + await recordInstall(cache, "acme/example", day("2026-10-04")); + expect(await readInstallCounts(cache, ["acme/example"], day("2026-10-04"))).toEqual({ + "acme/example": { week: 2, month: 3, all: 44 }, + }); + expect( + 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, + id: "zed/other", + name: "Other", + description: "Unrelated", + author: { github: "zed" }, + }; + const plugins = [plugin, other]; + expect(searchPlugins(plugins, "EXAMPLE").map((p) => p.id)).toEqual(["acme/example"]); + expect(searchPlugins(plugins, "unrelated").map((p) => p.id)).toEqual(["zed/other"]); + expect(searchPlugins(plugins, "zed/").map((p) => p.id)).toEqual(["zed/other"]); + expect(searchPlugins(plugins, "acme").map((p) => p.id)).toEqual(["acme/example"]); + 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"); + expect(readmeBody("## Usage\n\n> Note\n")).toBe("## Usage\n\n> Note\n"); + expect(readmeBody("# Example\n\n> [!WARNING]\n> Needs Docker\n\n## Usage\n")).toBe( + "> [!WARNING]\n> Needs Docker\n\n## Usage\n", + ); + }); + it("offers a registry install command", () => { + 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(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, options?: { expirationTtl?: number }) => { + values.set(key, value); + expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity); + }, + }; + 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 new file mode 100644 index 0000000000..7d99cb6789 --- /dev/null +++ b/packages/website/src/plugins/registry.ts @@ -0,0 +1,168 @@ +import type { z } from "zod"; +import { + isGitHubHost, + parseGitHubRemoteIdentity, + parseGitRemoteLocation, +} from "@getpaseo/protocol/git-remote"; +import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; +import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry"; +import { scoreTextFields } from "@getpaseo/protocol/search/text-match"; +import { CATEGORIES, type Category, type CategorySlug } from "./categories"; +import type { InstallCounts, InstallWindow } from "./installs"; +export { CATEGORIES, type Category, type CategorySlug }; +export type { InstallCounts, InstallWindow }; +export type Plugin = z.infer<typeof PublishedPluginSchema>; +export interface Author { + username: string; + name: string; + github: string; + npm?: string; +} +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)); +} +/** The namespace segment of the plugin ID. It is the route param for the plugin and its author. */ +export function pluginOwner(plugin: Plugin): string { + return plugin.id.split("/")[0]; +} +export function getAuthor(plugin: Plugin): Author { + const { author } = plugin; + return { + username: pluginOwner(plugin), + github: author.github, + name: author.name ?? author.github, + npm: author.npm, + }; +} +export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] { + return plugins.filter((plugin) => pluginOwner(plugin) === owner); +} +export function installCommand(plugin: Plugin): string { + return `paseo plugin add ${plugin.id}`; +} +export function pluginVersion(plugin: Plugin): string { + return plugin.artifact.kind === "npm" + ? plugin.artifact.version + : plugin.artifact.commit.slice(0, 12); +} +export function npmUrl(plugin: Plugin): string | undefined { + return plugin.artifact.kind === "npm" + ? `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; +} +export function authorGitHubUrl(author: Author): string { + return `https://github.com/${author.github}`; +} +export function authorAvatarUrl(author: Author): string { + return `https://github.com/${author.github}.png?size=80`; +} +export function formatInstalls(count: number): string { + if (count < 1000) return String(count); + const k = count / 1000; + return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; +} +/** 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]]; +} +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) => 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( + plugins: Plugin[], + installs: Record<string, InstallCounts>, + window: InstallWindow, +): Plugin[] { + const count = (plugin: Plugin) => installs[plugin.id]?.[window] ?? 0; + return [...plugins].sort((a, b) => count(b) - count(a)); +} +/** How long ago the plugin was first listed, relative to the loader's clock. */ +export function addedAgo(plugin: Plugin, now: string): string { + 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`; + return `${Math.round(days / 7)}w ago`; +} +/** + * A README usually opens with the plugin's name and quoted description; the page shows both. + * A leading GitHub alert (`> [!WARNING]`) is a note for the reader and stays. + */ +export function readmeBody(readme: string): string { + return readme + .replace(/^\s*#\s[^\n]*\n+/, "") + .replace(/^(?!>\s*\[!)(>[^\n]*\n)+\n*/, "") + .trimStart(); +} 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/plugins/window-switch.tsx b/packages/website/src/plugins/window-switch.tsx new file mode 100644 index 0000000000..c4098f097f --- /dev/null +++ b/packages/website/src/plugins/window-switch.tsx @@ -0,0 +1,35 @@ +import type { InstallWindow } from "./installs"; + +const WINDOWS: { value: InstallWindow; label: string }[] = [ + { value: "week", label: "This week" }, + { value: "month", label: "This month" }, + { value: "all", label: "All time" }, +]; + +/** Plain text time-window switch for install rankings. */ +export function WindowSwitch({ + current, + hrefs, +}: { + current: InstallWindow; + hrefs: Record<InstallWindow, string>; +}) { + return ( + <nav aria-label="Time window" className="flex items-baseline gap-4 text-sm"> + {WINDOWS.map((option) => ( + <a + key={option.value} + href={hrefs[option.value]} + aria-current={option.value === current ? "true" : undefined} + className={ + option.value === current + ? "text-foreground" + : "text-extra-muted-foreground transition-colors hover:text-muted-foreground" + } + > + {option.label} + </a> + ))} + </nav> + ); +} diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index 9b2a842b0d..46b5da4363 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -60,8 +60,11 @@ import { Route as AmpRouteImport } from "./routes/amp"; import { Route as AgoragenticRouteImport } from "./routes/agoragentic"; import { Route as AgentsRouteImport } from "./routes/agents"; import { Route as IndexRouteImport } from "./routes/index"; +import { Route as PluginsIndexRouteImport } from "./routes/plugins/index"; import { Route as DocsIndexRouteImport } from "./routes/docs/index"; import { Route as BlogIndexRouteImport } from "./routes/blog/index"; +import { Route as PluginsAllRouteImport } from "./routes/plugins/all"; +import { Route as PluginsOwnerRouteImport } from "./routes/plugins/$owner"; import { Route as DownloadThanksRouteImport } from "./routes/download_.thanks"; import { Route as DocsSplatRouteImport } from "./routes/docs/$"; import { Route as BlogSplatRouteImport } from "./routes/blog/$"; @@ -73,6 +76,8 @@ import { Route as AlternativesHappyCoderRouteImport } from "./routes/alternative import { Route as AlternativesConductorRouteImport } from "./routes/alternatives/conductor"; import { Route as AlternativesCodexAppRouteImport } from "./routes/alternatives/codex-app"; import { Route as AlternativesClaudeDesktopRouteImport } from "./routes/alternatives/claude-desktop"; +import { Route as PluginsCategorySlugRouteImport } from "./routes/plugins/category/$slug"; +import { Route as PluginsOwnerSlugRouteImport } from "./routes/plugins/$owner_.$slug"; const VtcodeRoute = VtcodeRouteImport.update({ id: "/vtcode", @@ -329,6 +334,11 @@ const IndexRoute = IndexRouteImport.update({ path: "/", getParentRoute: () => rootRouteImport, } as any); +const PluginsIndexRoute = PluginsIndexRouteImport.update({ + id: "/plugins/", + path: "/plugins/", + getParentRoute: () => rootRouteImport, +} as any); const DocsIndexRoute = DocsIndexRouteImport.update({ id: "/", path: "/", @@ -339,6 +349,16 @@ const BlogIndexRoute = BlogIndexRouteImport.update({ path: "/", getParentRoute: () => BlogRoute, } as any); +const PluginsAllRoute = PluginsAllRouteImport.update({ + id: "/plugins/all", + path: "/plugins/all", + getParentRoute: () => rootRouteImport, +} as any); +const PluginsOwnerRoute = PluginsOwnerRouteImport.update({ + id: "/plugins/$owner", + path: "/plugins/$owner", + getParentRoute: () => rootRouteImport, +} as any); const DownloadThanksRoute = DownloadThanksRouteImport.update({ id: "/download_/thanks", path: "/download/thanks", @@ -396,6 +416,16 @@ const AlternativesClaudeDesktopRoute = path: "/alternatives/claude-desktop", getParentRoute: () => rootRouteImport, } as any); +const PluginsCategorySlugRoute = PluginsCategorySlugRouteImport.update({ + id: "/plugins/category/$slug", + path: "/plugins/category/$slug", + getParentRoute: () => rootRouteImport, +} as any); +const PluginsOwnerSlugRoute = PluginsOwnerSlugRouteImport.update({ + id: "/plugins/$owner_/$slug", + path: "/plugins/$owner/$slug", + getParentRoute: () => rootRouteImport, +} as any); export interface FileRoutesByFullPath { "/": typeof IndexRoute; @@ -460,8 +490,13 @@ export interface FileRoutesByFullPath { "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; "/download/thanks": typeof DownloadThanksRoute; + "/plugins/$owner": typeof PluginsOwnerRoute; + "/plugins/all": typeof PluginsAllRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; + "/plugins/": typeof PluginsIndexRoute; + "/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute; + "/plugins/category/$slug": typeof PluginsCategorySlugRoute; } export interface FileRoutesByTo { "/": typeof IndexRoute; @@ -524,8 +559,13 @@ export interface FileRoutesByTo { "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; "/download/thanks": typeof DownloadThanksRoute; + "/plugins/$owner": typeof PluginsOwnerRoute; + "/plugins/all": typeof PluginsAllRoute; "/blog": typeof BlogIndexRoute; "/docs": typeof DocsIndexRoute; + "/plugins": typeof PluginsIndexRoute; + "/plugins/$owner/$slug": typeof PluginsOwnerSlugRoute; + "/plugins/category/$slug": typeof PluginsCategorySlugRoute; } export interface FileRoutesById { __root__: typeof rootRouteImport; @@ -591,8 +631,13 @@ export interface FileRoutesById { "/blog/$": typeof BlogSplatRoute; "/docs/$": typeof DocsSplatRoute; "/download_/thanks": typeof DownloadThanksRoute; + "/plugins/$owner": typeof PluginsOwnerRoute; + "/plugins/all": typeof PluginsAllRoute; "/blog/": typeof BlogIndexRoute; "/docs/": typeof DocsIndexRoute; + "/plugins/": typeof PluginsIndexRoute; + "/plugins/$owner_/$slug": typeof PluginsOwnerSlugRoute; + "/plugins/category/$slug": typeof PluginsCategorySlugRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; @@ -659,8 +704,13 @@ export interface FileRouteTypes { | "/blog/$" | "/docs/$" | "/download/thanks" + | "/plugins/$owner" + | "/plugins/all" | "/blog/" - | "/docs/"; + | "/docs/" + | "/plugins/" + | "/plugins/$owner/$slug" + | "/plugins/category/$slug"; fileRoutesByTo: FileRoutesByTo; to: | "/" @@ -723,8 +773,13 @@ export interface FileRouteTypes { | "/blog/$" | "/docs/$" | "/download/thanks" + | "/plugins/$owner" + | "/plugins/all" | "/blog" - | "/docs"; + | "/docs" + | "/plugins" + | "/plugins/$owner/$slug" + | "/plugins/category/$slug"; id: | "__root__" | "/" @@ -789,8 +844,13 @@ export interface FileRouteTypes { | "/blog/$" | "/docs/$" | "/download_/thanks" + | "/plugins/$owner" + | "/plugins/all" | "/blog/" - | "/docs/"; + | "/docs/" + | "/plugins/" + | "/plugins/$owner_/$slug" + | "/plugins/category/$slug"; fileRoutesById: FileRoutesById; } export interface RootRouteChildren { @@ -854,6 +914,11 @@ export interface RootRouteChildren { AlternativesOrcaRoute: typeof AlternativesOrcaRoute; AlternativesSupersetRoute: typeof AlternativesSupersetRoute; DownloadThanksRoute: typeof DownloadThanksRoute; + PluginsOwnerRoute: typeof PluginsOwnerRoute; + PluginsAllRoute: typeof PluginsAllRoute; + PluginsIndexRoute: typeof PluginsIndexRoute; + PluginsOwnerSlugRoute: typeof PluginsOwnerSlugRoute; + PluginsCategorySlugRoute: typeof PluginsCategorySlugRoute; } declare module "@tanstack/react-router" { @@ -1215,6 +1280,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof IndexRouteImport; parentRoute: typeof rootRouteImport; }; + "/plugins/": { + id: "/plugins/"; + path: "/plugins"; + fullPath: "/plugins/"; + preLoaderRoute: typeof PluginsIndexRouteImport; + parentRoute: typeof rootRouteImport; + }; "/docs/": { id: "/docs/"; path: "/"; @@ -1229,6 +1301,20 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof BlogIndexRouteImport; parentRoute: typeof BlogRoute; }; + "/plugins/all": { + id: "/plugins/all"; + path: "/plugins/all"; + fullPath: "/plugins/all"; + preLoaderRoute: typeof PluginsAllRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/plugins/$owner": { + id: "/plugins/$owner"; + path: "/plugins/$owner"; + fullPath: "/plugins/$owner"; + preLoaderRoute: typeof PluginsOwnerRouteImport; + parentRoute: typeof rootRouteImport; + }; "/download_/thanks": { id: "/download_/thanks"; path: "/download/thanks"; @@ -1306,6 +1392,20 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof AlternativesClaudeDesktopRouteImport; parentRoute: typeof rootRouteImport; }; + "/plugins/category/$slug": { + id: "/plugins/category/$slug"; + path: "/plugins/category/$slug"; + fullPath: "/plugins/category/$slug"; + preLoaderRoute: typeof PluginsCategorySlugRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/plugins/$owner_/$slug": { + id: "/plugins/$owner_/$slug"; + path: "/plugins/$owner/$slug"; + fullPath: "/plugins/$owner/$slug"; + preLoaderRoute: typeof PluginsOwnerSlugRouteImport; + parentRoute: typeof rootRouteImport; + }; } } @@ -1394,6 +1494,11 @@ const rootRouteChildren: RootRouteChildren = { AlternativesOrcaRoute: AlternativesOrcaRoute, AlternativesSupersetRoute: AlternativesSupersetRoute, DownloadThanksRoute: DownloadThanksRoute, + PluginsOwnerRoute: PluginsOwnerRoute, + PluginsAllRoute: PluginsAllRoute, + PluginsIndexRoute: PluginsIndexRoute, + PluginsOwnerSlugRoute: PluginsOwnerSlugRoute, + PluginsCategorySlugRoute: PluginsCategorySlugRoute, }; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/website/src/routes/docs.tsx b/packages/website/src/routes/docs.tsx index b58934cb75..f1c5632cf1 100644 --- a/packages/website/src/routes/docs.tsx +++ b/packages/website/src/routes/docs.tsx @@ -4,6 +4,7 @@ import { useCallback, useMemo, useState } from "react"; import { DocsBreadcrumbs } from "~/components/docs-breadcrumbs"; import { DocsNav } from "~/components/docs-nav"; import { DocsOutline } from "~/components/docs-outline"; +import { SponsorsSection } from "~/components/sponsorship"; import { buildDocsNavTree, getDoc, getDocs } from "~/docs"; import "~/styles.css"; @@ -63,6 +64,7 @@ function DocsLayout() { <div className="max-w-prose mx-auto"> {doc && <DocsBreadcrumbs doc={doc} tree={tree} />} <Outlet /> + <SponsorsSection placement="footer" /> </div> </main> diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index 38806673f9..5f0bc664ae 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -6,7 +6,7 @@ export const Route = createFileRoute("/")({ head: () => pageMeta( "Paseo – Run Claude Code, Codex, Copilot, OpenCode from anywhere", - "Self-hosted daemon for Claude Code, Codex, Copilot, OpenCode, and Pi. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.", + "Open source app for Claude Code, Codex, OpenCode, Pi, and 30+ more coding agents. Run many agents in parallel on your machines. Editor, terminals, diffs, pull requests, and a browser in one window. One download on desktop. The full app on iOS and Android.", "/", ), component: Home, @@ -17,16 +17,19 @@ function Home() { <LandingPage title={ <> - The control plane + The agentic development <br /> - for coding agents + environment </> } subtitle={ <> - Run any coding agent from anywhere. + Run many coding agents at once, + <br className="sm:hidden" /> + <span className="hidden sm:inline"> </span> + on any machine. <br /> - Self-hosted, multi-provider, open source + From your desk and from your phone. </> } /> diff --git a/packages/website/src/routes/plugins/$owner.tsx b/packages/website/src/routes/plugins/$owner.tsx new file mode 100644 index 0000000000..89c4f185ef --- /dev/null +++ b/packages/website/src/routes/plugins/$owner.tsx @@ -0,0 +1,93 @@ +import { PluginContentLink } from "~/plugins/overview"; +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { ExternalLink } from "lucide-react"; +import { useMemo } from "react"; +import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; +import { + authorGitHubUrl, + authorNpmUrl, + getAuthor, + getRegistry, + getPluginsByAuthor, + mostInstalled, + pluginOwner, +} 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"; + +export const Route = createFileRoute("/plugins/$owner")({ + loader: async ({ params }) => { + const registry = await getRegistry(); + const first = registry.plugins.find((plugin) => pluginOwner(plugin) === params.owner); + if (!first) throw notFound(); + return { plugins: registry.plugins, installs: registry.installs, author: getAuthor(first) }; + }, + head: ({ params, loaderData }) => + pageMeta( + loaderData ? `${loaderData.author.name} – Paseo plugins` : "Author not found – Paseo", + loaderData ? `Paseo plugins published by ${loaderData.author.name}.` : "Author not found.", + `/plugins/${params.owner}`, + ), + component: AuthorPage, + notFoundComponent: () => ( + <PluginsNotFound title="Author not found"> + Nobody with that GitHub owner has a plugin listed. + </PluginsNotFound> + ), +}); + +const LINK_CLASS = + "inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"; + +function AuthorPage() { + const { plugins: allPlugins, installs, author } = Route.useLoaderData(); + const crumbs = useMemo<BreadcrumbItem[]>( + () => [{ label: "Plugins", href: "/plugins" }, { label: author.name }], + [author], + ); + + const plugins = mostInstalled(getPluginsByAuthor(allPlugins, author.username), installs, "all"); + const github = authorGitHubUrl(author); + + return ( + <SiteShell width="default"> + <Breadcrumbs items={crumbs} /> + <div className="flex items-start gap-4"> + <AuthorAvatar author={author} size="lg" /> + <div className="space-y-2"> + <h1 className="text-3xl font-medium tracking-tight">{author.name}</h1> + <p className="text-sm text-muted-foreground"> + {plugins.length} {plugins.length === 1 ? "plugin" : "plugins"} + </p> + <div className="flex items-center gap-4 pt-1"> + {authorNpmUrl(author) && ( + <PluginContentLink href={authorNpmUrl(author)} className={LINK_CLASS}> + npm + <ExternalLink className="h-3 w-3" /> + </PluginContentLink> + )} + {github && ( + <PluginContentLink href={github} className={LINK_CLASS}> + GitHub + <ExternalLink className="h-3 w-3" /> + </PluginContentLink> + )} + </div> + </div> + </div> + + <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}> + <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 new file mode 100644 index 0000000000..b3d02177aa --- /dev/null +++ b/packages/website/src/routes/plugins/$owner_.$slug.tsx @@ -0,0 +1,114 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { ExternalLink } from "lucide-react"; +import { GitHubIcon } from "~/components/brand-icons"; +import { CodeBlock } from "~/components/code-block"; +import { PluginOverview, PluginContentLink } from "~/plugins/overview"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; +import { + firstMediaImage, + getAuthor, + getCategory, + getRegistry, + getRegistryPlugin, + installCommand, + npmUrl, + pluginVersion, + readmeBody, + reportBugUrl, +} from "~/plugins"; +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"; + +export const Route = createFileRoute("/plugins/$owner_/$slug")({ + loader: async ({ params }) => { + const [registry, plugin] = await Promise.all([ + getRegistry(), + getRegistryPlugin({ data: `${params.owner}/${params.slug}` }), + ]); + if (!plugin) throw notFound(); + return { plugin, installs: registry.installs[plugin.id]?.all ?? 0 }; + }, + head: ({ params, loaderData }) => + pageMeta( + loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo", + loaderData?.plugin?.description ?? "Plugin not found.", + `/plugins/${params.owner}/${params.slug}`, + loaderData?.plugin && firstMediaImage(loaderData.plugin), + ), + component: PluginPage, + notFoundComponent: () => ( + <PluginsNotFound title="Plugin not found"> + There is no plugin listed at this address. + </PluginsNotFound> + ), +}); + +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"> + <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> + {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> + + <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/all.tsx b/packages/website/src/routes/plugins/all.tsx new file mode 100644 index 0000000000..81715f0957 --- /dev/null +++ b/packages/website/src/routes/plugins/all.tsx @@ -0,0 +1,43 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { pageMeta } from "~/meta"; +import { getRegistry } from "~/plugins"; +import { BrowsePage } from "~/plugins/browse-page"; +import { + type BrowseQuery, + DEFAULT_WINDOW, + parseSearchTerm, + parseSort, + parseWindow, +} from "~/plugins/links"; +import "~/styles.css"; + +export const Route = createFileRoute("/plugins/all")({ + validateSearch: (search: Record<string, unknown>): Omit<Partial<BrowseQuery>, "category"> => ({ + sort: parseSort(search.sort), + window: parseWindow(search.window), + q: parseSearchTerm(search.q), + }), + head: () => + pageMeta( + "All plugins – Paseo plugins", + "Every community plugin for Paseo, by installs or newest first.", + "/plugins/all", + ), + loader: () => getRegistry(), + component: AllPluginsPage, +}); + +function AllPluginsPage() { + const { plugins, installs, now } = Route.useLoaderData(); + const search = Route.useSearch(); + const query = useMemo<BrowseQuery>( + () => ({ + sort: search.sort ?? "installs", + window: search.window ?? DEFAULT_WINDOW, + q: search.q, + }), + [search.sort, search.window, search.q], + ); + return <BrowsePage plugins={plugins} installs={installs} now={now} query={query} />; +} diff --git a/packages/website/src/routes/plugins/category/$slug.tsx b/packages/website/src/routes/plugins/category/$slug.tsx new file mode 100644 index 0000000000..53234bc528 --- /dev/null +++ b/packages/website/src/routes/plugins/category/$slug.tsx @@ -0,0 +1,52 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { pageMeta } from "~/meta"; +import { getCategory, getRegistry } from "~/plugins"; +import { BrowsePage } from "~/plugins/browse-page"; +import { + type BrowseQuery, + DEFAULT_WINDOW, + parseSearchTerm, + parseSort, + parseWindow, +} from "~/plugins/links"; +import { PluginsNotFound } from "~/plugins/not-found"; +import "~/styles.css"; + +export const Route = createFileRoute("/plugins/category/$slug")({ + validateSearch: (search: Record<string, unknown>): Omit<Partial<BrowseQuery>, "category"> => ({ + sort: parseSort(search.sort), + window: parseWindow(search.window), + q: parseSearchTerm(search.q), + }), + loader: async ({ params }) => { + const category = getCategory(params.slug); + if (!category) throw notFound(); + return { ...(await getRegistry()), category }; + }, + head: ({ params, loaderData }) => + pageMeta( + loaderData ? `${loaderData.category.label} – Paseo plugins` : "Category not found – Paseo", + loaderData?.category.description ?? "Category not found.", + `/plugins/category/${params.slug}`, + ), + component: CategoryPage, + notFoundComponent: () => ( + <PluginsNotFound title="Category not found">There is no plugin category here.</PluginsNotFound> + ), +}); + +function CategoryPage() { + const { plugins, installs, now, category } = Route.useLoaderData(); + const search = Route.useSearch(); + const query = useMemo<BrowseQuery>( + () => ({ + category: category.slug, + sort: search.sort ?? "installs", + window: search.window ?? DEFAULT_WINDOW, + q: search.q, + }), + [category.slug, search.sort, search.window, search.q], + ); + return <BrowsePage plugins={plugins} installs={installs} now={now} query={query} />; +} diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx new file mode 100644 index 0000000000..613afc375c --- /dev/null +++ b/packages/website/src/routes/plugins/index.tsx @@ -0,0 +1,248 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { + Blocks, + Boxes, + ChevronRight, + GitBranch, + LayoutPanelLeft, + type LucideIcon, + Network, + Palette, + Server, + Sparkles, + Wrench, +} from "lucide-react"; +import { useMemo } from "react"; +import { SiteShell } from "~/components/site-shell"; +import { pageMeta } from "~/meta"; +import { + addedAgo, + CATEGORIES, + type CategorySlug, + featuredPlugins, + getCategory, + getPluginsInCategory, + getRegistry, + type InstallWindow, + mostInstalled, + newestFirst, + pluginOwner, + recentlyUpdated, + updatedAgo, +} from "~/plugins"; +import { ContributeSection } from "~/plugins/contribute-links"; +import { PluginsHero } from "~/plugins/hero"; +import { + type BrowseQuery, + browseHref, + categoryHref, + DEFAULT_WINDOW, + mostInstalledHref, + parseSearchTerm, + parseSort, + parseWindow, +} from "~/plugins/links"; +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"; + +const NEW_COUNT = 4; +const TOP_COUNT = 6; + +const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = { + "daemon-management": Server, + themes: Palette, + providers: Boxes, + orchestration: Network, + git: GitBranch, + workspaces: Blocks, + sidebar: LayoutPanelLeft, + extras: Sparkles, + 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"; + +export const Route = createFileRoute("/plugins/")({ + validateSearch: (search: Record<string, unknown>): { window?: InstallWindow } => { + const window = parseWindow(search.window); + return window ? { window } : {}; + }, + beforeLoad: ({ location }) => { + // Keep links from before browse pages existed: /plugins?q=<term>&category=<slug>&sort=new. + const params = new URLSearchParams(location.searchStr); + const category = getCategory(params.get("category") ?? ""); + const sort = parseSort(params.get("sort")) ?? "installs"; + const window = parseWindow(params.get("window")) ?? DEFAULT_WINDOW; + const q = parseSearchTerm(params.get("q")); + if (category || sort !== "installs" || q) + throw redirect({ + href: browseHref({ category: category?.slug, sort, window, q }), + statusCode: 301, + }); + }, + head: () => + pageMeta( + "Plugins – Extend Paseo with community plugins", + "Themes, providers, panels, and automations built by the Paseo community. Install any of them with one command.", + "/plugins", + ), + loader: () => getRegistry(), + component: PluginsPage, +}); + +function PluginsPage() { + 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( + () => ({ + week: mostInstalledHref("week"), + month: mostInstalledHref("month"), + all: mostInstalledHref("all"), + }), + [], + ); + const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]); + const authorCount = new Set(plugins.map(pluginOwner)).size; + + return ( + <SiteShell width="default"> + <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> + + <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/server-entry.ts b/packages/website/src/server-entry.ts index 657abf9cfe..775e8348ea 100644 --- a/packages/website/src/server-entry.ts +++ b/packages/website/src/server-entry.ts @@ -1,3 +1,4 @@ +import { handlePluginRegistryRequest } from "~/plugins/published"; import type { Register } from "@tanstack/react-router"; import type { RequestHandler } from "@tanstack/react-start/server"; import { getAndroidVersionCode } from "~/android-version"; @@ -8,6 +9,7 @@ import { buildLlmsTxt } from "~/llms"; interface WebsiteEnv { WEBSITE_CACHE?: KVNamespace; + PLUGINS_REGISTRY_URL?: string; } function markdownResponse(body: string): Response { @@ -79,6 +81,12 @@ export default { async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise<Response> { const url = new URL(request.url); + const pluginResponse = await handlePluginRegistryRequest(request, env, { + cache: env.WEBSITE_CACHE ?? null, + waitUntil: (promise) => context.waitUntil(promise), + }); + if (pluginResponse) return pluginResponse; + const environment = import.meta.env.DEV ? "development" : "production"; const canonicalRedirect = getCanonicalRedirect(url, environment); if (canonicalRedirect) { diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index bfbe26a82a..47da4f3c47 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -406,6 +406,12 @@ font-weight: 400; } +.docs-prose img { + max-width: 100%; + border-radius: 0.5rem; + border: 1px solid var(--color-border); +} + .docs-prose p { margin-top: 1rem; margin-bottom: 1rem; @@ -498,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); @@ -537,23 +561,6 @@ margin-bottom: 2rem; } -.docs-source-footer { - border-top: 1px solid var(--color-border); - margin-top: 3rem; - padding-top: 1.25rem; - font-size: 0.875rem; -} - -.docs-source-footer a { - color: rgba(255, 255, 255, 0.5); - font-weight: 500; - text-decoration: none; -} - -.docs-source-footer a:hover { - color: var(--color-foreground); -} - /* Blog post prose styles — adapted from moboudra.com */ .blog-prose { color: rgba(250, 250, 250, 0.82); diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index f9260acf49..46dbc554de 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -80,6 +80,11 @@ function discoverBlogRoutes(): string[] { return ["/blog", ...slugs.map((slug) => `/blog/${slug}`)]; } +// Author and detail URLs are served by /sitemap-plugins.xml as the registry changes. +function discoverPluginRoutes(): string[] { + return ["/plugins"]; +} + const sitemapPages = [ "/", "/agents", @@ -90,6 +95,7 @@ const sitemapPages = [ "/terms", ...discoverAgentRoutes(), ...discoverAlternativeRoutes(), + ...discoverPluginRoutes(), ...discoverDocsRoutes(), ...discoverBlogRoutes(), ].map((routePath) => ({ diff --git a/packages/website/wrangler.toml b/packages/website/wrangler.toml index 089a22089e..48db01ed78 100644 --- a/packages/website/wrangler.toml +++ b/packages/website/wrangler.toml @@ -7,6 +7,7 @@ main = "./src/server-entry.ts" routes = [ { pattern = "paseo.sh", custom_domain = true }, { pattern = "www.paseo.sh", custom_domain = true }, + { pattern = "plugins.paseo.sh", custom_domain = true }, ] [observability] diff --git a/patches/@gorhom+bottom-sheet+5.2.14.patch b/patches/@gorhom+bottom-sheet+5.2.14.patch new file mode 100644 index 0000000000..f84d46f8fd --- /dev/null +++ b/patches/@gorhom+bottom-sheet+5.2.14.patch @@ -0,0 +1,116 @@ +diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js +index ee51e76..dc1bc17 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/components/bottomSheetModal/BottomSheetModal.js +@@ -315,7 +315,9 @@ function BottomSheetModalComponent(props, ref) { + } + currentIndexRef.current = _index; + nextIndexRef.current = null; +- statusRef.current = _index === -1 ? _constants.MODAL_STATUS.MINIMIZED : _constants.MODAL_STATUS.PRESENTED; ++ if (!(_index === -1 && statusRef.current === _constants.MODAL_STATUS.DISMISSING)) { ++ statusRef.current = _index === -1 ? _constants.MODAL_STATUS.MINIMIZED : _constants.MODAL_STATUS.PRESENTED; ++ } + if (_providedOnChange) { + _providedOnChange(_index, _position, _type); + } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js +index e44ad86..96555ef 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js +@@ -15,6 +15,7 @@ var _exportNames = { + useBottomSheetTimingConfigs: true, + useBottomSheetInternal: true, + useBottomSheetModalInternal: true, ++ BottomSheetModalInternalProvider: true, + useScrollEventsHandlersDefault: true, + useGestureEventsHandlersDefault: true, + useBottomSheetGestureHandlers: true, +@@ -220,6 +221,13 @@ var _useBottomSheetSpringConfigs = require("./hooks/useBottomSheetSpringConfigs" + var _useBottomSheetTimingConfigs = require("./hooks/useBottomSheetTimingConfigs"); + var _useBottomSheetInternal = require("./hooks/useBottomSheetInternal"); + var _useBottomSheetModalInternal = require("./hooks/useBottomSheetModalInternal"); ++var _modalInternal = require("./contexts/modal/internal"); ++Object.defineProperty(exports, "BottomSheetModalInternalProvider", { ++ enumerable: true, ++ get: function () { ++ return _modalInternal.BottomSheetModalInternalProvider; ++ } ++}); + var _useScrollEventsHandlersDefault = require("./hooks/useScrollEventsHandlersDefault"); + var _useGestureEventsHandlersDefault = require("./hooks/useGestureEventsHandlersDefault"); + var _useBottomSheetGestureHandlers = require("./hooks/useBottomSheetGestureHandlers"); +diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js +index 94fabdb..60ca66a 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/module/components/bottomSheetModal/BottomSheetModal.js +@@ -308,7 +308,9 @@ function BottomSheetModalComponent(props, ref) { + } + currentIndexRef.current = _index; + nextIndexRef.current = null; +- statusRef.current = _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ if (!(_index === -1 && statusRef.current === MODAL_STATUS.DISMISSING)) { ++ statusRef.current = _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ } + if (_providedOnChange) { + _providedOnChange(_index, _position, _type); + } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/index.js b/node_modules/@gorhom/bottom-sheet/lib/module/index.js +index 8259637..eb057fe 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/module/index.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/module/index.js +@@ -14,6 +14,7 @@ export { useBottomSheetSpringConfigs } from './hooks/useBottomSheetSpringConfigs + export { useBottomSheetTimingConfigs } from './hooks/useBottomSheetTimingConfigs'; + export { useBottomSheetInternal } from './hooks/useBottomSheetInternal'; + export { useBottomSheetModalInternal } from './hooks/useBottomSheetModalInternal'; ++export { BottomSheetModalInternalProvider } from './contexts/modal/internal'; + export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault'; + export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault'; + export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers'; +diff --git a/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts b/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts +index ffc6caa..550f1b3 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts ++++ b/node_modules/@gorhom/bottom-sheet/lib/typescript/index.d.ts +@@ -7,6 +7,8 @@ export { useBottomSheetSpringConfigs } from './hooks/useBottomSheetSpringConfigs + export { useBottomSheetTimingConfigs } from './hooks/useBottomSheetTimingConfigs'; + export { useBottomSheetInternal } from './hooks/useBottomSheetInternal'; + export { useBottomSheetModalInternal } from './hooks/useBottomSheetModalInternal'; ++export { BottomSheetModalInternalProvider } from './contexts/modal/internal'; ++export type { BottomSheetModalInternalContextType } from './contexts/modal/internal'; + export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault'; + export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault'; + export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers'; +diff --git a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx +index 2954b3f..7260656 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx ++++ b/node_modules/@gorhom/bottom-sheet/src/components/bottomSheetModal/BottomSheetModal.tsx +@@ -432,8 +432,15 @@ function BottomSheetModalComponent<T = never>( + currentIndexRef.current = _index; + nextIndexRef.current = null; + +- statusRef.current = +- _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ // A sheet that is dismissing (dismiss(), or unmounting while presented) keeps that status, ++ // so `onClose` lands on DISMISSED and unmounts it; MINIMIZED would read as a user close, ++ // which with `enableDismissOnClose=false` leaves the portal mounted forever. ++ if (_index === -1 && statusRef.current === MODAL_STATUS.DISMISSING) { ++ // keep dismissing ++ } else { ++ statusRef.current = ++ _index === -1 ? MODAL_STATUS.MINIMIZED : MODAL_STATUS.PRESENTED; ++ } + + if (_providedOnChange) { + _providedOnChange(_index, _position, _type); +diff --git a/node_modules/@gorhom/bottom-sheet/src/index.ts b/node_modules/@gorhom/bottom-sheet/src/index.ts +index 7aaea57..6a5f5a4 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/index.ts ++++ b/node_modules/@gorhom/bottom-sheet/src/index.ts +@@ -12,6 +12,8 @@ export { useBottomSheetSpringConfigs } from './hooks/useBottomSheetSpringConfigs + export { useBottomSheetTimingConfigs } from './hooks/useBottomSheetTimingConfigs'; + export { useBottomSheetInternal } from './hooks/useBottomSheetInternal'; + export { useBottomSheetModalInternal } from './hooks/useBottomSheetModalInternal'; ++export { BottomSheetModalInternalProvider } from './contexts/modal/internal'; ++export type { BottomSheetModalInternalContextType } from './contexts/modal/internal'; + export { useScrollEventsHandlersDefault } from './hooks/useScrollEventsHandlersDefault'; + export { useGestureEventsHandlersDefault } from './hooks/useGestureEventsHandlersDefault'; + export { useBottomSheetGestureHandlers } from './hooks/useBottomSheetGestureHandlers'; diff --git a/patches/react-native-uitextview+2.2.0.patch b/patches/react-native-uitextview+2.2.0.patch deleted file mode 100644 index f64f09ebc7..0000000000 --- a/patches/react-native-uitextview+2.2.0.patch +++ /dev/null @@ -1,72 +0,0 @@ -diff --git a/node_modules/react-native-uitextview/ios/RNUITextView.mm b/node_modules/react-native-uitextview/ios/RNUITextView.mm -index d0c31ed..df8a233 100644 ---- a/node_modules/react-native-uitextview/ios/RNUITextView.mm -+++ b/node_modules/react-native-uitextview/ios/RNUITextView.mm -@@ -22,6 +22,7 @@ using namespace facebook::react; - RNUITextViewShadowNode::ConcreteState::Shared _state; - UITapGestureRecognizer * _outsideTapRecognizer; - BOOL _suppressSelectionChange; -+ CGRect _lastTextLayoutFrame; - } - - + (ComponentDescriptorProvider)componentDescriptorProvider -@@ -47,7 +48,8 @@ using namespace facebook::react; - _textView.delegate = self; - // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. - _textView.layoutManager.usesFontLeading = NO; -- [self addSubview:_textView]; -+ _textView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; -+ [_view addSubview:_textView]; - - const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self - action:@selector(handleLongPressIfNecessary:)]; -@@ -94,16 +96,15 @@ using namespace facebook::react; - // Reset the frame to zero so that when it properly lays out on the next use - _textView.frame = CGRectZero; - _textView.attributedText = nil; -+ _lastTextLayoutFrame = CGRectZero; - } - - - (void)layoutSubviews - { - [super layoutSubviews]; -- // _textView's frame is assigned inside drawRect, which only fires when -- // state changes. Trigger a redraw whenever the host frame moves out from -- // under it (rotation, parent relayout) so the text view resizes and -- // onTextLayout re-fires with the new line wrapping. -- if (!CGRectEqualToRect(_textView.frame, _view.frame)) { -+ // Autoresizing updates UIKit immediately; report the new line layout when -+ // the content container's bounds change. -+ if (!CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds)) { - [self setNeedsDisplay]; - } - } -@@ -124,8 +125,9 @@ using namespace facebook::react; - // entirely when nothing actually changed so a JS-side state update made in - // response to onSelectionChange doesn't deselect what the user is selecting. - const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; -- const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); -- if (!textChanged && !frameChanged) { -+ const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.bounds); -+ const BOOL layoutChanged = !CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds); -+ if (!textChanged && !frameChanged && !layoutChanged) { - return; - } - if (textChanged) { -@@ -142,7 +144,7 @@ using namespace facebook::react; - _suppressSelectionChange = NO; - } - if (frameChanged) { -- _textView.frame = _view.frame; -+ _textView.frame = _view.bounds; - } - - __block std::vector<std::string> lines; -@@ -162,6 +164,7 @@ using namespace facebook::react; - } - }]; - -+ _lastTextLayoutFrame = _view.bounds; - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast<const facebook::react::RNUITextViewEventEmitter>(_eventEmitter) - ->onTextLayout(facebook::react::RNUITextViewEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines}); diff --git a/plugin-examples/lifecycle-logger/index.server.ts b/plugin-examples/lifecycle-logger/index.server.ts index 04d967949c..0f5fd4ed95 100644 --- a/plugin-examples/lifecycle-logger/index.server.ts +++ b/plugin-examples/lifecycle-logger/index.server.ts @@ -29,6 +29,10 @@ export default function contribute(server: PluginServerContext) { logHook("agent.created", event); }); + server.on("agent.closed", (event) => { + logHook("agent.closed", event); + }); + server.on("agent.turn_started", (event) => { logHook("agent.turn_started", event); }); 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/index.server.ts b/plugins/claude-usage-source/index.server.ts index 82bcae58b9..4c97771bfa 100644 --- a/plugins/claude-usage-source/index.server.ts +++ b/plugins/claude-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Claude", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => discover(scope), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/claude-usage-source/server/keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts index f79153933f..dd6c58f77a 100644 --- a/plugins/claude-usage-source/server/keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -134,12 +134,14 @@ describe("Claude credential routes", () => { JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }), ); const accounts = await discover( + { kind: "global" }, { home: dir, env: { CLAUDE_CONFIG_DIR: dir }, platform: "linux" }, fixtureFetch("fixture-default"), ); 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 2f8119f502..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"; @@ -29,22 +30,23 @@ async function accountIdentity( input: Parameters<typeof fetchUsage>[0], env: NodeJS.ProcessEnv = {}, ) { - const accounts = await discover(lookup(env), profile); + const accounts = await discover({ kind: "global" }, lookup(env), profile); const account = accounts.find( (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), ); if (!account) throw new Error("Expected discovered login"); return { key: account.key, ...(account.label ? { label: account.label } : {}) }; } -async function logins(options: Parameters<typeof discover>[0]) { - return (await discover(options, profile)).map( +async function logins(options: Parameters<typeof discover>[1]) { + return (await discover({ kind: "global" }, options, profile)).map( (account) => account.input as Parameters<typeof fetchUsage>[0], ); } 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(); } @@ -204,9 +207,12 @@ test("Claude Keychain login replaces the fallback file", async () => { const inputs = await logins({ ...lookup(), platform: "darwin", + env: { USER: "fixture-user" }, readKeychainCredentials: async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } }), }); - expect(inputs).toEqual([{ route: { store: "keychain" } }]); + expect(inputs).toEqual([ + { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" } }, + ]); }); test("Claude account metadata alone does not discover a login", async () => { @@ -221,23 +227,30 @@ test("keychain identity belongs to its token even when Claude Code metadata name oauthAccount: { accountUuid: "stale", organizationUuid: "stale-org" }, }); const [identity] = await discover( + { kind: "global" }, { ...lookup(), platform: "darwin", + env: { USER: "fixture-user" }, readKeychainCredentials: async () => ({ claudeAiOauth: { accessToken: "fixture-keychain-other" }, }), }, profile, ); - expect(identity).toEqual({ key: "pi-account.pi-org", input: { route: { store: "keychain" } } }); + expect(identity).toEqual({ + key: "pi-account.pi-org", + harness: "Claude", + input: { + route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" }, + }, + }); }); test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => { 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 289076a755..29e78a54e9 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -49,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 }, }); } @@ -193,7 +194,9 @@ describe("claude usage source", () => { "discovers no Claude logins in %s", async (scenario) => { if (scenario === "unrelated files") writeFileSync(join(homeDir, "unrelated.json"), "{}"); - expect(await discover({ home: homeDir, env: {}, platform: "linux" })).toEqual([]); + expect( + await discover({ kind: "global" }, { home: homeDir, env: {}, platform: "linux" }), + ).toEqual([]); }, ); @@ -757,6 +760,7 @@ it("discovery and fetch share the explicit macOS keychain route", async () => { try { const lookup = { platform: "darwin" as const, + env: { USER: "fixture", CLAUDE_CONFIG_DIR: directory }, claudeHome: directory, home: directory, readKeychainCredentials: async () => ({ @@ -765,7 +769,13 @@ it("discovery and fetch share the explicit macOS keychain route", async () => { }; let usageRequested = false; await fetchUsage( - { route: { store: "keychain" } }, + { + route: { + store: "keychain", + account: "fixture", + service: `Claude Code-credentials-${hashAccountKey(directory).slice(0, 8)}`, + }, + }, async () => { usageRequested = true; return new Response(null, { status: 401 }); @@ -774,7 +784,13 @@ it("discovery and fetch share the explicit macOS keychain route", async () => { ); expect(usageRequested).toBe(true); const account = await accountFor( - { route: { store: "keychain" } }, + { + route: { + store: "keychain", + account: "fixture", + service: `Claude Code-credentials-${hashAccountKey(directory).slice(0, 8)}`, + }, + }, async () => jsonResponse({ account: { uuid: "keychain-account" }, @@ -864,7 +880,10 @@ it("discovers a Pi OAuth login independently of the CLI login", async () => { anthropic: { type: "oauth", access: "fixture-pi", accountId: "pi-account" }, }), ); - const inputs = await discover({ home: directory, env: {}, platform: "linux" }); + const inputs = await discover( + { kind: "global" }, + { home: directory, env: {}, platform: "linux" }, + ); expect(inputs.map((account) => account.input)).toContainEqual({ route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") }, }); @@ -880,7 +899,7 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc const lookup = { home: directory, claudeHome: directory, - env: {}, + env: { USER: "fixture" }, platform: "darwin" as const, now: () => 10_800_000, readKeychainCredentials: async () => ({ @@ -892,9 +911,15 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc calls++; throw new Error("must not call network"); }; - const accounts = await discover(lookup, network); + const accounts = await discover({ kind: "global" }, lookup, network); expect(accounts).toEqual([ - { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { route: { store: "keychain" } } }, + { + key: expect.stringMatching(/^[a-f0-9]{64}$/), + harness: "Claude", + input: { + route: { store: "keychain", service: "Claude Code-credentials", account: "fixture" }, + }, + }, ]); expect(await fetchUsage(accounts[0]!.input as UsageInput, network, lookup)).toEqual({ status: "unavailable", @@ -912,10 +937,8 @@ async function accountFor( now: () => number, lookup: Parameters<typeof discover>[0] = {}, ) { - const accounts = await discover({ ...lookup, now }, fetchApi); - const account = accounts.find( - (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), - ); + const accounts = await discover({ kind: "global" }, { ...lookup, now }, fetchApi); + const account = accounts.find((candidate) => isDeepStrictEqual(candidate.input, input)); if (!account) throw new Error("Expected discovered login"); return { key: account.key, ...(account.label ? { label: account.label } : {}) }; } @@ -936,9 +959,13 @@ it.each([401, 403])("fetch reports rejected usage HTTP %i", async (status) => { it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account", async (status) => { const lookup = tokenLookup(`profile-rejection-${status}`); const api: typeof fetch = async () => new Response(null, { status }); - const accounts = await discover(lookup, api); + 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( @@ -969,19 +996,50 @@ it.each([ if (networkError) throw new Error(error); return new Response(null, { status: 500 }); }; - const accounts = await discover(lookup, api); - expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]); + const accounts = await discover({ kind: "global" }, lookup, api); + 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( join(lookup.claudeHome, ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "expiring-token", expiresAt: 0 } }), ); - const [account] = await discover(lookup, async () => { + const [account] = await discover({ kind: "global" }, lookup, async () => { throw new Error("expired token must not fetch"); }); expect(await fetchUsage(account!.input as UsageInput, fetch, lookup)).toEqual({ @@ -999,3 +1057,80 @@ it("re-reads a refreshed login at the original locator", async () => { ); expect(report.status).toBe("available"); }); + +it("session discovery reads only its Claude home and excludes foreign sessions", async () => { + const home = mkdtempSync(join(tmpdir(), "claude-session-scope-")); + try { + writeClaudeCredentials(home, "scoped-unique-token"); + const scope = { + kind: "session" as const, + provider: "claude", + env: { HOME: home, CLAUDE_CONFIG_DIR: home }, + }; + const calls: string[] = []; + const api: typeof fetch = async (url) => { + calls.push(String(url)); + return jsonResponse({ + account: { uuid: "session-account", email: "session@example.test" }, + organization: { uuid: "org" }, + }); + }; + 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", + harness: "Claude", + input: credentialInput(home), + }, + ]); + await discover(scope, lookup, api); + expect(calls).toEqual(["https://api.anthropic.com/api/oauth/profile"]); + for (const env of [ + { CLAUDE_CODE_USE_BEDROCK: "1" }, + { CLAUDE_CODE_USE_VERTEX: "1" }, + { ANTHROPIC_BASE_URL: "https://other.test" }, + ]) { + expect(await discover({ ...scope, env: { ...scope.env, ...env } }, lookup, api)).toEqual([]); + } + expect(await discover({ ...scope, provider: "codex" }, lookup, api)).toEqual([]); + expect( + await discover( + { ...scope, env: { ...scope.env, CLAUDE_CONFIG_DIR: join(home, "missing") } }, + lookup, + api, + ), + ).toEqual([]); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +it("session Keychain input retains the selected config-directory locator through fetch", async () => { + const reads: string[] = []; + const scope = { + kind: "session" as const, + provider: "claude", + env: { HOME: "/fixture", USER: "fixture-user", CLAUDE_CONFIG_DIR: "/fixture/work" }, + }; + const service = `Claude Code-credentials-${hashAccountKey(scope.env.CLAUDE_CONFIG_DIR).slice(0, 8)}`; + const lookup = { + platform: "darwin" as const, + readKeychainCredentials: async (selected: string, account: string) => { + reads.push(`${selected}:${account}`); + return { claudeAiOauth: { accessToken: "scoped-keychain-fixture" } }; + }, + }; + const accounts = await discover(scope, lookup, async () => + jsonResponse({ account: { uuid: "keychain-scoped" }, organization: { uuid: "org" } }), + ); + expect(accounts).toHaveLength(1); + const input = inputSchema.parse(accounts[0]?.input); + expect(input.route).toEqual({ store: "keychain", service, account: "fixture-user" }); + await fetchUsage(input, async () => jsonResponse(makeClaudeResponse()), { + ...lookup, + env: { CLAUDE_CONFIG_DIR: "/somewhere-else" }, + }); + expect(reads).toEqual(Array(3).fill(`${service}:fixture-user`)); +}); diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 03509bba21..9efade2062 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -11,6 +11,7 @@ import { toneFromUsedPct, unavailable, type UsageAccount, + type UsageScope, windowFromUsedPct, type UsageReport, type UsageWindow, @@ -315,10 +316,11 @@ async function runSecurityCommand(args: string[]): Promise<string | null> { export async function readClaudeKeychainCredentials( run: ClaudeKeychainCommandRunner = runSecurityCommand, account: string = claudeKeychainAccount(), + service: string = CLAUDE_KEYCHAIN_SERVICE, ): Promise<unknown | null> { const lookups = [ - ["find-generic-password", "-a", account, "-w", "-s", CLAUDE_KEYCHAIN_SERVICE], - ["find-generic-password", "-w", "-s", CLAUDE_KEYCHAIN_SERVICE], + ["find-generic-password", "-a", account, "-w", "-s", service], + ["find-generic-password", "-w", "-s", service], ]; for (const args of lookups) { @@ -337,7 +339,7 @@ export async function readClaudeKeychainCredentials( } interface ClaudeCredentialLookup extends StoreLookup { - readKeychainCredentials?: () => Promise<unknown | null>; + readKeychainCredentials?: (service: string, account: string) => Promise<unknown | null>; claudeHome?: string; } @@ -350,32 +352,38 @@ function claudeCredentialPath(lookup: ClaudeCredentialLookup): string { async function keychainCredentialRecord( lookup: ClaudeCredentialLookup, + route = claudeKeychainRoute(lookup), ): Promise<ClaudeCredentialRecord | null> { if ((lookup.platform ?? process.platform) !== "darwin") return null; const parsed = ClaudeCredentialsSchema.safeParse( - await (lookup.readKeychainCredentials ?? readClaudeKeychainCredentials)(), + await (lookup.readKeychainCredentials + ? lookup.readKeychainCredentials(route.service, route.account) + : readClaudeKeychainCredentials(undefined, route.account, route.service)), ); return parsed.success ? toCredentialRecord(parsed.data) : null; } export async function discover( + scope: UsageScope, lookup: ClaudeCredentialLookup = {}, fetchApi: typeof fetch = fetch, ): Promise<UsageAccount[]> { - // Claude Code only reads the file when its Keychain login is empty. - const primary: UsageInput = (await keychainCredentialRecord(lookup)) - ? { route: { store: "keychain" } } - : { route: { store: "claude", path: claudeCredentialPath(lookup) } }; - const candidates: UsageInput[] = [ - primary, - { route: { store: "pi", path: piAuthPath(lookup) } }, - ...discoverOmp(lookup).map((route) => ({ route })), - ]; + if (scope.kind === "session") + lookup = { + ...lookup, + env: scope.env, + home: scope.env.HOME || scope.env.USERPROFILE || homedir(), + }; + const candidates = + scope.kind === "global" ? await globalRoutes(lookup) : await sessionRoutes(scope, lookup); const accounts: UsageAccount[] = []; 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; @@ -386,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); @@ -395,6 +403,59 @@ export async function discover( return accounts; } +function claudeKeychainRoute(lookup: ClaudeCredentialLookup) { + const env = lookup.env ?? process.env; + const configDir = env.CLAUDE_CONFIG_DIR; + // macOS still uses Keychain with CLAUDE_CONFIG_DIR, with an entry keyed to that directory: + // https://code.claude.com/docs/en/authentication#credential-management + // Verified in @anthropic-ai/claude-code 2.1.59, package/cli.js: _c() appends the first + // 8 SHA-256 hex characters of HA() (the NFC-normalized config directory). + const suffix = configDir ? `-${hashAccountKey(configDir.normalize("NFC")).slice(0, 8)}` : ""; + return { + store: "keychain" as const, + service: `${CLAUDE_KEYCHAIN_SERVICE}${suffix}`, + account: claudeKeychainAccount(env.USER), + }; +} + +async function claudeRoute(lookup: ClaudeCredentialLookup): Promise<UsageInput> { + const route = claudeKeychainRoute(lookup); + return (await keychainCredentialRecord(lookup, route)) + ? { route } + : { route: { store: "claude", path: claudeCredentialPath(lookup) } }; +} + +async function globalRoutes(lookup: ClaudeCredentialLookup): Promise<UsageInput[]> { + return [ + await claudeRoute(lookup), + { route: { store: "pi", path: piAuthPath(lookup) } }, + ...discoverOmp(lookup).map((route) => ({ route })), + ]; +} + +async function sessionRoutes( + scope: Extract<UsageScope, { kind: "session" }>, + lookup: ClaudeCredentialLookup, +): Promise<UsageInput[]> { + if (scope.provider === "claude") { + const env = scope.env; + const foreign = + env.ANTHROPIC_BASE_URL && + env.ANTHROPIC_BASE_URL.replace(/\/+$/, "") !== "https://api.anthropic.com"; + if (enabled(env.CLAUDE_CODE_USE_BEDROCK) || enabled(env.CLAUDE_CODE_USE_VERTEX) || foreign) + return []; + return [await claudeRoute(lookup)]; + } + if (!scope.model?.startsWith("anthropic/")) return []; + if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }]; + if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route })); + return []; +} + +function enabled(value: string | undefined): boolean { + return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; +} + /** Re-read the selected login; the harness owns token refresh. */ export async function resolveClaudeCredentials( input: UsageInput, @@ -402,7 +463,12 @@ export async function resolveClaudeCredentials( ): Promise<ClaudeCredentialRecord | null> { const route = input.route; if (route.store === "claude") return readCredentialFile(route.path); - if (route.store === "keychain") return keychainCredentialRecord(lookup); + if (route.store === "keychain") + return keychainCredentialRecord(lookup, { + store: "keychain", + service: route.service ?? CLAUDE_KEYCHAIN_SERVICE, + account: route.account ?? claudeKeychainAccount(), + }); const oauth = await readHarness(route, lookup); return oauth ? { oauth: { accessToken: oauth.access }, expires: oauth.expires } : null; } @@ -458,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: { @@ -468,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()); } @@ -496,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 }); @@ -532,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/claude-usage-source/shared/input.ts b/plugins/claude-usage-source/shared/input.ts index 76ff6a8795..b82c69cfde 100644 --- a/plugins/claude-usage-source/shared/input.ts +++ b/plugins/claude-usage-source/shared/input.ts @@ -9,7 +9,13 @@ export const routeSchema = z.discriminatedUnion("store", [ credentialId: z.number().int().positive(), }) .strict(), - z.object({ store: z.literal("keychain") }).strict(), + z + .object({ + store: z.literal("keychain"), + service: z.string().optional(), + account: z.string().optional(), + }) + .strict(), ]); export const inputSchema = z.object({ route: routeSchema }).strict(); export type UsageInput = z.infer<typeof inputSchema>; diff --git a/plugins/codex-usage-source/index.server.ts b/plugins/codex-usage-source/index.server.ts index 786bfe5486..8d38c15ebf 100644 --- a/plugins/codex-usage-source/index.server.ts +++ b/plugins/codex-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Codex", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => discover(scope), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/codex-usage-source/server/stores.test.ts b/plugins/codex-usage-source/server/stores.test.ts index 3368a95147..335985bacd 100644 --- a/plugins/codex-usage-source/server/stores.test.ts +++ b/plugins/codex-usage-source/server/stores.test.ts @@ -23,15 +23,15 @@ async function accountIdentity( input: Parameters<typeof fetchUsage>[0], env: NodeJS.ProcessEnv = {}, ) { - const accounts = await discover(lookup(env)); + const accounts = await discover({ kind: "global" }, lookup(env)); const account = accounts.find( (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), ); if (!account) throw new Error("Expected discovered login"); return { key: account.key, ...(account.label ? { label: account.label } : {}) }; } -async function logins(options: Parameters<typeof discover>[0]) { - return (await discover(options)).map( +async function logins(options: Parameters<typeof discover>[1]) { + return (await discover({ kind: "global" }, options)).map( (account) => account.input as Parameters<typeof fetchUsage>[0], ); } @@ -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 e92c7ea2f2..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"; @@ -217,7 +218,10 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => { "openai-codex": { type: "oauth", access: "fixture-pi", accountId: "pi-account" }, }), ); - const inputs = await discover({ home: directory, env: {}, platform: "linux" }); + const inputs = await discover( + { kind: "global" }, + { home: directory, env: {}, platform: "linux" }, + ); expect(inputs.map((account) => account.input)).toContainEqual({ route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") }, }); @@ -227,16 +231,19 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => { }); async function accountFor(input: CodexUsageInput) { - const accounts = await discover({ - home: fixtureHome, - env: { - CODEX_HOME: - input.route.store === "codex" - ? (await import("node:path")).dirname(input.route.path) - : fixtureHome, + const accounts = await discover( + { kind: "global" }, + { + home: fixtureHome, + env: { + CODEX_HOME: + input.route.store === "codex" + ? (await import("node:path")).dirname(input.route.path) + : fixtureHome, + }, + platform: "linux", }, - platform: "linux", - }); + ); const account = accounts.find( (candidate) => JSON.stringify(candidate.input) === JSON.stringify(input), ); @@ -250,7 +257,7 @@ test.each(["empty home", "unrelated files"])( const home = await mkdtemp(join(tmpdir(), "codex-no-login-")); try { if (scenario === "unrelated files") await writeFile(join(home, "unrelated.json"), "{}"); - expect(await discover({ home, env: {}, platform: "linux" })).toEqual([]); + expect(await discover({ kind: "global" }, { home, env: {}, platform: "linux" })).toEqual([]); } finally { await rm(home, { recursive: true, force: true }); } @@ -379,3 +386,82 @@ test("unknown lengths and code review never get a duration from their slot", asy ], }); }); + +test("session discovery isolates CODEX_HOME and excludes foreign routes", async () => { + const home = await mkdtemp(join(tmpdir(), "codex-session-scope-")); + try { + await writeFile( + join(home, "auth.json"), + JSON.stringify({ tokens: { access_token: "session-token", account_id: "session-account" } }), + ); + const scope = { + kind: "session" as const, + provider: "codex", + env: { HOME: home, CODEX_HOME: home }, + }; + const accounts = await discover(scope, { home: "/unused-default-home", env: {} }); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.input).toEqual(authInput(home)); + expect( + await discover({ ...scope, env: { ...scope.env, OPENAI_BASE_URL: "https://other.test" } }), + ).toEqual([]); + expect(await discover({ ...scope, provider: "claude" })).toEqual([]); + expect( + await discover({ ...scope, env: { HOME: home, CODEX_HOME: join(home, "missing") } }), + ).toEqual([]); + } finally { + 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 7d2d89402f..0b211726c6 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -6,6 +6,7 @@ import { hashAccountKey, unavailable, type UsageAccount, + type UsageScope, toneFromUsedPct, windowFromReportedDuration, type UsageReport, @@ -57,7 +58,27 @@ interface Auth { expires?: number; } -export async function discover(lookup: StoreLookup = {}): Promise<UsageAccount[]> { +export async function discover( + scope: UsageScope, + lookup: StoreLookup = {}, +): Promise<UsageAccount[]> { + if (scope.kind === "session") + lookup = { + ...lookup, + env: scope.env, + home: scope.env.HOME || scope.env.USERPROFILE || homedir(), + }; + const candidates = scope.kind === "global" ? globalRoutes(lookup) : sessionRoutes(scope, lookup); + const present: UsageAccount[] = []; + for (const input of candidates) { + const auth = await readAuth(input, lookup); + if (auth) + present.push({ ...accountIdentity(auth, input), harness: harnessLabel(input), input }); + } + return present; +} + +function globalRoutes(lookup: StoreLookup): CodexUsageInput[] { const env = lookup.env ?? process.env; const home = lookup.home ?? homedir(); const paths = [ @@ -77,12 +98,36 @@ export async function discover(lookup: StoreLookup = {}): Promise<UsageAccount[] { route: { store: "pi", path: piAuthPath(lookup) } }, ...discoverOmp(lookup).map((route) => ({ route })), ); - const present: UsageAccount[] = []; - for (const input of candidates) { - const auth = await readAuth(input, lookup); - if (auth) present.push({ ...accountIdentity(auth, input), input }); + return candidates; +} + +function sessionRoutes( + scope: Extract<UsageScope, { kind: "session" }>, + lookup: StoreLookup, +): CodexUsageInput[] { + const env = scope.env; + const home = lookup.home ?? homedir(); + if (scope.provider === "codex") { + if (env.OPENAI_BASE_URL) return []; + return [ + { + route: { store: "codex", path: join(env.CODEX_HOME || join(home, ".codex"), "auth.json") }, + }, + ]; } - return present; + if (!scope.model?.startsWith("openai/")) return []; + if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }]; + if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route })); + if (scope.provider === "opencode") + return [ + { + route: { + store: "opencode", + path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"), + }, + }, + ]; + return []; } export async function readAuth( @@ -250,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/copilot-usage-source/index.server.ts b/plugins/copilot-usage-source/index.server.ts index 4f25223cb4..d5e965d759 100644 --- a/plugins/copilot-usage-source/index.server.ts +++ b/plugins/copilot-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "GitHub Copilot", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/cursor-usage-source/index.server.ts b/plugins/cursor-usage-source/index.server.ts index 078c321590..5b19939833 100644 --- a/plugins/cursor-usage-source/index.server.ts +++ b/plugins/cursor-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Cursor", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/grok-usage-source/index.server.ts b/plugins/grok-usage-source/index.server.ts index 15a6f4efd9..d3c37b9c15 100644 --- a/plugins/grok-usage-source/index.server.ts +++ b/plugins/grok-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Grok", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/hoplite-provider/icon.svg b/plugins/hoplite-provider/icon.svg new file mode 100644 index 0000000000..ed585a9083 --- /dev/null +++ b/plugins/hoplite-provider/icon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="1.394 1.679 8.494 7.828" fill="currentColor"><path d="M8.237 7.813C8.276 7.789 8.327 7.796 8.358 7.83L9.741 9.346C9.797 9.407 9.754 9.506 9.671 9.507H8.977C8.924 9.507 8.881 9.463 8.881 9.41V9.125C8.881 9.089 8.861 9.057 8.83 9.04L7.589 8.379C7.524 8.344 7.521 8.251 7.584 8.212L8.237 7.813ZM3.944 1.679C4.502 1.679 5.052 1.82 5.54 2.09L5.962 2.323C6.024 2.358 6.029 2.446 5.97 2.486L5.091 3.09C5.065 3.108 5.05 3.137 5.05 3.169V3.495C5.05 3.529 5.032 3.561 5.003 3.578L4.68 3.769C4.594 3.82 4.498 3.722 4.549 3.637L4.592 3.566C4.63 3.502 4.584 3.421 4.51 3.421H4.028C3.925 3.421 3.894 3.562 3.989 3.604L4.377 3.775C4.448 3.806 4.455 3.904 4.389 3.944L3.77 4.323C3.755 4.332 3.737 4.337 3.72 4.337H3.361C3.309 4.337 3.268 4.379 3.268 4.431C3.268 4.484 3.309 4.526 3.361 4.526H3.779C3.797 4.526 3.814 4.521 3.829 4.513L7.599 2.272C8.135 1.954 8.815 2.008 9.294 2.407C9.307 2.418 9.314 2.434 9.314 2.451V2.729C9.315 2.812 9.413 2.856 9.475 2.8L9.728 2.57C9.789 2.514 9.888 2.558 9.888 2.641V5.812C9.888 5.865 9.845 5.908 9.792 5.908H9.359C9.331 5.908 9.303 5.895 9.285 5.873L8.374 4.773C8.337 4.728 8.268 4.728 8.229 4.771L7.468 5.625C7.429 5.669 7.438 5.737 7.487 5.769L9.038 6.776C9.098 6.815 9.096 6.903 9.035 6.939L6.618 8.363C6.556 8.4 6.478 8.358 6.474 8.286L6.439 7.633C6.437 7.578 6.48 7.532 6.535 7.532H6.925C6.948 7.532 6.971 7.523 6.988 7.508L7.211 7.31C7.276 7.252 7.236 7.144 7.148 7.142L5.991 7.118H1.49C1.437 7.118 1.394 7.075 1.394 7.022V3.308C1.394 3.255 1.437 3.212 1.49 3.212H2.544C2.597 3.212 2.64 3.169 2.64 3.116V2.969C2.64 2.916 2.597 2.873 2.544 2.873H1.461C1.366 2.873 1.329 2.75 1.408 2.697L2.134 2.221C2.672 1.867 3.301 1.679 3.944 1.679Z"/></svg> diff --git a/plugins/hoplite-provider/index.server.ts b/plugins/hoplite-provider/index.server.ts new file mode 100644 index 0000000000..4c101df470 --- /dev/null +++ b/plugins/hoplite-provider/index.server.ts @@ -0,0 +1,7 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { createHopliteProvider } from "./server/provider.js"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createHopliteProvider()); + return () => {}; +} diff --git a/plugins/hoplite-provider/paseo-plugin.json b/plugins/hoplite-provider/paseo-plugin.json new file mode 100644 index 0000000000..138cf83ad6 --- /dev/null +++ b/plugins/hoplite-provider/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "hoplite-provider", "requirements": { "paseo": ">=0.11.0" } } diff --git a/plugins/hoplite-provider/server/provider.ts b/plugins/hoplite-provider/server/provider.ts new file mode 100644 index 0000000000..cb362052ea --- /dev/null +++ b/plugins/hoplite-provider/server/provider.ts @@ -0,0 +1,18 @@ +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; +import { runPhaseTransformer } from "./run-phase.js"; + +export function createHopliteProvider(): ProviderRegistration { + return { + requiresConfiguration: true, + ...runAcpProvider({ + id: "hoplite", + label: "Hoplite", + description: "Hoplite's coding agent through the local `hoplite acp` bridge", + icon: "icon.svg", + command: ["hoplite", "acp"], + acpOptions: { workspaceOnlyCatalog: true }, + transformers: [runPhaseTransformer], + }), + }; +} diff --git a/plugins/hoplite-provider/server/run-phase.test.ts b/plugins/hoplite-provider/server/run-phase.test.ts new file mode 100644 index 0000000000..5526f386c6 --- /dev/null +++ b/plugins/hoplite-provider/server/run-phase.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { runPhaseTransformer } from "./run-phase.js"; + +const context = { sessionId: "session-1" }; + +// Frames captured from `hoplite acp` 3.0.0. +function runPhase(phase: string | null, label: string | null) { + return { + method: "_hoplite/run_phase", + params: { + sessionId: "thr_c75d39f7d7624256af918eeee2f4941b", + runId: "run_eb213b97bd3147afb2927fbc27f87180", + phase, + label, + startedAt: phase === null ? null : "2026-10-04T17:51:46.311Z", + }, + }; +} + +describe("runPhaseTransformer", () => { + it("shows a provisioning phase as one running workspace item per run", () => { + const update = runPhaseTransformer.notification?.( + runPhase("fetching_repository", "Cloning repository"), + context, + ); + + expect(update).toEqual({ + type: "timeline", + item: { + type: "tool_call", + id: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", + callId: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", + name: "Hoplite workspace", + detail: { type: "plain_text", label: "Cloning repository", icon: "wrench" }, + status: "running", + error: null, + }, + }); + }); + + it("completes the same item when the run leaves provisioning", () => { + const update = runPhaseTransformer.notification?.(runPhase(null, null), context); + + expect(update).toMatchObject({ + type: "timeline", + item: { + id: "hoplite-run-phase:run_eb213b97bd3147afb2927fbc27f87180", + status: "completed", + detail: { label: "Workspace ready" }, + }, + }); + }); + + it("falls back to the phase name when Hoplite sends no label", () => { + const update = runPhaseTransformer.notification?.( + runPhase("configuring_workspace", null), + context, + ); + + expect(update).toMatchObject({ item: { detail: { label: "configuring_workspace" } } }); + }); + + it("ignores other vendor notifications and malformed phases", () => { + expect( + runPhaseTransformer.notification?.({ method: "_hoplite/other", params: {} }, context), + ).toBeNull(); + expect( + runPhaseTransformer.notification?.( + { method: "_hoplite/run_phase", params: { phase: "x" } }, + context, + ), + ).toBeNull(); + }); +}); diff --git a/plugins/hoplite-provider/server/run-phase.ts b/plugins/hoplite-provider/server/run-phase.ts new file mode 100644 index 0000000000..767ee0447d --- /dev/null +++ b/plugins/hoplite-provider/server/run-phase.ts @@ -0,0 +1,39 @@ +import type { AcpTransformer } from "@getpaseo/plugin/server/acp"; +import { z } from "zod"; + +// Hoplite provisions a cloud workspace before the first agent output of a run and reports each +// step as `_hoplite/run_phase`. A null phase ends provisioning for that run. +const runPhaseSchema = z.object({ + runId: z.string().min(1), + phase: z.string().nullable(), + label: z.string().nullable(), +}); + +const RUN_PHASE_METHOD = "_hoplite/run_phase"; + +export const runPhaseTransformer: AcpTransformer = { + notification(notification) { + if (notification.method !== RUN_PHASE_METHOD) return null; + const params = runPhaseSchema.safeParse(notification.params); + if (!params.success) return null; + const { runId, phase, label } = params.data; + const id = `hoplite-run-phase:${runId}`; + const running = phase !== null; + return { + type: "timeline", + item: { + type: "tool_call", + id, + callId: id, + name: "Hoplite workspace", + detail: { + type: "plain_text", + label: running ? (label ?? phase) : "Workspace ready", + icon: "wrench", + }, + status: running ? "running" : "completed", + error: null, + }, + }; + }, +}; diff --git a/plugins/kimi-usage-source/index.server.ts b/plugins/kimi-usage-source/index.server.ts index c6a9a978ff..a18da096a3 100644 --- a/plugins/kimi-usage-source/index.server.ts +++ b/plugins/kimi-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Kimi", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/plugins/minimax-usage-source/index.server.ts b/plugins/minimax-usage-source/index.server.ts index c98af742dd..a72a66c949 100644 --- a/plugins/minimax-usage-source/index.server.ts +++ b/plugins/minimax-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "MiniMax", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; 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/opencode-go-usage-source/index.server.ts b/plugins/opencode-go-usage-source/index.server.ts index b011eaf2f4..b707c26858 100644 --- a/plugins/opencode-go-usage-source/index.server.ts +++ b/plugins/opencode-go-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "OpenCode Go", icon: "icon.svg", input: inputSchema, - discover, + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; 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/plugins/zai-usage-source/index.server.ts b/plugins/zai-usage-source/index.server.ts index 78dfdf7fda..830afc8c29 100644 --- a/plugins/zai-usage-source/index.server.ts +++ b/plugins/zai-usage-source/index.server.ts @@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) { label: "Z.ai", icon: "icon.svg", input: inputSchema, - discover: () => discover(), + discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])), fetch: fetchUsage, }); return () => {}; diff --git a/public-docs/cli.md b/public-docs/cli.md index 71e54474e3..48f37e89de 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -21,8 +21,49 @@ paseo attach <id> # Stream agent output paseo send <id> "also fix linting" # Send follow-up task paseo logs <id> # View agent timeline paseo stop <id> # Stop an agent +paseo usage # Show account quotas and balances ``` +## Account usage + +Show account quotas and balances from the host's usage sources: + +```bash +paseo usage +paseo usage ls --refresh +paseo usage ls --agent <agent-id> +paseo usage inspect <report-id> +paseo usage inspect <report-id> --refresh +``` + +`paseo usage` is equivalent to `paseo usage ls`. The summary groups windows and balances by account, with report IDs, source and account labels, status, and fetch timestamps. Missing values appear as `-`; unavailable and failed reports include their reasons. + +Copy the full report ID from the list into `inspect` to see reset times, balances and units, plan details, supplied forecasts, and login failures with recovery instructions. Report IDs require exact matches. + +`--agent` selects the account quota associated with an agent. It does not measure that agent's token consumption or cost. Use the full agent ID, and do not combine `--agent` with `inspect`. + +Queries use the host's five-minute cache when available. Add `--refresh` to bypass it. Use `--host` or `--home` to [select a daemon](#select-one-daemon). Hosts without usage-source support return an update message. + +### Usage in scripts + +```bash +paseo usage ls --json +paseo usage inspect <report-id> --json +paseo usage ls --quiet +``` + +List JSON is always an array, including `[]` when no reports are returned. Inspect JSON is one report entry. Each entry includes `id`, `sourceId`, `sourceLabel`, `account`, `fetchedAt`, and a nested `report`. Read `report.status` before accessing its data: + +| Status | Fields | +| ------------- | ---------------------------------------------------------------------- | +| `available` | `windows`, with optional `planLabel`, `balances`, and `details` | +| `unavailable` | `problem`, describing expired or rejected credentials or missing quota | +| `error` | `error`, the failure message | + +Entries may also include `loginErrors`. Structured output preserves numeric values and optional fields. Standard `--format yaml`, `--no-headers`, and `--no-color` options are supported; `--quiet` prints full report IDs. + +A completed query exits with status `0`, including when individual reports are unavailable or failed. Connection or request failures, unsupported hosts, unknown report IDs, and conflicting selectors exit with status `1` and write the error to stderr. Human output says **No usage reports returned** for an empty result; this does not establish whether account discovery succeeded for every source. + ## Provider diagnostics Ask the daemon to inspect the provider environment it actually uses: @@ -42,7 +83,8 @@ Use `paseo run` to start a new agent with a task: ```bash paseo run "implement user authentication" paseo run --provider codex "refactor the API layer" -paseo run --background "run the focused test suite" +paseo run --no-wait "run the focused test suite" +paseo run --output-schema schema.json "name this branch" paseo run --new-workspace worktree --worktree-mode branch-off --new-branch feature/x --base origin/main "implement feature X" paseo run --workspace <workspace-id> "review the current diff" paseo run --output-schema schema.json "extract release notes" @@ -55,9 +97,14 @@ Worktree creation accepts `--worktree-mode branch-off|checkout-branch|checkout-p When an existing Paseo agent runs the same command, Paseo recognizes it through `PASEO_AGENT_ID`. Without explicit placement, the new agent becomes its subagent in the same workspace. `--workspace` can place that subagent elsewhere without changing its parent. -Use `--output-schema` to return only matching JSON output. You can pass a schema file path or an inline JSON schema object. This mode cannot be used with `--background`. +Use `--output-schema` to return only matching JSON output. You can pass a schema file path or an inline JSON schema object. This mode cannot be used with `--no-wait`. -By default, `paseo run` waits for completion. Use `--background` to return immediately while the agent keeps running. +By default, `paseo run` waits for completion. Use `--no-wait` to return immediately while the agent keeps running, the same flag `paseo send` takes. + +The old `run --background` and `-d` aliases still mean `--no-wait`. They are deprecated, hidden +from help, and print a warning to stderr. Use `--no-wait` in scripts. Workspace visibility is +selected separately with `workspace create --background`; this release does not repurpose +`run --background`. ## Projects @@ -116,10 +163,23 @@ paseo workspace create \ --pr-number 2186 ``` +Add `--background` to hide a workspace from default discovery. Its agents and terminals keep +ordinary history, persistence, hooks, and restart recovery. `--no-background` explicitly overrides +caller-workspace inheritance; omission inherits when run by a Paseo agent and defaults to false +from a human shell. Visibility is creation-only. + +```bash +paseo workspace create --isolation local --background --path /path/to/repo +paseo run --workspace <workspace-id> --no-wait "review the checkout flow" +``` + +Exact workspace and agent IDs remain accessible regardless of listing visibility. + Then list, use, rename, or archive it: ```bash paseo workspace ls +paseo workspace ls --background # Also include background workspaces paseo run --workspace <workspace-id> "implement authentication" paseo workspace rename <workspace-id> "Auth rework" paseo workspace rename <workspace-id> --reset # back to the branch or directory name @@ -168,11 +228,12 @@ The output includes each script's lifecycle and supervised terminal ID. Services Create and manage trusted plugins on a daemon: ```bash -paseo plugin init /absolute/path/to/plugin -paseo plugin install /absolute/path/to/plugin -paseo plugin add owner/repository +paseo plugin init ./plugin +paseo plugin install ./plugin +paseo plugin add owner/slug +paseo plugin add git:owner/repository paseo plugin add https://gitlab.com/group/repository.git --ref main -paseo plugin add owner/monorepo:plugins/review +paseo plugin add git:owner/monorepo:plugins/review paseo plugin ls [id] paseo plugin update my-plugin paseo plugin update --all @@ -183,8 +244,10 @@ paseo plugin enable my-plugin paseo plugin remove my-plugin ``` -GitHub shorthand checks an existing host directory first. Append `:<directory>` for a plugin in a -monorepo. `paseo plugin ls [id]` does not contact the remote. `paseo plugin logs <id>` returns the +Bare `owner/slug` installs a reviewed registry listing. Use `git:owner/repository` for GitHub +source, or `./plugin` for a local directory. Append `:<directory>` for a plugin in a monorepo. + +`paseo plugin ls [id]` does not contact the remote. `paseo plugin logs <id>` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for structured entries, or run `paseo --host <target> plugin logs <id>` for another daemon. See the [Plugin reference](/docs/plugins/reference) for installation, trust, lifecycle, and log-retention @@ -197,6 +260,7 @@ paseo ls # Non-archived agents in active workspaces paseo ls -a # Also include archived agents paseo ls -g # Non-archived agents across all workspaces paseo ls -a -g --json # All agents, including archived, as JSON +paseo ls --background # Also include agents in background workspaces ``` ## Streaming output @@ -391,7 +455,7 @@ The CLI is designed to be used by agents themselves. You can instruct an agent t ```bash # Agent A spawns Agent B and waits for it -agent_id=$(paseo run --background --quiet --title api-agent "implement the API") +agent_id=$(paseo run --no-wait --quiet --title api-agent "implement the API") paseo wait "$agent_id" paseo logs "$agent_id" --tail 5 ``` diff --git a/public-docs/community.md b/public-docs/community.md index 72f949bc83..f18815990d 100644 --- a/public-docs/community.md +++ b/public-docs/community.md @@ -10,12 +10,11 @@ category: Getting started These projects are related to Paseo and built by the community. -## Plugin directory +## Plugins -[paseo.cafe](https://paseo.cafe) is a community-run directory of Paseo plugins. Listings are generated -from each plugin's public repository, so it is the fastest way to see what people are building and to -publish your own. It is independent of the Paseo project and nothing on it is reviewed or endorsed by -us, so read a plugin's source before installing it. +Browse the [plugin registry](/plugins) for reviewed community plugins. To share your own, follow +[Publish a plugin](/docs/plugins/publishing) for package preparation and submission. +Plugins run unsandboxed; read their listing and source before installing. ## Tools and integrations @@ -30,7 +29,8 @@ us, so read a plugin's source before installing it. ## Hosting and infrastructure -| Project | What it does | -| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | -| [Paseo Self-hosted](https://github.com/blockfeed/paseo-selfhosted) | Packages the Paseo web UI and a local daemon as a Docker deployment. | -| [Devbox Fleet](https://github.com/omrihaviv/devbox-fleet) | Provisions and maintains per-developer GCP devboxes with Paseo, coding agents, and Tailscale access. | +| Project | What it does | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Paseo Self-hosted](https://github.com/blockfeed/paseo-selfhosted) | Packages the Paseo web UI and a local daemon as a Docker deployment. | +| [Devbox Fleet](https://github.com/omrihaviv/devbox-fleet) | Provisions and maintains per-developer GCP devboxes with Paseo, coding agents, and Tailscale access. | +| [Paseo Vercel Sandbox](https://github.com/vercel-labs/paseo-vercel-sandbox) | Creates persistent Vercel Sandboxes running a Paseo daemon with Codex, Claude Code, OpenCode, or Pi through Vercel AI Gateway. Pair each sandbox as a remote Paseo host. | diff --git a/public-docs/configuration.md b/public-docs/configuration.md index ca578a96ac..a11c4c69ba 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -141,7 +141,7 @@ Daemon logging uses separate console and file sinks by default: - Console: `info` and above - File (`$PASEO_HOME/daemon.log`): `trace` and above -- File rotation: `10m` max file size, `2` retained files total (active + 1 rotated) +- File rotation: `10m` max file size, `3` rotated files kept alongside the active file ```json { @@ -155,7 +155,7 @@ Daemon logging uses separate console and file sinks by default: "path": "daemon.log", "rotate": { "maxSize": "10m", - "maxFiles": 2 + "maxFiles": 3 } } } diff --git a/public-docs/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..f925d0f1d4 100644 --- a/public-docs/plugins/publishing.md +++ b/public-docs/plugins/publishing.md @@ -1,6 +1,6 @@ --- title: Publish a plugin -description: Share a Paseo plugin on npm, through a private registry, or from a Git repository. +description: Prepare, publish and submit a Paseo plugin to the reviewed registry. nav: Publishing order: 45 category: Plugins @@ -8,95 +8,241 @@ category: Plugins # Publish a plugin -Publish your plugin so other people can install and use it in Paseo. Start with a working -[plugin project](/docs/plugins), then choose where to share it: +Start with a working [plugin project](/docs/plugins). Prepare its listing, publish it on npm +or push it to GitHub, then submit it to the registry. After approval, users install the reviewed +version with `paseo plugin add owner/slug`. -- [npm](#publish-on-npm): publish a package on the public npm registry. -- [GitHub or Git](#share-through-github-or-git): let users install from a repository. +1. Prepare your [listing page](#your-listing-page) and, for a visual plugin, an + [interface image](#icons-and-screenshots). +2. [Publish on npm](#publish-on-npm) or [share through Git](#share-through-github-or-git). +3. [Submit to the plugin registry](#plugin-registry). + +For a private plugin, use [direct Git installation](#share-through-github-or-git) or +[private npm packages](#private-npm-packages). + +## Your listing page + +You can submit without `OVERVIEW.md`; the reviewer creates a listing page from your README and +verified behavior. To maintain the page yourself, put `OVERVIEW.md` beside `paseo-plugin.json` +and include it in the published package. The scaffold already includes it in `files`. +Your shipped overview replaces the reviewer-written page. + +Explain these points in order, using headings only when they help: + +1. What the plugin does. +2. How it works, when that is not obvious. +3. Setup: settings, accounts, tokens, external tools, supported operating systems and Paseo versions. +4. Useful capabilities, settings, data access, permissions and limitations. + +Describe where data goes, including prompts and tool results sent to agent providers. Local storage +does not mean data stays on the host. If the plugin downloads or installs software while running, +explain what it installs and its source, and require explicit user confirmation before the download. +This applies to activation and connection tests too; declining must leave the software uninstalled. + +Use plain factual language and sentence case. Omit marketing, unsupported claims, badges, +changelogs, contributing and license sections, and implementation details that do not help someone +choose the plugin. The listing already provides plugin installation instructions. + +The publication validator rejects `paseo plugin add`, `npm install` and `npm i` anywhere in an +overview, including development or setup examples. Link to your README for those commands. + +Also supply a one-sentence description: use `package.json`'s `description` for npm or the manifest's +`description` for Git. The reviewer can fill in a missing description in the registry. + +## Icons and screenshots + +Themes, panels and other visual plugin interfaces need at least one image showing the current +interface. Use sample data and hide private details. An icon alone does not meet this requirement; +plugins without their own visual interface do not need screenshots. + +Declare local images in `paseo-plugin.json` before publishing: + +```json +{ + "id": "review-tools", + "name": "Review tools", + "description": "Reviews changes before merge", + "icon": "assets/icon.png", + "media": ["assets/screenshot.png"], + "requirements": { "paseo": ">=0.11.0" } +} +``` + +Create the referenced files and include `assets/` in `package.json`'s `files` list for npm. +Paths are relative to the manifest, use forward slashes and stay inside the plugin directory. +These metadata fields require Paseo 0.11.0 or later. + +| Field | Listing behavior | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Optional display name; defaults to a humanized plugin ID. | +| `icon` | Optional package-relative PNG. Provider registration icons have a separate [SVG contract](/docs/plugins/reference#providers). | +| `media` | Package-relative paths or public HTTPS URLs. Images: PNG, JPG, JPEG, WebP or GIF. Videos: MP4 or WebM. The first image is the card thumbnail; videos supplement it. | + +Public media URLs need a supported file extension, matched case-insensitively, and must return +HTTP 200 with the corresponding image or video content type. SVG and extensionless attachment +URLs are not accepted as registry media. + +Instead of declaring `media`, you can supply suitable public image URLs in the submission or +review PR. The reviewer can add them to the listing without a new plugin release. ## Publish on npm -The scaffold prepares the package files and development dependencies. You choose the package name -and release version. +### 1. Set package identity -### 1. Set your package name and version - -From the plugin directory, set your package details and allow publication. Replace `@acme` with your npm scope: +Use an npm scope you can publish to and authenticate with `npm login`. From your plugin directory, +replace `@acme` and the repository below with your own values: ```bash npm pkg set name=@acme/paseo-review version=1.0.0 npm pkg delete private +npm pkg set repository.type=git repository.url=git+https://github.com/acme/paseo-review.git ``` -### 2. Check and publish +The current registry tooling requires a GitHub repository for npm listing ownership, supplied +through `package.json` or npm provenance. Set `repository` to your public source repository so +pinning can determine the listing owner. Provenance itself is optional; if present, its repository +must match the package's repository. A matching source checkout is not required for artifact review. + +### 2. Lock installed dependencies + +Keep Paseo's host libraries and type-only dependencies in `devDependencies`. Add other runtime +libraries to `dependencies`. For registry submission, every installed dependency tree needs a lock +with exact versions, resolved sources and integrity, including transitive dependencies. + +For an npm package with installed runtime dependencies, update its lock without running lifecycle +scripts, then create the publishable lock: + +```bash +npm install --package-lock-only --ignore-scripts +npm shrinkwrap +``` + +`npm shrinkwrap` converts `package-lock.json` into `npm-shrinkwrap.json`. npm publishes the shrinkwrap +at the package root; it does not publish `package-lock.json`. A repository-only lock or exact direct +version does not lock the dependency tree installed by your users. + +Plugins using only host libraries and development dependencies need no runtime lock unless a +preparation command installs those dependencies. You can also bundle a library and remove the +runtime dependency declaration. See [preparation and additional setup](#plugins-with-a-build-step) +if your plugin installs a separate tree. + +### 3. Check the package and publish + +Generate any required files before checking the package; the dry run skips packing scripts. ```bash npm run typecheck -npm pack --dry-run +npm pack --dry-run --ignore-scripts +``` + +Check the file list contains `paseo-plugin.json`, at least one runtime entry, every imported local +module, any overview and assets you declared, and the shrinkwrap when required. Repository-only import aliases +and files outside the package do not reach users. The scaffold includes the entries and `client/`, +`server/` and `shared/`; add other asset directories to `files`. + +Confirm any declared preparation command succeeds with the Node/npm versions you support; +an out-of-sync or incompatible lockfile still blocks installation. + +Publish the checked version: + +```bash npm publish --access public ``` -Check the pack output includes any assets you added to the project. - -### 3. Test the published plugin - -On a daemon host with npm available: +On a test daemon, install that exact published version and exercise its contributions: ```bash paseo plugin install npm:@acme/paseo-review@1.0.0 +paseo plugin ls ``` -Users can also paste `npm:@acme/paseo-review` into **Settings → Plugins → Plugin source**. +Require `running` with no error, then [submit the package](#plugin-registry). -:::example[Package configuration] +## Share through GitHub or Git -The scaffold includes this `files` list in `package.json`: +For the public registry, push your plugin to a public GitHub repository. Include its manifest, +runtime entries, imported files and listing assets. Git tags and commits are optional at submission; +provide one to select a revision, or the reviewer resolves `main`. + +If preparation installs runtime npm dependencies, commit a synchronized `package-lock.json` and +add this command to `paseo-plugin.json`: ```json { - "files": [ - "paseo-plugin.json", - "index.client.ts", - "index.client.tsx", - "index.server.ts", - "index.server.tsx", - "client/", - "server/", - "shared/" - ] + "build": [["npm", "ci", "--omit=dev", "--ignore-scripts"]] } ``` -- Add any assets stored outside these directories to `files`. -- Keep the scaffold's SDK and host libraries in `devDependencies`. -- Add other runtime libraries with `npm install <package>`. Paseo installs their dependencies too. -- The npm package name identifies the source. The manifest's `id` identifies the installed plugin. +`npm ci` consumes the committed lock and fails if it disagrees with `package.json`. `--omit=dev` +excludes development tools and `--ignore-scripts` suppresses dependency lifecycle scripts. +The daemon needs npm on its command path. Plugins using only host libraries need no preparation +command. See [preparation](#plugins-with-a-build-step) for generated files or explicit scripts. -See the [project reference](/docs/plugins/reference#project-files) for entry points and runtime boundaries. +Users can install directly from Git without a registry listing: -::: +```bash +paseo plugin add git:acme/paseo-review +paseo plugin add git:acme/monorepo:plugins/review +``` -### Plugins with a build step +Use `--ref` to select a branch, tag or commit for the initial installation. Direct installation +also supports private repositories and other Git hosts when the daemon has access; see +[plugin sources](/docs/plugins/reference#plugin-sources). Registry submission accepts public +GitHub sources and public npm packages. -Paseo compiles TypeScript. An ordinary plugin needs no separate build before publication. -If your plugin generates files, include the generated output in the package. +## Plugin registry -**Installation scripts do not run automatically.** If a dependency needs host-specific setup, -declare a [preparation command](/docs/plugins/reference#cli-reference). +Open the [submission form](https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml). +Use the form so the issue receives the submission label that starts intake. -:::example[Generated files and dependencies] +1. Use your plugin's name as the title. +2. In **Source**, enter the public npm package name or page, or the GitHub repository/folder URL. + Include the version or Git reference if you want a specific release reviewed. npm defaults to + `latest`. Registry IDs and local directories are not submission sources. +3. Select the categories that fit. Include any interface-image URLs in the issue description. -- Generate code and assets before running `npm publish`. -- Keep generated JavaScript in its runtime directory and import it from the TypeScript entry. -- Include generated assets in `files`. -- Keep host-provided modules external when producing your own bundle. -- Remove Git-only dependency-install commands from the published manifest; npm installation already - installs production dependencies. +The bot opens a review PR. The reviewer inspects the artifact and dependencies without running +plugin code, completes the listing and records the exact approved version or commit. Publication +requires the [review policy](https://github.com/getpaseo/plugins/blob/main/REVIEW.md), including +locked installation inputs and appropriate data access, even when automated validation passes. -Paseo skips npm lifecycle scripts during installation, including dependency scripts. For example, -a native dependency that needs rebuilding requires an explicit preparation command. +If a change is requested, reply on the review PR with the corrected version/revision or requested +image URL. The reviewer updates the pin and listing. Intake service errors retry automatically; +missing images or an overview do not prevent opening the review PR. -::: +After approval and publication, users install your listing with: + +```bash +paseo plugin add owner/slug +``` + +npm releases receive automatic update PRs. Submit Git updates manually with the desired revision. +Existing registry installations use approved pins; direct npm/Git installs follow their own update +rules in the [source reference](/docs/plugins/reference#plugin-sources). + +## Plugins with a build step + +Paseo compiles TypeScript, so an ordinary plugin needs no separate publication build. +Keep generated modules in their runtime directory and host-provided modules external. + +Paseo suppresses npm lifecycle scripts during package acquisition. Commands explicitly declared in +manifest `build` still run, including scripts they invoke. Explain the purpose of those commands +and keep their inputs locked and inspectable. Remove Git-only dependency-install commands from +npm manifests when npm already installs the production dependencies. + +Later setup that installs another dependency tree needs its own complete lock, even if your plugin +has a development lockfile. For npm setup, ship the runtime manifest and lock together and use +`npm ci`. For Python setup, ship a requirements file containing exact versions and SHA-256 hashes +for all transitive packages, then install it with: + +```bash +python -m pip install --require-hashes -r requirements-documents.txt +``` + +This command rejects unpinned or unhashed dependencies. Runtime installation also needs the +[confirmation and disclosure](#your-listing-page) described above. + +## Private npm packages :::example[Publish a private package with GitHub Packages] @@ -135,32 +281,3 @@ Paseo uses the host's npm registry settings and credentials for installation and In the app, enter only the source identifier. ::: - -## Share through GitHub or Git - -Push the plugin project to a repository. Users can install it with: - -```bash -paseo plugin install github:acme/paseo-review -``` - -For another Git host: - -```bash -paseo plugin install git:https://git.example.com/acme/paseo-review.git -``` - -If your plugin has runtime npm dependencies, commit `package-lock.json` and add a preparation -command to `paseo-plugin.json`: - -```json -{ - "build": [["npm", "ci", "--omit=dev"]] -} -``` - -- `npm ci` installs the versions in the committed lockfile. -- `--omit=dev` excludes development tools. -- npm must be available on the daemon host. - -Plugins that only use Paseo's host libraries need no preparation command. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 188e88c154..409bb3f5a6 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -52,12 +52,26 @@ The required root manifest is `paseo-plugin.json`: } ``` -| Field | Required | Behavior | -| -------------- | -------- | ---------------------------------------------------------------------- | -| `id` | Yes | Default installation ID. | -| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | -| `requirements` | No | Supported Paseo versions, described below. | -| `build` | No | Preparation commands, described in the CLI reference. | +| Field | Required | Behavior | +| -------------- | -------- | ----------------------------------------------------------------------------------------- | +| `id` | Yes | Default installation ID. | +| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. | +| `icon` | No | Relative path to a PNG inside the plugin package. | +| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | +| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | +| `requirements` | No | Supported Paseo versions, described below. | +| `build` | No | Preparation commands, described in the CLI reference. | + +Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..` +segments. Registry listings for visual plugins require an interface image even though `media` is +optional in the runtime manifest. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) +for accepted formats and listing requirements. +Paseo validates the references without opening local assets or fetching URLs. + +Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside +`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check. +Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject +these fields during installation. ### Requirements @@ -166,6 +180,29 @@ The scaffold's `tsconfig.json` omits the DOM library. Keep DOM globals out of cr components; do not add `/// <reference lib="dom" />` or `"DOM"` to `lib`. `layout.platform` carries the same value as React Native's `Platform.OS` for rendering decisions. +### Play audio + +Call `client.playAudio({ base64, mimeType }): Promise<void>` to play an audio file on the +current client (browser, Electron, iOS, or Android). Pass the base64 file returned by your +plugin RPC; no browser globals or platform checks are needed. + +```ts +const audio = await client.rpc(renderSpeech, { text: "Your agent needs you" }); +// renderSpeech returns { base64: string, mimeType: "audio/wav" }. +await client.playAudio(audio); +``` + +The promise resolves when playback finishes and rejects if the file is invalid, playback +fails, or the plugin unloads. Calls share Paseo's voice playback queue and play in order. +Unloading a plugin cancels its active and queued audio. Voice playback controls can also +interrupt that shared queue. Playback does not request microphone permission. + +Use PCM WAV or MP3 for portable files. Other codecs depend on the client's decoder. +MIME parameters are accepted. Pass a complete audio file; raw PCM samples are not supported. +Browsers require user interaction before allowing sound; handle rejection and offer a +play button. The function plays on the device running the plugin client, not on the daemon, +and does not promise delivery while the app is suspended or closed. + ### External links and workspace browsers Use `ExternalLink` to open documentation outside Paseo: @@ -319,15 +356,20 @@ A source implements two calls: interface UsageAccount { key: string; label?: string; + harness?: string; input: JsonValue; } +type UsageScope = + | { kind: "global" } + | { kind: "session"; provider: string; model?: string; env: Record<string, string> }; + interface UsageSourceRegistration { id: string; label: string; icon?: string; input: ZodType; - discover(): Promise<UsageAccount[]>; + discover(scope: UsageScope): Promise<UsageAccount[]>; fetch(input: unknown): Promise<UsageReport>; } @@ -348,19 +390,45 @@ type UsageProblem = | { kind: "no_quota"; detail: string }; ``` -Return every account whose login exists on the machine from `discover()`, including expired -logins. Return `[]` when none exists. Discovery takes no arguments and must work independently of -agent sessions and provider names. Inputs name credential stores; never put credentials in inputs -or reports. Paseo validates each input against your schema before calling `fetch()`. +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; +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 | +| 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. Use a stable account key: 1–128 characters from `[A-Za-z0-9._-]`. It identifies the account or organization whose quota is metered and survives token rotation. Never use a credential or raw 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 @@ -440,7 +508,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. @@ -654,6 +723,7 @@ plans, and mode changes; requesting permission does not end the turn. | Name | Event fields | Trigger | | ---------------------------- | ---------------------------------------- | -------------------------------------------------- | | `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume | +| `agent.closed` | `agent` | A live agent runtime closes | | `agent.turn_started` | `agent`, `turnId` | Live turn starts | | `agent.turn_ended` | `agent`, `turnId`, `outcome`, `timeline` | Live turn completes, fails, or is canceled | | `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending | @@ -662,8 +732,9 @@ plans, and mode changes; requesting permission does not end the turn. | `workspace.created` | `workspace` | Record created; directory available | | `workspace.archived` | `workspace` | Archive state is saved | -Agent events exclude internal utility agents. Archive events can precede runtime/worktree cleanup; -`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`): @@ -717,7 +788,6 @@ type PluginTurnOutcome = | `providerOptions` | Opaque provider-specific options | | `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | | `cwd` | Cannot change | -| `internal` | Daemon-owned; cannot change through this hook | **`agent.session_open` request example:** @@ -2069,7 +2139,7 @@ export default function contribute(server: PluginServerContext) { Inputs and outputs are validated on both sides. RPC names start with a lowercase letter and contain lowercase letters, numbers, dots, hyphens, or underscores. `useRpc()` returns a typed async function. Use TanStack Query for request state, caching, and mutations. -Backend handlers receive the same `PaseoApi` as `{ paseo }`. Their connection belongs to the subprocess and closes when the plugin stops. It does not subscribe to timelines or catalog events until plugin code subscribes. Follow the [SDK event contract](../../sdk/events.md) for cleanup and timeline replacements. Backend code can use Node APIs and dependencies installed in the plugin directory. +Backend handlers receive the same `PaseoApi` as `{ paseo }`. Their connection belongs to the subprocess and closes when the plugin stops. It does not subscribe to timelines or catalog events until plugin code subscribes. Follow the [SDK event contract](/docs/sdk/events) for cleanup and timeline replacements. Backend code can use Node APIs and dependencies installed in the plugin directory. ## Debug backend output @@ -2200,16 +2270,20 @@ failures stay inside the plugin error boundary. Paste one of these source identifiers into **Settings → Plugins**, or pass it to `paseo plugin install`. `paseo plugin add <source>` and `paseo plugin install <source>` are aliases. -Absolute host paths are recommended because relative paths resolve against the daemon's working -directory. The app does not expand `~`; your shell may expand it before the CLI runs. +The CLI resolves relative directory paths from your current working directory and expands `~` +to your local home directory, preserving any `:plugin/path` suffix. With `--host`, use an absolute +path that exists on the daemon host; plugin files are read there and are never uploaded by the CLI. +Paths entered in Settings resolve relative to the daemon's working directory, and `~` expands to +the daemon's home directory. | Source | Accepted form | Example | | -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | +| Plugin registry | `owner/slug` or `host/owner/slug` | `acme/review` | | Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` | -| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` | +| GitHub repository | `git:owner/repository` or `github:owner/repository` | `github:acme/paseo-review` | | Git repository | `git:<URL or SCP source>`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` | | npm package | `npm:<name>[@<version, tag, or range>]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` | -| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` | +| Plugin below a source root | Append `:relative/plugin/path` to a directory, Git, or npm source | `github:acme/monorepo:plugins/review` | Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use `user@host:path`. `file://` selects Git acquisition, not directory installation. @@ -2225,20 +2299,28 @@ 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. +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 its enabled state or files. @@ -2271,8 +2353,9 @@ keeps the complete dependency tree and `package-lock.json` when activating it. T package and lockfile provide its current version and artifact integrity. A version, tag, or range chooses content for this installation only. -For package contents, dependencies, preparation, and private registries, see -[Publish a plugin](/docs/plugins/publishing). +The installed lockfile records what this installation resolved. Authors submitting npm plugins +with runtime dependencies must also ship the [publication lock](/docs/plugins/publishing#2-lock-installed-dependencies). +For package contents, preparation and submission, see [Publish a plugin](/docs/plugins/publishing). A failed download, dependency installation, manifest check, preparation command, compilation, or activation discards the candidate. Other installed plugins keep running. Removing an npm plugin @@ -2284,9 +2367,10 @@ deletes its managed files; removing a directory plugin keeps your source directo paseo plugin init /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin paseo plugin install /absolute/path/to/plugin --id another-runtime-id -paseo plugin add owner/repository +paseo plugin add owner/slug +paseo plugin add git:owner/repository paseo plugin add https://git.example.com/owner/repository.git --ref main -paseo plugin add owner/monorepo:plugins/review +paseo plugin add git:owner/monorepo:plugins/review paseo plugin ls [id] paseo plugin update <id> paseo plugin update --all --check diff --git a/public-docs/providers.md b/public-docs/providers.md index 6de304a019..fc23bff784 100644 --- a/public-docs/providers.md +++ b/public-docs/providers.md @@ -8,7 +8,9 @@ category: Providers # Providers -Paseo doesn't ship its own coding agent. It launches and supervises **existing CLIs you've already installed and authenticated**, Claude Code, Codex, OpenCode, Antigravity, Muse Code, Cursor, Gemini, and the rest. Your subscriptions, your config, your skills, your MCP servers all stay intact. Paseo just gives you a UI, a CLI, a relay, and orchestration on top. +Paseo runs **existing coding agents you've installed and authenticated** in workspaces with an editor, terminals, diffs, and, on desktop, a browser. Run several agents in parallel, choose a provider for each task, and review their work in the same app. Your subscriptions, config, skills, and MCP servers stay intact. + +To try the workflow, [run parallel tasks in separate worktrees](/docs/parallel-development). ## Mental model diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md index fa3ed80c00..87d3e993cf 100644 --- a/public-docs/sdk/agents.md +++ b/public-docs/sdk/agents.md @@ -105,6 +105,31 @@ const child = await workspace.agents.create({ `parent` establishes parentage. Archiving a parent cascade-archives its children. Call `detach()` first when a child should continue independently. +## Run work in a background workspace + +Create a background workspace and its first agent in one call. Put `background` on the +workspace options and the agent's configuration under `agent`: + +```ts +const cwd = "/Users/me/dev/storefront"; +const workspace = await client.workspaces.create({ + source: { kind: "directory", path: cwd }, + background: true, + agent: { + cwd, + config: { provider: "codex/gpt-5.5" }, + prompt: "Review the checkout flow.", + }, +}); +``` + +The call returns a workspace handle and starts its first agent with the supplied prompt. + +Background agents keep their history and survive daemon restarts. Default discovery hides them; +use `client.agents.list({ filter: { includeBackground: true } })` to include them. +Exact-ID handles work normally. See [workspace creation](./workspaces.md#create-a-fresh-workspace) +for defaults and caller inheritance. + ## Request structured output ```ts diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 620f398691..76490974fe 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -57,7 +57,10 @@ Create a new client after `close()`. `list({ subscribe: {} })` also returns a `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Capable daemons assign the ID and keep observations independent. Older daemons use local IDs and their established shared delivery behavior. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). -Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`. +Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, `background`, and `labels`. + +`background` is workspace creation intent. It cannot accompany an existing `workspaceId`. +See [background workspaces](./workspaces.md) for caller inheritance and inclusive discovery. `config` accepts: @@ -123,14 +126,19 @@ See [events](./events.md#follow-provider-catalog-changes) for explicit event obs ## `client.workspaces` -| Method | Result | Behavior | -| ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------- | -| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | -| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | -| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | -| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | -| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | -| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | +| Method | Result | Behavior | +| ------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | +| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | +| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. `background: true` hides it from default discovery. | +| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | +| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | +| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | + +`create({ source, agent, background })` creates a workspace and its initial agent together. +`agent` accepts agent creation options except `worktree`, `git`, `onEvent`, `idempotencyKey`, +and `requestId`. Set `background` on the workspace options; `agent.background` is rejected. +See the [combined creation example](./agents.md#run-work-in-a-background-workspace). A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. diff --git a/public-docs/sdk/workspaces.md b/public-docs/sdk/workspaces.md index 6775e90b3d..2bf1dd7ee3 100644 --- a/public-docs/sdk/workspaces.md +++ b/public-docs/sdk/workspaces.md @@ -50,8 +50,25 @@ const workspace = await client.workspaces.create({ }); ``` +Pass `agent` to create the workspace and its first agent in one call. See the +[combined background workspace example](./agents.md#run-work-in-a-background-workspace). +Set visibility on the workspace options; `agent.background` is rejected. + You can pass `projectId` in either source when you already have one. Most integrations should omit it; the daemon finds or creates the project from the directory. +Pass `background: true` to hide a workspace from default discovery. Its agents and terminals +remain durable, with ordinary history, lifecycle hooks, archive, and exact-ID access. The app's +sidebar offers **Show background** without dropping replicated state. + +`background` is creation-only. Omission defaults to false; when `callerAgentId` identifies an agent +creating a new workspace, omission inherits its workspace's value. Explicit false or true overrides +inheritance. Agent creation with `workspaceId` rejects explicit background creation intent; configure +visibility when creating the workspace. SDK/RPC implicit agent creation also accepts `background` +as workspace creation intent, never as an agent property. + +Creation and inclusive discovery require `features.backgroundWorkspaces`; the client rejects an +unsupported host with an update-host message. + ## Start an agent in a workspace Create through the workspace handle: @@ -116,6 +133,11 @@ do { } while (cursor); ``` +Background workspaces are left out unless `filter.includeBackground` is true. Use +`client.workspaces.list({ filter: { includeBackground: true } })` or +`client.agents.list({ filter: { includeBackground: true } })`. Workspace descriptors carry +`background`; agents derive visibility from their workspace and have no separate setting. + ## Refresh and archive a handle ```ts diff --git a/public-docs/security.md b/public-docs/security.md index a935a75878..d1b6ee229a 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -121,6 +121,14 @@ The image runs the daemon and launched agents as the non-root `paseo` user, but See [Docker](/docs/docker) for Compose and reverse proxy examples. +## Linux desktop sandbox + +Paseo checks Chromium sandbox availability each time the desktop app launches on Linux. It keeps sandboxing enabled when user namespaces work or an installed sandbox helper is usable. The `.deb` and `.rpm` installers configure that helper. + +AppImage and extracted tar archives launch with Chromium's sandbox disabled when neither is available. Use the `.deb` or `.rpm` package if you require Chromium process isolation. Passing `--no-sandbox` explicitly disables it. + +Open **Settings → Diagnostics → App Diagnostics** to check the sandbox state and reason. The [desktop log](/docs/troubleshooting#reading-the-logs) records the same decision. + ## Agent authentication Paseo wraps agent CLIs (Claude Code, Codex, OpenCode, Muse Code) but does not manage their authentication. Each agent provider handles its own credentials: diff --git a/public-docs/supported-providers.md b/public-docs/supported-providers.md index f2b1370b68..1aa981ba29 100644 --- a/public-docs/supported-providers.md +++ b/public-docs/supported-providers.md @@ -85,6 +85,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in - [goose](https://block.github.io/goose/), Block's local open-source AI agent. - [Grok](https://docs.x.ai/build/overview), xAI's Grok Build agentic coding CLI. - [Hermes Agent](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp), Nous Research's self-improving agent. +- [Hoplite](#hoplite), cloud coding agent through its local ACP bridge. - [Junie](https://junie.jetbrains.com/docs/junie-cli-acp.html), JetBrains' coding agent. - [Kilo Code](https://kilo.ai/docs/code-with-ai/platforms/cli), open-source coding agent. - [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), Moonshot AI's coding assistant. @@ -100,3 +101,18 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in - [VT Code](https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md), open-source multi-provider coding agent. The in-app catalog is the canonical, version-pinned source. Anything not listed here can still be added manually, see [Custom providers](/docs/custom-providers). + +### Hoplite + +Install the [Hoplite CLI](https://hoplite.sh/docs/cli) on your daemon host and run +`hoplite login`. In **Settings → Hosts → your host → Providers**, find **Hoplite** +in the catalog and choose **Add**. Paseo launches `hoplite acp` through the generic ACP adapter. + +Hoplite runs the agent in a cloud workspace. Open a repository linked to a Hoplite project, +or set `agents.providers.hoplite.env.HOPLITE_PROJECT_ID` to use one project everywhere. +Without a linked repository or pinned project, discovery reports `No Hoplite project`. +Your local files and Paseo's loopback MCP tools are not available in that cloud workspace. + +See [Hoplite's ACP guide](https://hoplite.sh/docs/cli/acp) for authentication and project setup. +Use a [command override](/docs/custom-providers) if `hoplite` is outside your daemon's PATH; +keep `acp` in the arguments. diff --git a/public-docs/troubleshooting.md b/public-docs/troubleshooting.md index 77b2497e03..420c3363aa 100644 --- a/public-docs/troubleshooting.md +++ b/public-docs/troubleshooting.md @@ -1,6 +1,6 @@ --- title: Troubleshooting -description: Why Paseo can't find a provider you've installed, and how to fix the PATH and environment mismatches behind most setup issues. +description: Fix Linux AppImage launch errors, missing providers, and environment mismatches. nav: Common problems order: 90 category: Troubleshooting @@ -8,7 +8,22 @@ category: Troubleshooting # Troubleshooting -Almost every "it works in my terminal but not in Paseo" problem is the same thing: Paseo and your terminal aren't searching the same `PATH`. This page covers how to spot that and fix it. +## AppImage won't open on Linux + +Make the downloaded AppImage executable, then launch it from a terminal to see any error: + +```bash +chmod +x Paseo-x86_64.AppImage +./Paseo-x86_64.AppImage +``` + +If it reports `error loading libfuse.so.2`, run without FUSE: + +```bash +./Paseo-x86_64.AppImage --appimage-extract-and-run +``` + +The Paseo window should open. You can also install the `.deb` on Debian/Ubuntu or the `.rpm` on Fedora from the [download page](https://paseo.sh/download). ## Paseo can't find my provider diff --git a/public-docs/web-ui.md b/public-docs/web-ui.md index 9e80e8355e..6fe574e3cd 100644 --- a/public-docs/web-ui.md +++ b/public-docs/web-ui.md @@ -197,7 +197,7 @@ If you don't want to manage a reverse proxy or open ports, a tunnel gives you an - **Tailscale Serve** keeps it inside your tailnet, no public exposure, TLS handled for you: ```bash - tailscale serve https / http://127.0.0.1:6767 + tailscale serve --bg http://127.0.0.1:6767 ``` Reach it at `https://<your-machine>.<tailnet>.ts.net/`. Only devices on your tailnet can connect. diff --git a/public-docs/why.md b/public-docs/why.md index 44ac4a90c9..8b3d0d8531 100644 --- a/public-docs/why.md +++ b/public-docs/why.md @@ -1,6 +1,6 @@ --- title: Why Paseo? -description: What Paseo is, what it isn't, and how it fits into your workflow. +description: A full agentic development environment for running coding agents, editing files, reviewing changes, and testing your work. nav: Why Paseo? order: 2 category: Getting started @@ -8,38 +8,36 @@ category: Getting started # Why Paseo? -Paseo is a self-hostable platform for running and orchestrating coding agents. It runs the agent CLIs you already use, on the hardware you already have, and gives you a UI, CLI, and API to drive them from anywhere. +Paseo is a full agentic development environment. Run coding agents, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. For agent-driven development, Paseo replaces your IDE: take a task from the first prompt through review and testing in one app. -## Architecture +## Develop in one workspace -- Daemon-client architecture. The daemon manages agents; clients (mobile, desktop, web, CLI) connect locally or over a relay. Remote access isn't an add-on. -- macOS, Windows, and Linux are all primary targets. None of them are a port or an afterthought. -- Mobile, desktop, and web are separate native clients. The mobile app is built in React Native, not a webview. +Open the desktop app, choose a project and an agent, and start working. Keep the conversation, editor, terminal, diffs, and browser together in split panes. Run your app, inspect the result, and ask the agent for changes without leaving the workspace. -## Providers +The desktop app is available on macOS, Windows, and Linux. It starts and manages its local daemon automatically. -- Bring your own. Use your Claude subscription, your OpenAI account, your own API keys, a self-hosted endpoint. Paseo doesn't proxy model calls. -- Local voice stack. Speech-to-text and text-to-speech run on-device by default. OpenAI providers are configurable if you want cloud quality. -- Open source. No telemetry on your code. +## Run tasks in parallel -## Where agents run +Give independent tasks their own worktrees and branches. Run several agents at once, inspect each diff, test each implementation, and choose what to merge. Use the same provider for every task or choose a different provider and model for each. -- Your laptop, a homelab, a company server. Same daemon, same client surface. -- Any directory, git or not. Launch agents, merge locally, review the diff in the app. -- GitHub PRs, checks, and reviews surface in the app when you want them. Not required. +Configure [workspace scripts](/docs/worktrees#scripts-and-services) to start development servers with separate ports for each worktree. [Try the parallel development workflow](/docs/parallel-development). -## Parallel work +## Use your agents and tools -- Splits and panes. Agents, terminals, and browsers side by side in one workspace. -- Per-worktree services. Each worktree gets allocated ports for dev servers and databases, reachable through proxy URLs like `web.fix-auth.my-app.localhost` so they don't collide. -- Multiple agents on the same repo via worktrees. +Paseo runs the coding agents you already use, with your subscriptions, credentials, configuration, skills, and MCP servers. Your agents work with the files and tools on your machine. See [supported providers](/docs/supported-providers). -## Automation +Dictate tasks or use [voice mode](/docs/voice). Speech-to-text and text-to-speech run locally by default, with cloud providers available in settings. -- The CLI exposes the same surface as the app. Anything in the UI is scriptable. -- [Paseo tools](/docs/orchestration). Agents can drive Paseo themselves: create isolated workspaces, spawn subagents, open terminals, and send prompts. -- [Hub](/docs/hub). A service you host that starts agents on your daemon when someone mentions you on GitHub, Slack, or Discord. +## Continue from another device -## What it isn't +The daemon owns the running agents and workspaces. The desktop app connects to it locally; mobile, web, and CLI clients connect to the same environment. That separation lets you continue working from another device or run your environment on a server. -Not a hosted agent, not an IDE, not a model provider. Paseo runs the CLIs you already use and stays out of the way. +Use Paseo entirely on your laptop, or connect to another machine when you need it. The native iOS and Android app gives you agents, files, terminals, and diffs on your phone. The built-in browser runs in the desktop app. [Connect your devices](/docs/connectivity) directly or through the optional end-to-end encrypted relay. + +## Automate and extend your workflow + +Give agents [Paseo tools](/docs/orchestration) to create worktrees, launch other agents, send prompts, and collect results. Use the [CLI](/docs/cli) or [TypeScript SDK](/docs/sdk) to automate agent and workspace operations. + +[Plugins](/docs/plugins) add providers, workspace panels, commands, and workflows. [Hub](/docs/hub) connects external events to agents running on your machines. + +Paseo is open source under Apache-2.0, with no telemetry, tracking, or required Paseo account. diff --git a/public-docs/workspaces.md b/public-docs/workspaces.md index caf59884c4..2e74935d54 100644 --- a/public-docs/workspaces.md +++ b/public-docs/workspaces.md @@ -48,6 +48,8 @@ The workspace is the product concept; a git worktree is one way to isolate its f ## Creating a workspace +For a desktop walkthrough from starting parallel agents to reviewing and testing their changes, see [Run parallel tasks](/docs/parallel-development). + You can create a workspace in the app or from the CLI: ```bash diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 4235127a63..3eeafc6371 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -12,10 +12,6 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-markdown-display", patchPrefix: "react-native-markdown-display+", }, - { - nodeModulesPath: "node_modules/react-native-uitextview", - patchPrefix: "react-native-uitextview+", - }, // Remove after react-native-unistyles ships // https://github.com/jpudysz/react-native-unistyles/pull/1203. { @@ -30,6 +26,14 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-gesture-handler", patchPrefix: "react-native-gesture-handler+", }, + // Exports BottomSheetModalInternalProvider so a subtree can host its own sheets on the shared + // modal stack (see packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx), and + // keeps a dismissing modal's status through its final onChange(-1), so a modal with + // enableDismissOnClose=false that unmounts while presented still leaves the portal. + { + nodeModulesPath: "node_modules/@gorhom/bottom-sheet", + patchPrefix: "@gorhom+bottom-sheet+", + }, { nodeModulesPath: "node_modules/react-native-svg", patchPrefix: "react-native-svg+", diff --git a/scripts/trace-daemon-dist.test.mjs b/scripts/trace-daemon-dist.test.mjs new file mode 100644 index 0000000000..a2c2149daa --- /dev/null +++ b/scripts/trace-daemon-dist.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { cp, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const bridgeModule = "packages/server/dist/server/server/agent/providers/opencode/bridge.js"; + +// Mirrors nix/package.nix's installPhase: copy every traced path into $out. +async function installTracedDaemon(outRoot) { + const { stdout } = await promisify(execFile)( + process.execPath, + [path.join(repoRoot, "scripts/trace-daemon.mjs")], + { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 }, + ); + for (const file of stdout.split("\n").filter(Boolean)) { + await cp(path.join(repoRoot, file), path.join(outRoot, file), { + recursive: true, + verbatimSymlinks: true, + }); + } +} + +test("traced daemon closure ships the OpenCode bridge plugin for every OpenCode version", async () => { + const outRoot = await mkdtemp(path.join(os.tmpdir(), "trace-daemon-dist-")); + try { + await installTracedDaemon(outRoot); + const installedBridgeUrl = pathToFileURL(path.join(outRoot, bridgeModule)).href; + const { loadOpenCodeBridgePluginArtifact } = await import(installedBridgeUrl); + + for (const version of [1, 2]) { + const artifact = await loadOpenCodeBridgePluginArtifact( + installedBridgeUrl, + undefined, + version, + ); + assert.ok(artifact.byteLength > 0, `OpenCode v${version} bridge plugin is empty`); + } + } finally { + await rm(outRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index cb7408571f..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 @@ -58,37 +100,45 @@ const additionalInputs = [ "packages/server/dist/server/terminal/shell-integration/**", // Silero VAD ONNX model (sherpa speech provider) "packages/server/dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx", - // OpenCode loads this plugin from a content-addressed runtime copy. - "packages/server/dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs", + // OpenCode loads these plugins from a content-addressed runtime copy, one + // bundle per supported OpenCode version (opencode/ and opencode/v2/). + "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 @@ -128,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");