mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72d9cef1a8 | ||
|
|
03d23c577a | ||
|
|
0e2870c413 | ||
|
|
9f6c394a8e | ||
|
|
950e1f8f41 | ||
|
|
7a30305503 | ||
|
|
3ad878b47f | ||
|
|
15d774d4a1 | ||
|
|
5fd4072893 | ||
|
|
84054f8d72 | ||
|
|
f02cc16bb4 | ||
|
|
2f0cb2f54b | ||
|
|
4ed13fadb6 | ||
|
|
db303be3c2 | ||
|
|
3e2d77723e | ||
|
|
18412604dd | ||
|
|
bc6ddbfa97 | ||
|
|
27a95a5dec | ||
|
|
9760292650 | ||
|
|
d826d8109f | ||
|
|
62f79e88bb | ||
|
|
9da676d709 | ||
|
|
dfb8a1a2bc | ||
|
|
8ced059b4d | ||
|
|
da9fae172d | ||
|
|
f24bb523e8 | ||
|
|
cf2b3f4bb3 | ||
|
|
a50e14b69e | ||
|
|
7b43f21940 | ||
|
|
2f9a786061 | ||
|
|
6b00310f7b | ||
|
|
981bb4c409 | ||
|
|
3ee4c49e3c | ||
|
|
fbd1bc47e0 |
@@ -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 "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo-server stdout/stderr"
|
||||
echo "::group::paseo daemon run stdout/stderr"
|
||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+79
-60
@@ -1,63 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.3 - 2026-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
### 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))
|
||||
|
||||
## 0.11.0-beta.2 - 2026-10-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
|
||||
### 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))
|
||||
|
||||
## 0.11.0-beta.1 - 2026-10-01
|
||||
## 0.11.0-beta.5 - 2026-10-06
|
||||
|
||||
### 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 a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
|
||||
- Added 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.<id>.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 `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
||||
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
|
||||
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- 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))
|
||||
|
||||
### 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))
|
||||
- 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 +43,36 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
|
||||
- Fixed 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 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 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 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 +83,47 @@
|
||||
- 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))
|
||||
- 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 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 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 macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
|
||||
- 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 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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -248,7 +248,7 @@ dependency; shared types must not refer to React components, hooks, Node APIs, o
|
||||
| Entry | Owns | May depend on |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||
@@ -488,9 +488,9 @@ SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
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
|
||||
|
||||
|
||||
+3
-1
@@ -10,7 +10,9 @@ Keep a bundled provider in `plugins/<id>/` 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.<provider-id>.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.
|
||||
|
||||
+15
-17
@@ -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 <latest-release-tag>..<release-source-sha>` 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
|
||||
|
||||
@@ -157,6 +157,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
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
|
||||
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
|
||||
|
||||
@@ -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/
|
||||
|
||||
Generated
+30
-30
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36413,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37496,12 +37496,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"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.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/server": "0.11.0-beta.5",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37748,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37762,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38006,7 +38006,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38902,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38931,14 +38931,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"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.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38948,8 +38948,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38998,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39216,16 +39216,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"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.0-beta.5",
|
||||
"@getpaseo/highlight": "0.11.0-beta.5",
|
||||
"@getpaseo/plugin": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40146,11 +40146,12 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@getpaseo/protocol": "*",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-start": "^1.168.26",
|
||||
"framer-motion": "^12.35.2",
|
||||
@@ -40165,8 +40166,7 @@
|
||||
"remark-directive": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"wrangler": "^4.75.0",
|
||||
"@getpaseo/protocol": "*"
|
||||
"wrangler": "^4.75.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
@@ -41753,7 +41753,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -120,6 +120,54 @@ async function selectAssistantElement(page: Page, selector: string): Promise<voi
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an element from the end of the text before it. A drag that starts in the gap
|
||||
* above a block anchors there, so nothing outside the element is highlighted.
|
||||
*/
|
||||
async function selectAssistantElementFromEndOf(
|
||||
page: Page,
|
||||
startText: string,
|
||||
selector: string,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await assistantMessageBlocks(page)
|
||||
.locator("img")
|
||||
.evaluate((element) => {
|
||||
const range = document.createRange();
|
||||
range.selectNode(element);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
});
|
||||
}
|
||||
|
||||
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
@@ -565,6 +613,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 +662,50 @@ 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.",
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
"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\n\nAfter the image.`,
|
||||
);
|
||||
|
||||
await selectAssistantImage(page);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(``);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
else await expect(sheetTrigger).toContainText(label);
|
||||
}
|
||||
|
||||
async function expectSpeedIconColor(page: Page, color: string): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -273,7 +273,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const card = contextWindowDetails(page);
|
||||
await refreshUsageCard(card, "Claude");
|
||||
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("after a resume under another login, only that login shows", async () => {
|
||||
@@ -321,7 +321,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
|
||||
// One request per open that had usage; this one sends none.
|
||||
expect(usage.agentRequests()).toHaveLength(5);
|
||||
expect(usage.agentRequests()).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,7 +351,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const sheet = contextWindowDetails(page);
|
||||
await refreshUsageCard(sheet, "Claude");
|
||||
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
|
||||
await shot("problem");
|
||||
});
|
||||
|
||||
@@ -365,7 +365,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
|
||||
expect(usage.agentRequests()).toHaveLength(2);
|
||||
expect(usage.agentRequests()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectWorkspaceHoverCardClosed,
|
||||
expectWorkspaceHoverCardOpen,
|
||||
expectWorkspaceHoverCardStaysOpen,
|
||||
focusWorkspaceRowWithKeyboard,
|
||||
movePointerAwayFromWorkspaceHoverCard,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
@@ -404,6 +409,47 @@ 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 stays open for a keyboard-focused 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);
|
||||
} 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-",
|
||||
|
||||
@@ -322,14 +322,14 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
|
||||
await gotoAppShell(page);
|
||||
await openUsage(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, / });
|
||||
@@ -474,3 +474,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 openUsage(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);
|
||||
}
|
||||
|
||||
@@ -134,8 +134,8 @@ export interface AgentUsageScript {
|
||||
stopReportingUsage(): void;
|
||||
/** The agent of each agent usage request, in order. */
|
||||
agentRequests(): string[];
|
||||
/** Each report Refresh, by report ID. */
|
||||
refreshedReports(): string[][];
|
||||
/** Each scoped agent Refresh, by agent ID. */
|
||||
refreshedAgents(): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
supported = false;
|
||||
},
|
||||
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
|
||||
refreshedReports: () =>
|
||||
usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])),
|
||||
refreshedAgents: () =>
|
||||
usage
|
||||
.listRequests()
|
||||
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
|
||||
// Longer than the hover card's close grace period.
|
||||
await page.waitForTimeout(300);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
|
||||
|
||||
@@ -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 () => {};
|
||||
}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<AgentMode, "id" | "colorTier">): boolean {
|
||||
return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan");
|
||||
|
||||
@@ -1241,7 +1241,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
!liveAuxiliary;
|
||||
|
||||
return (
|
||||
<div style={viewportStyle}>
|
||||
<div style={viewportStyle} data-window-content>
|
||||
<div
|
||||
ref={handleScrollContainerRef}
|
||||
data-testid="agent-chat-scroll"
|
||||
|
||||
@@ -320,6 +320,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 = [
|
||||
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
|
||||
"<div><div><span>Install it:</span></div>",
|
||||
'<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="sh">',
|
||||
'<div data-paseo-markdown-tag="code"><span>brew install foo</span></div>',
|
||||
'<button data-paseo-markdown-ignore="true">Copy</button>',
|
||||
"</div></div>",
|
||||
"</div>",
|
||||
'<div data-paseo-markdown-tag="li">',
|
||||
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">2.</div>',
|
||||
"<div><div><span>Done.</span></div></div>",
|
||||
"</div>",
|
||||
"</div>",
|
||||
].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("<div>2. Done.</div>");
|
||||
});
|
||||
|
||||
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 +411,20 @@ describe("assistant selection copy ranges", () => {
|
||||
expect(content?.html).not.toContain("<pre>");
|
||||
});
|
||||
|
||||
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 = '<div><div><img src="blob:https://example.test/preview" alt=""></div></div>';
|
||||
message.prepend(frame);
|
||||
|
||||
expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("copies code without a fence when every character is selected inside the block", () => {
|
||||
const message = mountFixture();
|
||||
const blockCode = fixtureElement(
|
||||
@@ -405,10 +445,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 [
|
||||
'<div data-testid="assistant-message">',
|
||||
'<div data-paseo-markdown-tag="p"><span>Before the block.</span></div>',
|
||||
highlightedCodeBlock(language),
|
||||
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function highlightedCodeBlock(language: string | null): string {
|
||||
const languageAttribute =
|
||||
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
|
||||
return [
|
||||
'<div data-testid="assistant-message">',
|
||||
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
|
||||
'<span data-paseo-markdown-tag="code">',
|
||||
"<span>const</span><span> answer</span><span> = 1;</span>",
|
||||
@@ -419,8 +468,6 @@ function highlightedFixture(language: string | null): string {
|
||||
"</span>",
|
||||
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
|
||||
"</div>",
|
||||
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
@@ -598,6 +645,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 =
|
||||
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><img src="blob:https://example.test/preview" alt=""></div>';
|
||||
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(
|
||||
"\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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -53,9 +55,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`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,8 +67,8 @@ export function createAssistantSelectionClipboardContent(
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const parts = selectedMessageParts(range);
|
||||
if (!parts) {
|
||||
const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range));
|
||||
if (!parts?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -103,10 +103,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 +132,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 +295,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 +399,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 +433,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 +529,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 +574,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)) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<EditingTextInputHandle>(null);
|
||||
const passwordInputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const header = useMemo<SheetHeader>(() => ({ 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<ViewStyle> => [
|
||||
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}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("pairing.remoteSsh.fields.password")} testID="remote-ssh-password">
|
||||
<View style={styles.passwordRow}>
|
||||
<View style={styles.passwordInputWrap}>
|
||||
<FormTextInput
|
||||
ref={passwordInputRef}
|
||||
size={isCompact ? "md" : "sm"}
|
||||
testID="remote-ssh-password-input"
|
||||
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
|
||||
initialValue=""
|
||||
onChangeText={handlePasswordChange}
|
||||
placeholder={t("pairing.remoteSsh.fields.optional")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={iconButtonStyle}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isPasswordVisible
|
||||
? t("pairing.remoteSsh.passwordVisibility.hide")
|
||||
: t("pairing.remoteSsh.passwordVisibility.show")
|
||||
}
|
||||
testID="remote-ssh-password-visibility-toggle"
|
||||
>
|
||||
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Field>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
|
||||
@@ -108,6 +108,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import {
|
||||
markdownCopyDataSet,
|
||||
markdownCopyImageDataSet,
|
||||
markdownCopyOrderedListDataSet,
|
||||
markdownCopyTableCellDataSet,
|
||||
type MarkdownCopyInlineTag,
|
||||
@@ -884,10 +885,11 @@ function AssistantMarkdownImage({
|
||||
],
|
||||
[containerStyle, imageSizeStyle],
|
||||
);
|
||||
const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]);
|
||||
|
||||
if (image.status === "failed") {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -895,14 +897,14 @@ function AssistantMarkdownImage({
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={frameStyle} dataSet={copyDataSet}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -98,14 +98,21 @@ function WebHoverCard({
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 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 (focusInside()) return;
|
||||
if (keyboardFocusInside()) return;
|
||||
if (graceTimerRef.current) return;
|
||||
graceTimerRef.current = setTimeout(() => {
|
||||
graceTimerRef.current = null;
|
||||
if (keyboardFocusInside()) return;
|
||||
setOpen(false);
|
||||
}, CLOSE_GRACE_MS);
|
||||
}, [focusInside]);
|
||||
}, [keyboardFocusInside]);
|
||||
|
||||
const openNow = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
|
||||
@@ -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({
|
||||
<AgentControlTrigger
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature.id,
|
||||
feature.value,
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
@@ -1370,6 +1369,11 @@ function DesktopFeatureItem({
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="toolbar"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
@@ -1453,8 +1457,7 @@ function SheetFeatureItem({
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature.id,
|
||||
feature.value,
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
@@ -1490,6 +1493,11 @@ function SheetFeatureItem({
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="sheet"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<AgentFeature, "label" | "tooltip
|
||||
return feature.tooltip ?? feature.label;
|
||||
}
|
||||
|
||||
export function isFeatureActive(feature: AgentFeature): boolean {
|
||||
if (feature.type === "toggle") return feature.value;
|
||||
const selectedOption = feature.options.find((option) => 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";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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 Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -15,7 +15,11 @@ import Animated, {
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
|
||||
@@ -34,11 +38,12 @@ function ComposerViewport({
|
||||
}: ComposerViewportProps) {
|
||||
const measuredHeight = useSharedValue(0);
|
||||
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
|
||||
const { layoutShift } = useKeyboardShift();
|
||||
const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift();
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
height: measuredHeight.value,
|
||||
bottomInset,
|
||||
safeAreaBottom: safeAreaBottom.value,
|
||||
keyboardShift: layoutShift.value,
|
||||
centered,
|
||||
}),
|
||||
@@ -79,6 +84,24 @@ 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;
|
||||
@@ -91,6 +114,7 @@ export function ComposerDock({
|
||||
}: 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;
|
||||
if (centered) {
|
||||
@@ -99,8 +123,13 @@ export function ComposerDock({
|
||||
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||
bottomInset={bottomInset}
|
||||
centered
|
||||
onLayout={centeredClearance.measureViewport}
|
||||
>
|
||||
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
|
||||
<KeyboardTranslateView
|
||||
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
|
||||
bottomClearance={centeredClearance.value}
|
||||
onLayout={centeredClearance.measureForm}
|
||||
>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
} from "./capacity";
|
||||
|
||||
describe("composer viewport", () => {
|
||||
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 +19,7 @@ describe("composer viewport", () => {
|
||||
const open = updateComposerCapacity(undefined, {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -22,6 +27,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 650,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}).capacity,
|
||||
@@ -30,6 +36,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 0,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}),
|
||||
@@ -39,6 +46,7 @@ describe("composer viewport", () => {
|
||||
const height = resolveComposerCapacity({
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 308,
|
||||
centered: false,
|
||||
});
|
||||
@@ -46,23 +54,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,11 +14,25 @@ export interface ComposerCapacity {
|
||||
|
||||
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(
|
||||
|
||||
@@ -1703,6 +1703,12 @@ export const ar: TranslationResources = {
|
||||
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
|
||||
fields: {
|
||||
target: "مضيف SSH",
|
||||
password: "كلمة مرور الدايمون",
|
||||
optional: "خياري",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "إظهار كلمة المرور",
|
||||
hide: "إخفاء كلمة المرور",
|
||||
},
|
||||
actions: {
|
||||
cancel: "إلغاء",
|
||||
|
||||
@@ -1727,6 +1727,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",
|
||||
|
||||
@@ -1749,6 +1749,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",
|
||||
|
||||
@@ -1753,6 +1753,12 @@ export const fr: TranslationResources = {
|
||||
helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.",
|
||||
fields: {
|
||||
target: "Hôte SSH",
|
||||
password: "Mot de passe du démon",
|
||||
optional: "Facultatif",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Afficher le mot de passe",
|
||||
hide: "Masquer le mot de passe",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Annuler",
|
||||
|
||||
@@ -1719,6 +1719,12 @@ export const ja: TranslationResources = {
|
||||
helper: "リモートホストで動作する Paseo デーモンに接続します。",
|
||||
fields: {
|
||||
target: "SSH ホスト",
|
||||
password: "デーモンのパスワード",
|
||||
optional: "任意",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "パスワードを表示",
|
||||
hide: "パスワードを非表示",
|
||||
},
|
||||
actions: {
|
||||
cancel: "キャンセル",
|
||||
|
||||
@@ -1711,6 +1711,12 @@ export const ko: TranslationResources = {
|
||||
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
|
||||
fields: {
|
||||
target: "SSH 호스트",
|
||||
password: "데몬 비밀번호",
|
||||
optional: "선택 사항",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "비밀번호 표시",
|
||||
hide: "비밀번호 숨기기",
|
||||
},
|
||||
actions: {
|
||||
cancel: "취소",
|
||||
|
||||
@@ -1734,6 +1734,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",
|
||||
|
||||
@@ -1732,6 +1732,12 @@ export const ru: TranslationResources = {
|
||||
helper: "Подключитесь к демону Paseo на удалённом хосте.",
|
||||
fields: {
|
||||
target: "Хост SSH",
|
||||
password: "Пароль демона",
|
||||
optional: "Необязательно",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Показать пароль",
|
||||
hide: "Скрыть пароль",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Отмена",
|
||||
|
||||
@@ -1683,6 +1683,12 @@ export const zhCN: TranslationResources = {
|
||||
helper: "连接到远程主机上运行的 Paseo 守护进程。",
|
||||
fields: {
|
||||
target: "SSH 主机",
|
||||
password: "守护进程密码",
|
||||
optional: "可选",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "显示密码",
|
||||
hide: "隐藏密码",
|
||||
},
|
||||
actions: {
|
||||
cancel: "取消",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }] }),
|
||||
|
||||
@@ -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 <View {...props}>{children}</View>;
|
||||
|
||||
@@ -3919,6 +3919,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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -2844,6 +2848,7 @@ export interface HostMutations {
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
daemonPort?: number;
|
||||
password?: string;
|
||||
label?: string;
|
||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
|
||||
@@ -27,7 +27,12 @@ export function AgentUsage({
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
{view.kind === "ready" ? (
|
||||
<AgentUsageCards serverId={serverId} reports={view.reports} refreshable={refreshable} />
|
||||
<AgentUsageCards
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
reports={view.reports}
|
||||
refreshable={refreshable}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.message}>
|
||||
{view.kind === "loading" ? usageCopy.loading : view.message}
|
||||
@@ -39,10 +44,12 @@ export function AgentUsage({
|
||||
|
||||
function AgentUsageCards({
|
||||
serverId,
|
||||
agentId,
|
||||
reports,
|
||||
refreshable,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
reports: readonly UsageReportEntry[];
|
||||
refreshable: boolean;
|
||||
}) {
|
||||
@@ -51,6 +58,7 @@ function AgentUsageCards({
|
||||
<UsageCard
|
||||
key={entry.id}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
entry={entry}
|
||||
display={display}
|
||||
compact
|
||||
|
||||
@@ -40,11 +40,24 @@ 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,
|
||||
@@ -52,6 +65,7 @@ export function UsageCard({
|
||||
compact = false,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
entry: UsageReportEntry;
|
||||
display: UsageDisplay;
|
||||
/** Whether each window row pins the window to the sidebar. */
|
||||
@@ -61,13 +75,14 @@ export function UsageCard({
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
|
||||
const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId);
|
||||
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
|
||||
const showsFreshnessInline = isNative || isCompact || !refreshable;
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
const footer = entry.account.label ?? null;
|
||||
const { windows, balances, details, message } = reportContent(usage);
|
||||
const { windows, balances, details } = reportContent(usage);
|
||||
const messages = reportMessages(entry);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
@@ -110,9 +125,9 @@ export function UsageCard({
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{message ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
{message}
|
||||
{messages.length > 0 ? (
|
||||
<Text style={styles.error} testID="usage-login-error">
|
||||
{messages.join("\n")}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -98,10 +98,14 @@ async function getReport(
|
||||
serverId: string,
|
||||
reportId: string,
|
||||
forceRefresh = false,
|
||||
agentId?: string,
|
||||
): Promise<UsageReportEntry | null> {
|
||||
return (
|
||||
(await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh }))
|
||||
.reports[0] ?? null
|
||||
(
|
||||
await requireClient(serverId).listUsageReports(
|
||||
agentId === undefined ? { reportIds: [reportId], forceRefresh } : { agentId, forceRefresh },
|
||||
)
|
||||
).reports.find((report) => report.id === reportId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,20 +227,22 @@ export function useUsageHosts(): UsageHost[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the source to fetch one report, and only that report. The result replaces the report
|
||||
* in every list of its host that holds it, the host's and each agent's, so every surface showing
|
||||
* it moves together; until then the previous report stays on screen.
|
||||
* Refreshes a host report, or the agent's session reports. The result replaces the card
|
||||
* in the requesting view. Host and agent views can use different logins of the same account.
|
||||
*/
|
||||
export function useReportRefresh(
|
||||
serverId: string,
|
||||
reportId: string,
|
||||
agentId?: string,
|
||||
): { refresh: () => void; refreshState: UsageRefresh } {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => getReport(serverId, reportId, true),
|
||||
mutationFn: () => getReport(serverId, reportId, true, agentId),
|
||||
onSuccess: (report) => {
|
||||
queryClient.setQueriesData<UsageReportEntry[]>(
|
||||
{ queryKey: hostUsageQueryKey(serverId) },
|
||||
queryClient.setQueryData<UsageReportEntry[]>(
|
||||
agentId === undefined
|
||||
? usageReportsQueryKey(serverId)
|
||||
: agentUsageQueryKey(serverId, agentId),
|
||||
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"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.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/server": "0.11.0-beta.5",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command {
|
||||
.argument("<id>", "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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
@@ -224,16 +225,7 @@ async function runFollowMode(
|
||||
await unsubscribe.ready;
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
// Wait for interrupt
|
||||
await new Promise<void>((resolve) => {
|
||||
const cleanup = () => {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
});
|
||||
|
||||
await waitForStop();
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 ===");
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"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.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2641,13 +2641,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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"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.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@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.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
@@ -400,9 +401,9 @@ class AcpRuntime {
|
||||
args: options.options.command.slice(1),
|
||||
env: process.env,
|
||||
};
|
||||
child = spawn(launch.command, launch.args, {
|
||||
child = spawnProcess(launch.command, launch.args, {
|
||||
env: { ...launch.env, ...options.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
stdio: "pipe",
|
||||
});
|
||||
spawnFailure = new Promise<never>((_resolve, reject) => child!.once("error", reject));
|
||||
child.stderr.on("data", () => undefined);
|
||||
@@ -734,9 +735,9 @@ class AcpRuntime {
|
||||
if (!child || this.processFailed) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const closed = new Promise<void>((resolve) => child.once("close", () => resolve()));
|
||||
child.kill("SIGTERM");
|
||||
await terminateProcess(child, "SIGTERM");
|
||||
if (await settlesWithin(closed, 1_000)) return;
|
||||
child.kill("SIGKILL");
|
||||
await terminateProcess(child);
|
||||
if (!(await settlesWithin(closed, 1_000))) {
|
||||
throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`);
|
||||
}
|
||||
|
||||
@@ -22,3 +22,5 @@ export type {
|
||||
PluginBeforeRequests,
|
||||
PluginLifecycleRegistration,
|
||||
} from "./lifecycle.js";
|
||||
|
||||
export { spawnProcess, execCommand, terminateProcess } from "./process.js";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export interface UsageAccount {
|
||||
/** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */
|
||||
key: string;
|
||||
label?: string;
|
||||
/** Harness owning this login, e.g. Codex, OpenCode, Pi or OMP. */
|
||||
harness?: string;
|
||||
/** Store locator, opaque to the daemon. */
|
||||
input: JsonValue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -6289,6 +6289,17 @@ export const UsageReportEntrySchema = z.object({
|
||||
sourceLabel: z.string(),
|
||||
icon: z.string().optional(),
|
||||
report: UsageReportSchema,
|
||||
loginErrors: z
|
||||
.array(
|
||||
z.object({
|
||||
harness: z.string(),
|
||||
report: z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }),
|
||||
z.object({ status: z.literal("error"), error: z.string() }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
export const UsageListReportsUpdateMessageSchema = z.object({
|
||||
type: z.literal("usage.list_reports.update"),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.0-beta.5",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -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.0-beta.5",
|
||||
"@getpaseo/highlight": "0.11.0-beta.5",
|
||||
"@getpaseo/plugin": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -27,6 +27,8 @@ 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;
|
||||
}): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
@@ -86,6 +88,11 @@ async function runSupervisorFixture(options: {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (options.closeOutput) {
|
||||
child.stdout.destroy();
|
||||
child.stderr.destroy();
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
@@ -116,10 +123,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 +205,30 @@ describe("supervisor durable logging", () => {
|
||||
expect(result.stderr).toContain('"worker-json-stderr"');
|
||||
});
|
||||
|
||||
test("keeps supervising the worker when nobody reads its stdout or stderr", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
closeOutput: true,
|
||||
workerSource: `
|
||||
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: `
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,8 @@ import { createRequire } from "node:module";
|
||||
import { existsSync } from "node:fs";
|
||||
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 +79,6 @@ export async function probeExecutable(
|
||||
timeout: timeoutMs,
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: 64 * 1024,
|
||||
shell: isWindowsCommandScript(executablePath),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -890,6 +890,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async
|
||||
}
|
||||
});
|
||||
|
||||
test("steers a send that arrives while the previous turn is still starting", async () => {
|
||||
const startHeld = deferred<void>();
|
||||
class SlowStartSteeringSession extends SteeringTestSession {
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
await startHeld.promise;
|
||||
return await super.startTurn(prompt);
|
||||
}
|
||||
}
|
||||
const session = new SlowStartSteeringSession({
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
const steerOptions = (clientMessageId: string) => ({
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "steer" as const,
|
||||
runOptions: { clientMessageId },
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
||||
const second = startAgentRun(
|
||||
manager,
|
||||
agent.id,
|
||||
"SECOND",
|
||||
logger,
|
||||
steerOptions("second-client"),
|
||||
);
|
||||
|
||||
startHeld.resolve();
|
||||
await expect(second).resolves.toEqual({ disposition: "steered" });
|
||||
|
||||
expect(session.startPrompts).toEqual(["FIRST"]);
|
||||
expect(session.interruptCount).toBe(0);
|
||||
expect(
|
||||
manager
|
||||
.fetchTimeline(agent.id, { limit: 0 })
|
||||
.rows.filter((row) => row.item.type === "user_message")
|
||||
.map((row) => ({
|
||||
text: row.item.type === "user_message" ? row.item.text : null,
|
||||
turnId: row.turnId,
|
||||
})),
|
||||
).toEqual([
|
||||
{ text: "FIRST", turnId: "active-turn-1" },
|
||||
{ text: "SECOND", turnId: "active-turn-1" },
|
||||
]);
|
||||
} finally {
|
||||
startHeld.resolve();
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
|
||||
const startHeld = deferred<void>();
|
||||
class FailingFirstStartSession extends SteeringTestSession {
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
if (prompt === "FIRST") {
|
||||
this.startPrompts.push(prompt);
|
||||
await startHeld.promise;
|
||||
throw new Error("first start failed");
|
||||
}
|
||||
return await super.startTurn(prompt);
|
||||
}
|
||||
}
|
||||
const session = new FailingFirstStartSession({
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
const steerOptions = (clientMessageId: string) => ({
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "steer" as const,
|
||||
runOptions: { clientMessageId },
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
||||
const second = startAgentRun(
|
||||
manager,
|
||||
agent.id,
|
||||
"SECOND",
|
||||
logger,
|
||||
steerOptions("second-client"),
|
||||
);
|
||||
|
||||
startHeld.resolve();
|
||||
await expect(second).resolves.toEqual({ disposition: "turn_started" });
|
||||
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
|
||||
expect(session.interruptCount).toBe(0);
|
||||
} finally {
|
||||
startHeld.resolve();
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
|
||||
@@ -2448,7 +2448,7 @@ export class AgentManager {
|
||||
throw error;
|
||||
}
|
||||
if (isStaleProviderSessionError(error)) {
|
||||
pendingRun.start = { status: "failed", error: error.message };
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
|
||||
agent.pendingReplacement = false;
|
||||
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
@@ -2456,14 +2456,18 @@ export class AgentManager {
|
||||
}
|
||||
agent.pendingReplacement = false;
|
||||
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
||||
pendingRun.start = { status: "failed", error: errorMsg };
|
||||
await this.handleStreamEvent(agent, {
|
||||
type: "turn_failed",
|
||||
provider: agent.provider,
|
||||
error: errorMsg,
|
||||
});
|
||||
this.finalizeForegroundTurn(agent);
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
|
||||
try {
|
||||
await this.handleStreamEvent(agent, {
|
||||
type: "turn_failed",
|
||||
provider: agent.provider,
|
||||
error: errorMsg,
|
||||
});
|
||||
this.finalizeForegroundTurn(agent);
|
||||
} finally {
|
||||
// A steer waiting on this start is released only once the run is cleared.
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2524,7 +2528,7 @@ export class AgentManager {
|
||||
agent.pendingReplacement = false;
|
||||
}
|
||||
const turnStartedAt = new Date();
|
||||
pendingRun.start = { status: "started", turnId };
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
|
||||
agent.activeForegroundTurnId = turnId;
|
||||
this.openActiveTurn(agent, turnId, turnStartedAt);
|
||||
agent.lifecycle = "running";
|
||||
@@ -2730,6 +2734,9 @@ export class AgentManager {
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentSteerOptions,
|
||||
): Promise<ActiveTurnSteerDispatchResult> {
|
||||
// A turn that is still starting has no id to steer yet. Replacing it would drop its
|
||||
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
|
||||
await this.runs.waitForForegroundStart(agentId);
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
|
||||
if (!expectedTurnId) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { AgentRunState } from "./agent-run-state.js";
|
||||
|
||||
function track(promise: Promise<void>): { readonly resolved: boolean } {
|
||||
const state = { resolved: false };
|
||||
void (async () => {
|
||||
await promise;
|
||||
state.resolved = true;
|
||||
})();
|
||||
return state;
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe("waitForForegroundStart", () => {
|
||||
test("resolves once the starting turn has an id", async () => {
|
||||
const runs = new AgentRunState();
|
||||
const run = runs.createPendingRun("agent");
|
||||
const wait = track(runs.waitForForegroundStart("agent"));
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(false);
|
||||
|
||||
runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" });
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(true);
|
||||
});
|
||||
|
||||
test("waits for a failed start's run to be cleared", async () => {
|
||||
const runs = new AgentRunState();
|
||||
const run = runs.createPendingRun("agent");
|
||||
const wait = track(runs.waitForForegroundStart("agent"));
|
||||
|
||||
runs.settleForegroundStart(run, { status: "failed", error: "start failed" });
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(false);
|
||||
expect(runs.hasPendingRun("agent")).toBe(true);
|
||||
|
||||
runs.settleForegroundRun("agent", run.token);
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(true);
|
||||
});
|
||||
|
||||
test("resolves at once when no turn is starting", async () => {
|
||||
const runs = new AgentRunState();
|
||||
await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter {
|
||||
resolveSettled: () => void;
|
||||
}
|
||||
|
||||
type ForegroundRunStart =
|
||||
| { status: "pending" }
|
||||
| { status: "started"; turnId: string }
|
||||
| { status: "failed"; error: string };
|
||||
|
||||
export interface PendingForegroundRun {
|
||||
token: string;
|
||||
kind: "foreground";
|
||||
stagedEvents: AgentStreamEvent[];
|
||||
start:
|
||||
| { status: "pending" }
|
||||
| { status: "started"; turnId: string }
|
||||
| { status: "failed"; error: string };
|
||||
start: ForegroundRunStart;
|
||||
turnStartedPromise: Promise<void>;
|
||||
resolveTurnStarted: () => void;
|
||||
settled: boolean;
|
||||
settledPromise: Promise<void>;
|
||||
resolveSettled: () => void;
|
||||
@@ -54,6 +58,28 @@ export class AgentRunState {
|
||||
return run?.kind === "foreground" ? run : null;
|
||||
}
|
||||
|
||||
settleForegroundStart(
|
||||
run: PendingForegroundRun,
|
||||
start: Exclude<ForegroundRunStart, { status: "pending" }>,
|
||||
): void {
|
||||
run.start = start;
|
||||
if (start.status === "started") {
|
||||
run.resolveTurnStarted();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the agent's foreground run has a turn id or has ended. A failed start
|
||||
* resolves only once its run is cleared, so callers never see a failed run still tracked.
|
||||
*/
|
||||
async waitForForegroundStart(agentId: string): Promise<void> {
|
||||
const run = this.getPendingRun(agentId);
|
||||
if (run?.start.status !== "pending") {
|
||||
return;
|
||||
}
|
||||
await Promise.race([run.turnStartedPromise, run.settledPromise]);
|
||||
}
|
||||
|
||||
hasPendingRun(agentId: string): boolean {
|
||||
return this.getPendingRun(agentId) !== null;
|
||||
}
|
||||
@@ -277,10 +303,16 @@ export class ForegroundTurnStream {
|
||||
}
|
||||
|
||||
function createPendingForegroundRun(): PendingForegroundRun {
|
||||
let resolveTurnStarted!: () => void;
|
||||
const turnStartedPromise = new Promise<void>((resolvePromise) => {
|
||||
resolveTurnStarted = resolvePromise;
|
||||
});
|
||||
return {
|
||||
...createTrackedRunState(),
|
||||
kind: "foreground",
|
||||
start: { status: "pending" },
|
||||
turnStartedPromise,
|
||||
resolveTurnStarted,
|
||||
stagedEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -130,6 +130,7 @@ import {
|
||||
type ProviderCatalog,
|
||||
type ProviderRefreshContext,
|
||||
type ResolveAgentDefaultModeInput,
|
||||
type ToolCallDetail,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { importSessionFromPersistence } from "../../provider-session-import.js";
|
||||
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
|
||||
@@ -295,6 +296,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 {
|
||||
@@ -4249,16 +4257,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) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4977,6 +4976,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.
|
||||
@@ -4987,7 +4987,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 = [
|
||||
@@ -5016,6 +5016,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const replay = {
|
||||
restoredIds: restoredProviderSubagentIds,
|
||||
toolOwners: subagentReplay.toolOwners,
|
||||
subagentToolCalls: parentFacts.toolCalls,
|
||||
};
|
||||
if (observations.length === 0) return replay;
|
||||
|
||||
@@ -5082,7 +5083,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" &&
|
||||
@@ -5832,6 +5838,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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -927,6 +927,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 });
|
||||
@@ -1218,6 +1236,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();
|
||||
|
||||
@@ -172,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;
|
||||
}
|
||||
@@ -695,7 +710,8 @@ 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;
|
||||
@@ -865,12 +881,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;
|
||||
}
|
||||
@@ -1028,6 +1045,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;
|
||||
@@ -1309,7 +1329,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, []);
|
||||
}
|
||||
|
||||
@@ -1318,6 +1345,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);
|
||||
}
|
||||
|
||||
@@ -1715,15 +1743,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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user