mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +00:00
Compare commits
141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e791ab3df7 | ||
|
|
f521abfb24 | ||
|
|
b89ebbede6 | ||
|
|
aec01ea605 | ||
|
|
a1da1b8f23 | ||
|
|
73390346ca | ||
|
|
718ed932b7 | ||
|
|
0009a7e717 | ||
|
|
29db2b7980 | ||
|
|
8c096b5e33 | ||
|
|
d0bc188a5b | ||
|
|
06fe97c901 | ||
|
|
f943e790b6 | ||
|
|
23d7a089e2 | ||
|
|
99fc204c55 | ||
|
|
33eb36c021 | ||
|
|
0fc6bf0c94 | ||
|
|
2d55870490 | ||
|
|
ab10a6694c | ||
|
|
2a151a4494 | ||
|
|
9fcae41c98 | ||
|
|
b352dad2e3 | ||
|
|
1ae979d3d0 | ||
|
|
d46164ce0d | ||
|
|
a591320409 | ||
|
|
ee6deda079 | ||
|
|
0555c877d0 | ||
|
|
b6454d7ea4 | ||
|
|
1b88f16124 | ||
|
|
1fae8e89f7 | ||
|
|
22488d4502 | ||
|
|
2a5264c188 | ||
|
|
8ddeb79c8d | ||
|
|
c90587e5b7 | ||
|
|
56f4fca9e4 | ||
|
|
28f7528588 | ||
|
|
f13bb2136c | ||
|
|
2323ee6844 | ||
|
|
1e00f452ea | ||
|
|
a162e1075f | ||
|
|
8f1091da64 | ||
|
|
4dd0e50e78 | ||
|
|
dc785bb29a | ||
|
|
93bfb34e21 | ||
|
|
2d85fe4e04 | ||
|
|
1463396ebc | ||
|
|
d4ea77b910 | ||
|
|
f715221de8 | ||
|
|
aedba5ec27 | ||
|
|
bdc58fb07c | ||
|
|
3f1a04304d | ||
|
|
50cf244e86 | ||
|
|
aaeefdc00b | ||
|
|
c5deaa0516 | ||
|
|
72c180aaf7 | ||
|
|
66b2567be3 | ||
|
|
cb2b569bbd | ||
|
|
2ecf3ba783 | ||
|
|
4515061f88 | ||
|
|
3c478e5967 | ||
|
|
05637a7f05 | ||
|
|
13d21f60f1 | ||
|
|
fc3b2c4eb7 | ||
|
|
a9fef4879a | ||
|
|
a292b402cf | ||
|
|
64c9f263e8 | ||
|
|
48059aeae3 | ||
|
|
7fd469ae19 | ||
|
|
56d5680742 | ||
|
|
06bde1e57a | ||
|
|
396a2bc0b2 | ||
|
|
f5c55dfc55 | ||
|
|
a700d7875c | ||
|
|
c301d6e8aa | ||
|
|
4ede81e841 | ||
|
|
543e3712e1 | ||
|
|
b7c8be4081 | ||
|
|
2974d7c960 | ||
|
|
a7f7405c04 | ||
|
|
2dbfd1e0f8 | ||
|
|
81a5e5bc61 | ||
|
|
0255738ee7 | ||
|
|
e3db7c3397 | ||
|
|
fe10c40b9a | ||
|
|
b6e8ca8974 | ||
|
|
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 | ||
|
|
36e6ccd6c7 | ||
|
|
fa0f04058d | ||
|
|
d517b3314e | ||
|
|
a8e6c57f15 | ||
|
|
1f8a4559a8 | ||
|
|
2187124642 | ||
|
|
154a088d6e | ||
|
|
71b236d38a | ||
|
|
8a4ce5a482 | ||
|
|
b3ed7363c6 | ||
|
|
c062280429 | ||
|
|
febe9611bf | ||
|
|
642d69b142 | ||
|
|
5aee41952e | ||
|
|
48329facc3 | ||
|
|
97083dd736 | ||
|
|
5c2c85ebc8 | ||
|
|
30fb66c8cb | ||
|
|
05b074764d | ||
|
|
5285b7e502 | ||
|
|
409b8f9312 | ||
|
|
485221b739 | ||
|
|
ed48655a77 | ||
|
|
cc8fe41e28 | ||
|
|
8216e86ecb | ||
|
|
79a3942393 |
@@ -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": {
|
||||
|
||||
+116
-49
@@ -1,63 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.3 - 2026-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
## 0.11.2 - 2026-10-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445))
|
||||
|
||||
## 0.11.0-beta.2 - 2026-10-01
|
||||
## 0.11.1 - 2026-10-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308))
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316))
|
||||
- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301))
|
||||
|
||||
## 0.11.0-beta.1 - 2026-10-01
|
||||
## 0.11.0 - 2026-10-07
|
||||
|
||||
Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry).
|
||||
|
||||
**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory.
|
||||
|
||||
### Added
|
||||
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
||||
- Added Antigravity as a provider, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216))
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it, listing each login's error when none works ([#5876](https://github.com/getpaseo/paseo/pull/5876), [#6156](https://github.com/getpaseo/paseo/pull/6156))
|
||||
- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m))
|
||||
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
|
||||
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
|
||||
- Added Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
|
||||
- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
|
||||
- Added provider option defaults in `config.json` under `agents.providers.<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 syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1))
|
||||
- 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))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
|
||||
- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
|
||||
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
||||
- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
|
||||
- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185))
|
||||
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot))
|
||||
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
|
||||
- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090))
|
||||
- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- Changed the sidebar workspace row to show last activity by default on new installs ([#6087](https://github.com/getpaseo/paseo/pull/6087))
|
||||
- Changed compact sidebar footer buttons to 44×44 tap targets ([#6044](https://github.com/getpaseo/paseo/pull/6044))
|
||||
- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds))
|
||||
- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750))
|
||||
- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
@@ -65,19 +72,44 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson))
|
||||
- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272))
|
||||
- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248))
|
||||
- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268))
|
||||
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
|
||||
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
|
||||
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
|
||||
- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Fixed ACP agents running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273))
|
||||
- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228))
|
||||
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
|
||||
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
|
||||
- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx))
|
||||
- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD))
|
||||
- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil))
|
||||
- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork))
|
||||
- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Fixed OMP tool rows showing raw objects or generic labels for failed tools, Task, Wait, web search, and fetch ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi))
|
||||
- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff))
|
||||
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112))
|
||||
- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115))
|
||||
- Fixed reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215))
|
||||
- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
|
||||
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
|
||||
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
|
||||
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
|
||||
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
|
||||
- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
|
||||
- Fixed OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188))
|
||||
- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum))
|
||||
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
@@ -88,23 +120,58 @@
|
||||
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
|
||||
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
|
||||
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
|
||||
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172))
|
||||
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138))
|
||||
- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158))
|
||||
- Fixed copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195))
|
||||
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
|
||||
- Fixed text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207))
|
||||
- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219))
|
||||
- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq))
|
||||
- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen))
|
||||
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
|
||||
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
|
||||
- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone))
|
||||
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
|
||||
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
|
||||
- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
|
||||
- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279))
|
||||
- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117))
|
||||
- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097))
|
||||
- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095))
|
||||
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
|
||||
- Fixed a failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255))
|
||||
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
|
||||
- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart))
|
||||
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
|
||||
- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
|
||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
|
||||
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
|
||||
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
|
||||
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
|
||||
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602))
|
||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Fixed a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772))
|
||||
- Fixed the centered New workspace form collapsing and overlapping when the keyboard opens on tablets ([#6093](https://github.com/getpaseo/paseo/pull/6093) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058))
|
||||
- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang))
|
||||
- Fixed iPad chat text keeping its narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
|
||||
- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
|
||||
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
|
||||
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
|
||||
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
|
||||
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed `paseo agent delete --all` and `--cwd` leaving archived agents behind ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed `paseo agent` commands reporting a generic failure instead of `AGENT_NOT_FOUND` for an unknown agent ID ([#6053](https://github.com/getpaseo/paseo/pull/6053))
|
||||
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
|
||||
- Fixed `paseo restart` exiting with `RESTART_NOT_CONFIRMED` when provider checks take more than a second ([#6009](https://github.com/getpaseo/paseo/pull/6009))
|
||||
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||
- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815))
|
||||
|
||||
## 0.10.3 - 2026-10-02
|
||||
|
||||
@@ -39,21 +39,33 @@
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
|
||||
|
||||
- **Parallel agents:** Run many agents at once, each in its own worktree.
|
||||
- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers.
|
||||
- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
|
||||
|
||||
## Plugins
|
||||
|
||||
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
|
||||
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
|
||||
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
|
||||
iOS, and Android. Write a plugin once and it is on your phone.
|
||||
|
||||
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
|
||||
machine and inside connected clients; install only code you trust.
|
||||
- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes.
|
||||
- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends.
|
||||
- **Providers:** add a coding agent as a provider.
|
||||
|
||||
Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory.
|
||||
|
||||
**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)**
|
||||
|
||||
Plugins run with access to your daemon machine and inside connected clients; install only code you trust.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -77,16 +89,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r
|
||||
|
||||
To connect from your phone, open **Settings → your host → Pair Device**.
|
||||
|
||||
### CLI / headless
|
||||
### Server
|
||||
|
||||
Install the CLI and start Paseo:
|
||||
For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
|
||||
Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host.
|
||||
|
||||
For full setup and configuration, see:
|
||||
|
||||
@@ -197,7 +209,7 @@ npm run typecheck
|
||||
|
||||
## Sponsors
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
|
||||
+30
-3
@@ -69,11 +69,11 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
|
||||
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
|
||||
@@ -84,6 +84,32 @@ Some providers can create their own child sessions inside one provider runtime.
|
||||
|
||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||
|
||||
## Background workspaces
|
||||
|
||||
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
|
||||
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
|
||||
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
|
||||
change it. New workspaces default to false, inherit the caller workspace's value when an agent
|
||||
creates one without an override, and accept explicit true or false.
|
||||
|
||||
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
|
||||
including History requests, exclude background work; `filter.includeBackground` includes it.
|
||||
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
|
||||
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
|
||||
|
||||
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
||||
those collections with its subset. Inclusive and default observations have distinct cursor
|
||||
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
||||
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
|
||||
### Private daemon helpers
|
||||
|
||||
Branch-name and commit-message generators still use private disposable internal runtimes.
|
||||
`AgentManager` does not persist them or their provider sessions, excludes them from public
|
||||
discovery and plugin hooks, and drops their timelines on archive. An archived helper retains
|
||||
its exact-ID result briefly to cover completion before a waiter attaches. This internal
|
||||
lifecycle is separate from public workspace visibility and is not a public creation option.
|
||||
|
||||
## Archive
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
@@ -197,10 +223,11 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
|
||||
|
||||
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
|
||||
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
|
||||
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
|
||||
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`.
|
||||
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
|
||||
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
|
||||
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
|
||||
- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one.
|
||||
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
|
||||
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
|
||||
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
|
||||
|
||||
@@ -345,6 +345,10 @@ join active work or reuse committed stages. A persisted resource alone cannot pr
|
||||
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
|
||||
receipt return an unknown outcome instead of being repeated.
|
||||
|
||||
Agent registration commits creation. Rejecting the first prompt is a turn error on that
|
||||
agent; reporting creation failure leaves an orphaned agent and invites another Create.
|
||||
Receipt replay must not resend the prompt.
|
||||
|
||||
`packages/client/src/creation/` owns capability selection and legacy orchestration.
|
||||
Callers always pass the initial prompt to agent creation. On an older host, the client
|
||||
adapts keyed creation to the legacy create/send sequence; it cannot continue that
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ canonical files and are trying to add or change one.
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
|
||||
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||
|
||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||
@@ -92,7 +92,7 @@ portal can cover them.
|
||||
Painting and keyboard ownership use the same relative layer model. Register
|
||||
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
|
||||
highest painted scope alone receives overlay keys, traps focus, and restores
|
||||
focus when it closes. Do not add component-local global Escape listeners: two
|
||||
focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two
|
||||
stacked overlays would both close on one keypress.
|
||||
|
||||
If an overlay is rendered by a global host rather than beneath its opener in
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a tooltip on wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. Only the sheet has Refresh. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
+2
-2
@@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
@@ -150,5 +150,5 @@ Before opening a PR that touches hover:
|
||||
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
|
||||
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
|
||||
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel) and the user can point at it, it is a `HoverCard`.
|
||||
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
|
||||
|
||||
+2
-2
@@ -197,7 +197,7 @@ its own.
|
||||
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
|
||||
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
|
||||
inactive, and that inactive commit is where the surface unmounts.
|
||||
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
|
||||
positioning bugs there, not at a call site.
|
||||
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
|
||||
card, and are unit-tested. Fix positioning bugs there, not at a call site.
|
||||
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
||||
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
|
||||
|
||||
@@ -32,8 +32,10 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
|
||||
React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists
|
||||
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
release. Native panel hosts and their dependent draggable lists
|
||||
also retain identity across appearance hydration and settings changes. Do not wrap those hosts in
|
||||
appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences).
|
||||
|
||||
|
||||
@@ -65,6 +65,26 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
|
||||
The Android sidebar scroll-retention flow requires a connected host with enough
|
||||
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
|
||||
`SCROLLED_WORKSPACE` as the `serverId:workspaceId` suffixes of their sidebar row
|
||||
IDs. Choose one row near the top and one initially below the viewport. Replace
|
||||
the quoted example values below with those IDs. Start in a workspace with the
|
||||
sidebar closed and keep the list stable during the run. The same flow checks
|
||||
reopening by swipe and button:
|
||||
|
||||
```bash
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml \
|
||||
--maestro --platform android --serial emulator-5556 --metro-port 8099 \
|
||||
--env TOP_WORKSPACE='server-id:top-workspace-id' \
|
||||
--env SCROLLED_WORKSPACE='server-id:scrolled-workspace-id'
|
||||
```
|
||||
|
||||
Pass this checkout's Metro port so the debug app uses the intended bundle.
|
||||
For iOS, compare the scrolled row's screen bounds and screenshots across reopening.
|
||||
Agent Device's iOS visibility lookup includes offscreen scroll descendants, so
|
||||
its `assertNotVisible` cannot establish the starting scroll position for this flow.
|
||||
|
||||
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
|
||||
screens launch on their own, take input focus, and the run fails with
|
||||
`IME did not become visible` while the app still looks focused:
|
||||
|
||||
+20
-10
@@ -117,10 +117,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
|
||||
|
||||
## Install from a registry
|
||||
|
||||
`paseo plugin install owner/slug` installs the registry's reviewed artifact. Registry installs
|
||||
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
|
||||
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
|
||||
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
|
||||
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
|
||||
selection is unavailable for registry installs; install an explicit source to select your own.
|
||||
|
||||
Registry overviews are untrusted author content, even before installation. Render them with
|
||||
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
|
||||
URLs, and isolated external links. The website adapter and corpus tests live in
|
||||
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
|
||||
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
|
||||
this policy. Installed-plugin descriptions in the app remain plain text.
|
||||
|
||||
Use `host/owner/slug` for a registry at `https://host`, or set
|
||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||
Private registry credentials live in daemon config under
|
||||
@@ -133,13 +142,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub shorthand requires `github:`. Other hosts use a Git URL. An existing directory
|
||||
still wins over source resolution.
|
||||
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||
URL.
|
||||
|
||||
```bash
|
||||
paseo plugin install github:owner/repository
|
||||
paseo plugin install git:owner/repository
|
||||
paseo plugin install https://gitlab.com/group/repository.git
|
||||
paseo plugin install github:owner/monorepo:plugins/review
|
||||
paseo plugin install git:owner/monorepo:plugins/review
|
||||
paseo plugin install github:owner/repository --ref main
|
||||
```
|
||||
|
||||
@@ -150,8 +159,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r
|
||||
## Managed source ownership
|
||||
|
||||
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
|
||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
|
||||
calling `installPluginSource`; see the public source reference for CLI and app path semantics.
|
||||
Only the daemon reads plugin files and acquires sources.
|
||||
|
||||
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
|
||||
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
|
||||
@@ -243,7 +253,7 @@ dependency; shared types must not refer to React components, hooks, Node APIs, o
|
||||
| Entry | Owns | May depend on |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||
@@ -483,9 +493,9 @@ SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
|
||||
+6
-2
@@ -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.
|
||||
@@ -59,6 +61,8 @@ Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`code
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
|
||||
|
||||
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
|
||||
@@ -101,7 +105,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. It calls `query.interrupt()` only while a main-session turn is in flight (between `system/init` and that turn's `result`): with the main session idle, an interrupt reaches only background subagents and no result follows it, so the adapter withdraws its unstarted messages and stops there. The adapter also declares `perTaskStopAffordance`, because without it the CLI kills every background subagent on interrupt. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
|
||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -51,7 +51,7 @@ Claude hook mapping:
|
||||
|
||||
- `UserPromptSubmit` → `running`
|
||||
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
|
||||
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
|
||||
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
|
||||
|
||||
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
|
||||
while terminal activity is working clears the activity without finished attention. The same
|
||||
|
||||
+19
-5
@@ -223,7 +223,7 @@ submission. Overlapping sends settle independently rather than collapsing to one
|
||||
message.
|
||||
|
||||
Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted
|
||||
prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
|
||||
human prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
|
||||
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
|
||||
row is recorded before handler output. The app tracks submission transactions only for hosts with this
|
||||
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
|
||||
@@ -238,11 +238,25 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
||||
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
||||
|
||||
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
|
||||
the Paseo timeline, so one visible response can span several canonical turns without a user message
|
||||
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
|
||||
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
|
||||
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
|
||||
prompts, including schedule injections, remain hidden. One visible response can span several canonical
|
||||
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
|
||||
fork positions retain the canonical `turnId` boundaries.
|
||||
|
||||
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
|
||||
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
|
||||
also the projected call ID, so a provider echo cannot create a second row. Sender IDs are opaque and
|
||||
do not imply local ownership or authorization. Capture the sender title at delivery: archived or
|
||||
removed senders must remain understandable without a live directory lookup.
|
||||
|
||||
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
|
||||
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
|
||||
must not construct UI labels or prepend sender labels to the semantic message body. The existing
|
||||
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
|
||||
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
|
||||
XML. Stream reduction and disk-cache serialization preserve the payload without rendering it.
|
||||
|
||||
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
|
||||
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
|
||||
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
|
||||
@@ -250,7 +264,7 @@ second running state. Disconnect preserves the last replicated turn until cache
|
||||
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
|
||||
liveness, never from submission records or whichever timeline rows happen to be mounted.
|
||||
|
||||
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
For human prompts, the daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
|
||||
the wire. A correlated provider echo records the provider's native identity internally without
|
||||
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -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-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ=
|
||||
|
||||
@@ -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
+31
-30
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36413,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37496,12 +37496,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/server": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/server": "0.11.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37748,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/relay": "0.11.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37762,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38006,7 +38006,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38902,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38931,14 +38931,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38948,8 +38948,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38998,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39216,16 +39216,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/highlight": "0.11.0-beta.3",
|
||||
"@getpaseo/plugin": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/highlight": "0.11.2",
|
||||
"@getpaseo/plugin": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/relay": "0.11.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40146,11 +40146,12 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@getpaseo/protocol": "*",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-start": "^1.168.26",
|
||||
"framer-motion": "^12.35.2",
|
||||
@@ -40165,8 +40166,7 @@
|
||||
"remark-directive": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"wrangler": "^4.75.0",
|
||||
"@getpaseo/protocol": "*"
|
||||
"wrangler": "^4.75.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
@@ -40174,6 +40174,7 @@
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"parse5": "^7.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
@@ -41753,7 +41754,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -284,7 +284,6 @@ async function expectRejectedSubmissionRestored(
|
||||
await expectComposerDraft(page, input.prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
|
||||
if (input.preservesActiveTurn) {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
return;
|
||||
@@ -295,13 +294,21 @@ async function expectRejectedSubmissionRestored(
|
||||
|
||||
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(userMessage).toHaveCount(1);
|
||||
const attempts = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(attempts).toHaveCount(2);
|
||||
const userMessage = attempts.last();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
|
||||
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
|
||||
await expect(messages).toHaveCount(2);
|
||||
await expect(messages.nth(0)).toContainText(prompt);
|
||||
await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`);
|
||||
}
|
||||
|
||||
async function configureSteerInSettings(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
@@ -1173,13 +1180,14 @@ test.describe("Agent message submission", () => {
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
test("restores a rejected submission and accepts its retry", async ({
|
||||
test("retains a rejected submission before its error and accepts its retry", async ({
|
||||
page,
|
||||
rejectionScenario,
|
||||
}) => {
|
||||
const prompt = "Restore this rejected submission.";
|
||||
await submitMessageThatWillBeRejected(page, prompt);
|
||||
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
|
||||
await expectRejectedPromptBeforeError(page, prompt, rejectionScenario.errorMessage);
|
||||
await retryRestoredSubmission(page, prompt);
|
||||
});
|
||||
|
||||
@@ -1209,6 +1217,7 @@ test.describe("Agent message submission", () => {
|
||||
errorMessage: "Requested mock steer transport failure",
|
||||
preservesActiveTurn: true,
|
||||
});
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
|
||||
|
||||
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
|
||||
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
|
||||
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
|
||||
|
||||
test("agent list age advances and command center says just now for a fresh agent", async ({
|
||||
page,
|
||||
@@ -41,9 +40,6 @@ test("agent list age advances and command center says just now for a fresh agent
|
||||
"3m ago",
|
||||
);
|
||||
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
|
||||
@@ -120,6 +120,84 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
/** Press in the gap below the image and drag to the end of `text` in the block after it. */
|
||||
async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const target = blocks.getByText(text);
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox();
|
||||
const end = await target.evaluate((element, selectedText) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode as Text;
|
||||
const offset = node.textContent?.indexOf(selectedText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset + selectedText.length);
|
||||
const box = range.getBoundingClientRect();
|
||||
return { x: box.right - 1, y: box.top + box.height / 2, left: box.left };
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find ${selectedText}`);
|
||||
}, text);
|
||||
if (!frame) {
|
||||
throw new Error("Expected a rendered image");
|
||||
}
|
||||
await page.mouse.move(end.left + 5, frame.y + frame.height + 4);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
@@ -565,6 +643,19 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
const completeCodeClipboard = await readRichClipboard(page);
|
||||
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
|
||||
|
||||
// Touching the block before it without selecting any of its text is still a
|
||||
// selection inside the code.
|
||||
await selectAssistantElementFromEndOf(
|
||||
page,
|
||||
"not a generated link.",
|
||||
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
|
||||
);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'const answer = "yes";\n return answer;',
|
||||
);
|
||||
|
||||
// Crossing the block boundary expresses an intent to carry its Markdown structure.
|
||||
await selectAssistantAcrossCodeLines(page, "const", "After code.");
|
||||
await copySelection(page);
|
||||
@@ -601,3 +692,55 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const image =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-copy-image-",
|
||||
title: "Assistant selection copy image",
|
||||
initialPrompt: "Render the image fixture.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: [
|
||||
"Before the image.",
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
"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(``);
|
||||
|
||||
await dragFromBelowImageTo(page, "After the");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe("After the");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -428,7 +428,16 @@ async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo
|
||||
}
|
||||
}
|
||||
|
||||
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
|
||||
test("opens and refocuses Find with Control+f from the composer on Linux", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// Chromium retains the host navigator.platform even when its user agent is overridden.
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await session.send("Emulation.setUserAgentOverride", {
|
||||
userAgent:
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
platform: "Linux x86_64",
|
||||
});
|
||||
await findFromComposer(page, "Control+f", testInfo);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
expectComposerNotFocused,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
@@ -27,3 +28,51 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-open-",
|
||||
title: "Composer focus on open",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForAnimationFrames(page: Page, count: number): Promise<void> {
|
||||
await page.evaluate(async (frames) => {
|
||||
for (let frame = 0; frame < frames; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}, count);
|
||||
}
|
||||
|
||||
test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
|
||||
// composer there raises the on-screen keyboard over the conversation.
|
||||
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("opening an agent leaves the composer unfocused", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-touch-",
|
||||
title: "Composer focus on touch",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
expect(
|
||||
await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches),
|
||||
).toBe(false);
|
||||
// Autofocus makes its first attempt on the next animation frame.
|
||||
await waitForAnimationFrames(page, 4);
|
||||
await expectComposerNotFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
submitMessageWithButton,
|
||||
} from "../support/helpers/composer";
|
||||
import {
|
||||
type AgentUsageScript,
|
||||
closeContextWindowSheet,
|
||||
contextWindowSheet,
|
||||
contextWindowDetails,
|
||||
expectRefreshButtons,
|
||||
expiredLogin,
|
||||
gate,
|
||||
hoverContextWindowMeter,
|
||||
leaveContextWindowMeter,
|
||||
type MockAgentSession,
|
||||
moveOntoContextWindowDetails,
|
||||
onPersonalLogin,
|
||||
onWorkLogin,
|
||||
openAgent,
|
||||
@@ -24,6 +29,7 @@ import {
|
||||
} from "../support/helpers/context-window";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item";
|
||||
import { installLoginUsage } from "../support/helpers/usage-login";
|
||||
|
||||
const test = base.extend<{ agent: MockAgentSession }>({
|
||||
agent: async ({ page: _page }, provide) => {
|
||||
@@ -107,6 +113,66 @@ const DESKTOP = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/;
|
||||
|
||||
for (const layout of [
|
||||
{
|
||||
name: "desktop",
|
||||
viewport: DESKTOP,
|
||||
open: hoverContextWindowMeter,
|
||||
close: leaveContextWindowMeter,
|
||||
},
|
||||
{
|
||||
name: "compact",
|
||||
viewport: COMPACT,
|
||||
open: pressContextWindowMeter,
|
||||
close: closeContextWindowSheet,
|
||||
},
|
||||
]) {
|
||||
test(`shows account usage before the first turn (${layout.name})`, async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const source = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 31 }],
|
||||
});
|
||||
try {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-empty-",
|
||||
title: "Agent before its first turn",
|
||||
});
|
||||
try {
|
||||
await page.setViewportSize(layout.viewport);
|
||||
await openAgent(page, agent);
|
||||
|
||||
await test.step("the empty meter opens context details and account usage", async () => {
|
||||
const details = await layout.open(page, "Context window: No context data");
|
||||
await expect(details.getByText("No context data", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("0% used", { exact: true })).toHaveCount(0);
|
||||
await expect(details.getByText(/tokens$/)).toHaveCount(0);
|
||||
const account = usageCard(details, "login-journey:account");
|
||||
await expect(account.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31%", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, `context-without-data-${layout.name}`);
|
||||
await layout.close(page);
|
||||
});
|
||||
|
||||
await test.step("the first context reading replaces the empty state", async () => {
|
||||
await submitMessageWithButton(page, "emit 32000 byte file agent stream payload");
|
||||
const details = await layout.open(page);
|
||||
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type OpenDetails = (page: Page) => Promise<Locator>;
|
||||
type Shot = (state: string) => Promise<void>;
|
||||
|
||||
@@ -142,7 +208,6 @@ async function expectCardsToStreamIn(
|
||||
// Only the agent's login, never the host's default one, and no pins.
|
||||
await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0);
|
||||
await expectUnpinnableRows(details);
|
||||
await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2);
|
||||
return details;
|
||||
}
|
||||
|
||||
@@ -177,24 +242,49 @@ async function expectOnlyContextWindowWithoutUsage(
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => {
|
||||
test(`the context window hover card shows the agent's usage (${theme})`, async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`);
|
||||
const shot: Shot = (state) => qaScreenshot(page, `hover-card-desktop-${theme}-${state}`);
|
||||
|
||||
await test.step("reports stream in one card at a time, without Refresh", async () => {
|
||||
const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
|
||||
// The tooltip cannot be pressed, so its cards have no Refresh.
|
||||
await expectRefreshButtons(tooltip, 0);
|
||||
await test.step("reports stream in one card at a time, each with Refresh", async () => {
|
||||
const card = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
|
||||
await expectRefreshButtons(card, 2);
|
||||
await shot("ready");
|
||||
expect(usage.agentRequests()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("the card stays open while the pointer moves onto it", async () => {
|
||||
const card = contextWindowDetails(page);
|
||||
await moveOntoContextWindowDetails(page, card);
|
||||
await page.waitForTimeout(300);
|
||||
await expect(card).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Refresh keeps the card open and replaces it with the new report", async () => {
|
||||
const refreshed = gate();
|
||||
usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] });
|
||||
const card = contextWindowDetails(page);
|
||||
await refreshUsageCard(card, "Claude");
|
||||
await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled();
|
||||
// Past the card's close grace, with the pointer still on the card.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(card).toBeVisible();
|
||||
refreshed.open();
|
||||
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("after a resume under another login, only that login shows", async () => {
|
||||
// Refresh can retain focus; return to the composer before testing pointer dismissal.
|
||||
await composerLocator(page).focus();
|
||||
// Closed while the old login's report is still on its way.
|
||||
await leaveContextWindowMeter(page);
|
||||
const slowSource = gate();
|
||||
@@ -208,25 +298,25 @@ for (const theme of ["light", "dark"] as const) {
|
||||
usage.answerNext({
|
||||
stream: [resumed.promise, onPersonalLogin(claude!), finished.promise],
|
||||
});
|
||||
const tooltip = await hoverContextWindowMeter(page);
|
||||
await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
const card = await hoverContextWindowMeter(page);
|
||||
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
|
||||
// The old login's report lands during the new request, before the new login's.
|
||||
slowSource.open();
|
||||
resumed.open();
|
||||
await expect(
|
||||
usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }),
|
||||
usageCard(card, "claude:personal").getByText("personal@example.com", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
finished.open();
|
||||
});
|
||||
|
||||
await test.step("a report with a problem shows it on the card", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
await reloadAgent(page);
|
||||
const tooltip = await hoverContextWindowMeter(page);
|
||||
await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
const card = await hoverContextWindowMeter(page);
|
||||
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
await shot("problem");
|
||||
});
|
||||
|
||||
@@ -237,7 +327,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
|
||||
// One request per open that had usage; this one sends none.
|
||||
expect(usage.agentRequests()).toHaveLength(5);
|
||||
expect(usage.agentRequests()).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,16 +346,18 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await test.step("reports stream in one card at a time, each with Refresh", async () => {
|
||||
const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot);
|
||||
await expectRefreshButtons(sheet, 2);
|
||||
// Without hover, each card prints its freshness instead of a Refresh tooltip.
|
||||
await expect(sheet.getByText("Updated just now", { exact: true })).toHaveCount(2);
|
||||
await shot("ready");
|
||||
expect(usage.agentRequests()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("Refresh replaces the card with the source's new report", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
const sheet = contextWindowSheet(page);
|
||||
const sheet = contextWindowDetails(page);
|
||||
await refreshUsageCard(sheet, "Claude");
|
||||
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
|
||||
await shot("problem");
|
||||
});
|
||||
|
||||
@@ -279,7 +371,85 @@ for (const theme of ["light", "dark"] as const) {
|
||||
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
|
||||
expect(usage.agentRequests()).toHaveLength(2);
|
||||
expect(usage.agentRequests()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("context details support keyboard entry and dismiss when the viewport moves", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
|
||||
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stop).toBeVisible();
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
});
|
||||
await expect(trigger).toHaveAttribute("tabindex", "0");
|
||||
await trigger.focus();
|
||||
const details = contextWindowDetails(page);
|
||||
await expect(details).toBeVisible();
|
||||
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeVisible();
|
||||
await trigger.press("ArrowDown");
|
||||
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(details).toHaveCount(0);
|
||||
await expect(stop).toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
for (const key of ["Enter", " "]) {
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
await trigger.press(key);
|
||||
await expect(details).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(details).toHaveCount(0);
|
||||
await expect(stop).toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
}
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
await trigger.press("ArrowDown");
|
||||
await expect(details).toBeVisible();
|
||||
await page.setViewportSize({ width: 1280, height: 850 });
|
||||
await expect(details).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const input = composerLocator(page);
|
||||
await input.focus();
|
||||
await hoverContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
await leaveContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
|
||||
test("Find remains available after dismissing context details", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
});
|
||||
await trigger.focus();
|
||||
await expect(contextWindowDetails(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
@@ -128,3 +128,46 @@ for (const scenario of [
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of [
|
||||
{
|
||||
name: "agent draft",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openAgentDraft(),
|
||||
button: "Send message",
|
||||
settled: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
|
||||
},
|
||||
{
|
||||
name: "new workspace",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openWorkspaceForm("local"),
|
||||
button: "Create",
|
||||
settled: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.expectOneCreatedWorkspace(),
|
||||
},
|
||||
]) {
|
||||
test(`${entry.name} becomes an agent tab when the first prompt is rejected`, async ({
|
||||
creation,
|
||||
promptRejection,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
void promptRejection;
|
||||
const prompt =
|
||||
"Continue the attached conversation.\n" + "Earlier conversation context.\n".repeat(100);
|
||||
await entry.open(creation);
|
||||
await creation.submitPrompt(prompt, entry.button);
|
||||
await entry.settled(creation);
|
||||
await creation.expectCreatedAgentError();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
|
||||
await creation.reloadAgent();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
|
||||
await creation.expectPromptVisible(
|
||||
"emit 1 coalesced agent stream updates for a corrected prompt.",
|
||||
);
|
||||
await creation.expectAssistantReply();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,6 +96,36 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("jumps to each relative reference to the same file", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "relative-reference-qa-",
|
||||
title: "Relative line references",
|
||||
initialPrompt: "Show both references",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First `src/router.go:46`. Then `src/router.go:403`.",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "src"));
|
||||
await writeFile(
|
||||
path.join(session.cwd, "src/router.go"),
|
||||
Array.from({ length: 450 }, (_, n) => `// line ${n + 1}`).join("\n"),
|
||||
);
|
||||
await openAgentRoute(page, session);
|
||||
for (const line of [46, 403, 46]) {
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByText(`src/router.go:${line}`, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Line ${line}, column 1`)).toBeVisible();
|
||||
}
|
||||
await page.screenshot({ path: test.info().outputPath("relative-line-target.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
|
||||
|
||||
await installPlugin(page, missingSource);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Plugin source is neither an existing directory nor a Git URL",
|
||||
`Plugin directory does not exist: ${missingSource}`,
|
||||
);
|
||||
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
|
||||
await capturePluginInstallForm(page, testInfo, "compact-error");
|
||||
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
|
||||
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
|
||||
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
|
||||
await installPluginWithUntrustedDescription(pluginEnvironment, description);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
const text = page.getByText(description, { exact: true });
|
||||
await expect(text).toBeVisible();
|
||||
// This field must remain a text node, with no Markdown links, images, or HTML children.
|
||||
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
|
||||
});
|
||||
|
||||
async function installPluginWithUntrustedDescription(
|
||||
environment: PluginEnvironment,
|
||||
description: string,
|
||||
) {
|
||||
const directory = await createDirectoryPlugin(
|
||||
environment.directory,
|
||||
"plain-text-plugin",
|
||||
description,
|
||||
"Plain text plugin",
|
||||
);
|
||||
await environment.client.installPluginSource({ source: directory });
|
||||
}
|
||||
|
||||
@@ -155,6 +155,18 @@ test("keeps diagnostics available from Settings after globalizing the sheet", as
|
||||
test.describe("compact sidebar help", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("footer buttons provide finger-sized targets and accept taps beyond the glyph", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectFingerSizedFooterTargets(page);
|
||||
await tapHelpNearTargetEdge(page);
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
});
|
||||
|
||||
test("offers diagnostics without advertising disabled keyboard shortcuts", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
@@ -165,3 +177,29 @@ test.describe("compact sidebar help", () => {
|
||||
await expectDiagnosticReport(page);
|
||||
});
|
||||
});
|
||||
|
||||
async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
|
||||
}
|
||||
|
||||
const MIN_TARGET_SIZE = 44;
|
||||
|
||||
async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
|
||||
await expect(buttons).toHaveCount(5);
|
||||
for (const button of await buttons.all()) {
|
||||
const bounds = await button.boundingBox();
|
||||
// Layout can report a 44pt box as 43.99999, so compare at a hundredth of a point.
|
||||
expect(bounds?.width).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
|
||||
expect(bounds?.height).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
|
||||
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
|
||||
const glyphWidth = await button.locator("svg").first().getAttribute("width");
|
||||
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
|
||||
}
|
||||
}
|
||||
|
||||
async function tapHelpNearTargetEdge(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "Help and support", exact: true });
|
||||
await button.tap({ position: { x: 40, y: 40 } });
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(workspaceRow(page)).toBeVisible();
|
||||
await toggleTrailing(page, "Diff stats", true);
|
||||
await page.mouse.move(0, 0);
|
||||
await page.screenshot({ path: testInfo.outputPath("sidebar.png") });
|
||||
}
|
||||
@@ -106,6 +107,7 @@ async function openDesktopWorkspaceList(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
const row = workspaceRow(page);
|
||||
await expect(row).toBeVisible();
|
||||
await toggleTrailing(page, "Diff stats");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(row.getByText("+12.3k", { exact: true })).toBeVisible();
|
||||
return titleWidth(row);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
@@ -73,3 +74,75 @@ test.describe("Sidebar workspace rename", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Show background", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function expectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// Rows have no single accessible role: their nested title and actions share this container.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectWorkspaceHidden(page: Page, workspaceId: string): Promise<void> {
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function reconnectApp(page: Page): Promise<void> {
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Display preferences", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectWorkerOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Background reviewer", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Background review prompt", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("background workspace stays directly accessible across sidebar toggles and reconnect", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-background-",
|
||||
title: "Background review",
|
||||
background: true,
|
||||
});
|
||||
try {
|
||||
const worker = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Background reviewer",
|
||||
initialPrompt: "Background review prompt",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await test.step("Default sidebar hides background work", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
});
|
||||
await test.step("Show background exposes the fully replicated workspace", async () => {
|
||||
await toggleBackgroundWorkspaces(page);
|
||||
await expectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await reconnectApp(page);
|
||||
await expectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
});
|
||||
await test.step("Direct agent navigation survives hiding and reconnect", async () => {
|
||||
await toggleBackgroundWorkspaces(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: worker.id });
|
||||
await expectWorkerOpen(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
await reconnectApp(page);
|
||||
await expectWorkerOpen(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
await page.screenshot({ path: testInfo.outputPath("background-direct-open.png") });
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectWorkspaceHoverCardClosed,
|
||||
expectWorkspaceHoverCardOpen,
|
||||
expectWorkspaceHoverCardStaysOpen,
|
||||
focusWorkspaceRowWithKeyboard,
|
||||
movePointerAwayFromWorkspaceHoverCard,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
@@ -404,6 +409,52 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await row.click();
|
||||
await expect(row).toBeFocused();
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sidebar-mark-unread-",
|
||||
|
||||
+100
-48
@@ -18,15 +18,19 @@ import {
|
||||
installLoginUsage,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
openUsage,
|
||||
refreshLoginUsage,
|
||||
hoverUsageWindow,
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
closeUsageOptions,
|
||||
expectUsageOptionsFitContent,
|
||||
showUsageAs,
|
||||
refreshAllUsage,
|
||||
togglePin,
|
||||
@@ -96,7 +100,7 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("usage screen", () => {
|
||||
test.describe("usage modal", () => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -132,14 +136,13 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
await openUsageFromItem(page);
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(hostFilter(page)).toHaveCount(0);
|
||||
await qaScreenshot(page, "phase7-usage-screen-one-host");
|
||||
await qaScreenshot(page, "usage-modal-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
@@ -174,7 +177,7 @@ test.describe("usage screen", () => {
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct }],
|
||||
},
|
||||
});
|
||||
// The sidebar summary and the screen each load reports; only a card's Refresh forces one.
|
||||
// The sidebar summary and the modal each load reports; only a card's Refresh forces one.
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
(request) =>
|
||||
@@ -185,7 +188,7 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
await openUsageFromItem(page);
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -222,7 +225,9 @@ test.describe("usage screen", () => {
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/usage");
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
@@ -236,19 +241,19 @@ test.describe("usage screen", () => {
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
// Without reports the footer has no Usage item; its Usage icon opens the screen.
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
|
||||
// Without reports the footer has no Usage item; its Usage icon opens Usage.
|
||||
await openUsageFromIcon(page);
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await qaScreenshot(page, "phase7-usage-update-host");
|
||||
await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0);
|
||||
await expect(page.getByTestId("usage-options-menu")).toHaveCount(0);
|
||||
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
|
||||
test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(420_000);
|
||||
@@ -271,7 +276,7 @@ test.describe("usage screen", () => {
|
||||
|
||||
// Nothing picked and no workspace open: the first host.
|
||||
await expectPinnedUsage(page, ["31% Weekly"]);
|
||||
await usageItem(page).click();
|
||||
await openUsageFromItem(page);
|
||||
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
||||
|
||||
await hostFilter(page).click();
|
||||
@@ -280,7 +285,7 @@ test.describe("usage screen", () => {
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-screen-picked-host");
|
||||
await qaScreenshot(page, "usage-modal-picked-host");
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
|
||||
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
|
||||
@@ -290,6 +295,7 @@ test.describe("usage screen", () => {
|
||||
port: secondary.port,
|
||||
});
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
@@ -318,16 +324,16 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-expired-login");
|
||||
await fixture.setReport(loginWindows);
|
||||
await refreshLoginUsage(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
|
||||
@@ -364,7 +370,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => {
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
|
||||
@@ -411,7 +418,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await expect(usageItem(page)).toBeVisible();
|
||||
await usageItem(page).click();
|
||||
await openUsageFromItem(page);
|
||||
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
|
||||
await expectCodexReportedWindows(page, shape);
|
||||
await qaScreenshot(page, `codex-${shape}-pins`);
|
||||
@@ -426,7 +433,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
desktop: { width: 1440, height: 900 },
|
||||
compact: { width: 390, height: 844 },
|
||||
})) {
|
||||
test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => {
|
||||
test(`Usage Settings cog ${size} ${theme}`, async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript((value) => {
|
||||
const key = "@paseo:app-settings";
|
||||
@@ -437,45 +444,29 @@ for (const theme of ["light", "dark"] as const) {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/usage");
|
||||
await gotoAppShell(page);
|
||||
if (size === "compact") await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const settings = screen.getByRole("button", { name: "Settings", exact: true });
|
||||
const chevron = settings.locator("svg").locator("../..");
|
||||
const settings = page.getByTestId("usage-options-menu");
|
||||
await expect(settings).toHaveAccessibleName("Settings");
|
||||
await expect(settings).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(chevron).toHaveCSS("transform", "none");
|
||||
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await expect(
|
||||
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-pinned"),
|
||||
).toHaveCSS("opacity", "1");
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-collapsed`);
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-cog`);
|
||||
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await page.mouse.move(0, viewport.height - 1);
|
||||
await expect(
|
||||
pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-unpinned"),
|
||||
).toHaveCSS("opacity", "1");
|
||||
await openUsageOptions(page);
|
||||
await expect(settings).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(chevron).toHaveCSS("transform", "matrix(0, 1, -1, 0, 0, 0)");
|
||||
await expect(screen.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
|
||||
await expect(screen.getByTestId("usage-display-used")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await settings.blur();
|
||||
await page.mouse.move(0, viewport.height - 1);
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-expanded`);
|
||||
await expectUsageOptionsFitContent(page);
|
||||
await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveAttribute("aria-selected", "true");
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-settings`);
|
||||
await showUsageAs(page, "remaining");
|
||||
await expect(screen.getByText("69% left")).toBeVisible();
|
||||
await showUsageAs(page, "used");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, 31% · resets /,
|
||||
);
|
||||
await settings.click();
|
||||
await expect(settings).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(screen.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await openUsageOptions(page);
|
||||
await closeUsageOptions(page);
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]);
|
||||
});
|
||||
@@ -490,3 +481,64 @@ async function captureSettingsState(
|
||||
await qaScreenshot(page, name);
|
||||
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
|
||||
}
|
||||
|
||||
test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await installLoginUsage([
|
||||
{ harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } },
|
||||
{
|
||||
harness: "OpenCode",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "rejected", status: 401, refreshedBy: "opencode" },
|
||||
},
|
||||
},
|
||||
{
|
||||
harness: "Pi",
|
||||
report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } },
|
||||
},
|
||||
{
|
||||
harness: "OMP",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "rejected", status: 403, refreshedBy: "omp" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectLoginErrors(page, [
|
||||
"Codex: Usage API returned 500. Try again.",
|
||||
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
|
||||
"Pi: No active plan",
|
||||
"OMP: Login rejected (HTTP 403). Run omp to refresh it.",
|
||||
]);
|
||||
await fixture.setReport([
|
||||
{
|
||||
harness: "Codex",
|
||||
report: { status: "error", error: "Usage API returned 500. Try again." },
|
||||
},
|
||||
{
|
||||
harness: "OpenCode",
|
||||
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] },
|
||||
},
|
||||
]);
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectLoginErrors(page: Page, lines: string[]) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n"));
|
||||
}
|
||||
|
||||
async function expectLoginUsageOnly(page: Page, percentage: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
installUsageReportsFixture,
|
||||
type UsageReportsFixture,
|
||||
} from "../support/helpers/usage-reports";
|
||||
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
|
||||
import {
|
||||
openUsageFromIcon,
|
||||
refreshAllUsage,
|
||||
showUsageAs,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
||||
|
||||
@@ -95,10 +99,11 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
|
||||
// Percentages now live on the Usage screen, and still apply to the host section.
|
||||
await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0);
|
||||
// The shared percentages setting applies to the host section.
|
||||
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
|
||||
const hostUsageUrl = page.url();
|
||||
await page.goto("/usage");
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await showUsageAs(page, "remaining");
|
||||
await page.goto(hostUsageUrl);
|
||||
await expect(card.getByText("30% left")).toBeVisible();
|
||||
|
||||
@@ -2,6 +2,11 @@ import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { gotoWorkspace, waitForTabBar } from "../support/helpers/launcher";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
@@ -14,25 +19,29 @@ import {
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsage,
|
||||
closeUsageFromBackdrop,
|
||||
closeUsageOptions,
|
||||
expectNoUsageItem,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
expectSummaryInSidebar,
|
||||
expectUnpinnableRows,
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
openUsageScreenFromIcon,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
pinRow,
|
||||
scrollUsage,
|
||||
setSummaryInSidebar,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
usageItem,
|
||||
usageSheet,
|
||||
usageModal,
|
||||
installTallUsageSource,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
@@ -62,32 +71,106 @@ async function footerClip(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
|
||||
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: false }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
const startingUrl = page.url();
|
||||
await expectNoUsageItem(page);
|
||||
|
||||
await test.step("the icon opens a sheet without leaving the current screen", async () => {
|
||||
await openUsageFromIcon(page);
|
||||
await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-icon-sheet");
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await closeUsageFromBackdrop(page);
|
||||
});
|
||||
|
||||
await test.step("the summary opens the same sheet", async () => {
|
||||
await openUsageFromItem(page);
|
||||
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
||||
await closeUsageFromBackdrop(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("on desktop Usage opens as a dialog over the workspace and closes back to it", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
const repo = await createTempGitRepo("usage-modal-");
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
await page.setViewportSize(COMPACT);
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
await page.setViewportSize(WIDE);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await usageItem(page).click();
|
||||
const content = page.getByTestId("sidebar-usage-sheet-content");
|
||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
||||
await waitForSettledPosition(content);
|
||||
const bounds = (await content.boundingBox())!;
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
|
||||
await gotoWorkspace(page, project.workspaceId);
|
||||
const workspaceUrl = page.url();
|
||||
|
||||
await test.step("the summary opens a dialog over the workspace", async () => {
|
||||
await openUsageFromItem(page);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Refresh all", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(workspaceUrl);
|
||||
await qaScreenshot(page, "desktop-usage-dialog");
|
||||
});
|
||||
|
||||
await test.step("Close returns to the same workspace", async () => {
|
||||
await closeUsage(page);
|
||||
await expect(page).toHaveURL(workspaceUrl);
|
||||
await waitForTabBar(page);
|
||||
});
|
||||
|
||||
await test.step("the icon opens it again, and Escape closes it", async () => {
|
||||
await openUsageFromIcon(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(workspaceUrl);
|
||||
});
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const layouts = {
|
||||
desktop: { viewport: WIDE, showSidebar: async (_page: Page) => {} },
|
||||
compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
|
||||
};
|
||||
for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
|
||||
test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
try {
|
||||
await page.setViewportSize(viewport);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await showSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await openUsageFromItem(page);
|
||||
const content = usageModal(page);
|
||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
||||
await waitForSettledPosition(content);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-top`);
|
||||
await scrollUsage(page, 5_000);
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await qaScreenshot(page, `${size}-usage-scroll-bottom`);
|
||||
await scrollUsage(page, -5_000);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -116,8 +199,7 @@ test.describe("Usage item", () => {
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromItem(page);
|
||||
await qaScreenshot(page, "default-pins-card");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
@@ -142,17 +224,13 @@ test.describe("Usage item", () => {
|
||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-screen-header", {
|
||||
kind: "element",
|
||||
locator: page.getByTestId("page-title").locator(".."),
|
||||
});
|
||||
await qaScreenshot(page, "usage-card-refresh", {
|
||||
kind: "element",
|
||||
locator: screen.getByTestId("usage-report-claude:default"),
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await test.step("remaining flips the Usage item and the Usage modal", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
|
||||
@@ -164,23 +242,15 @@ test.describe("Usage item", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("on desktop the Usage item opens the Usage screen", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
|
||||
await closeUsage(page);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
await openUsageFromItem(page);
|
||||
const sheet = usageModal(page);
|
||||
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
|
||||
await expect(sheet.getByText("88% left")).toBeVisible();
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
|
||||
@@ -189,57 +259,51 @@ test.describe("Usage item", () => {
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, \d+% left( · .+)?$/,
|
||||
);
|
||||
// The sheet carries the same inline Settings row and title-row Refresh all.
|
||||
await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
// Settings opens a second sheet; closing it restores the Usage sheet underneath.
|
||||
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
|
||||
await openUsageOptions(page);
|
||||
await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "compact-settings-expanded");
|
||||
await sheet.getByTestId("usage-options-toggle").click();
|
||||
await closeUsageOptions(page);
|
||||
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await waitForSettledPosition(sheet);
|
||||
const sheetBox = (await sheet.boundingBox())!;
|
||||
// Settings adds a row; the sheet still reserves the top 20% as its backdrop.
|
||||
// The sheet leaves the screen above it as its backdrop.
|
||||
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
||||
await qaScreenshot(page, "compact-sheet");
|
||||
await qaScreenshot(page, "phase7-compact-sheet");
|
||||
// Tap the backdrop above the sheet.
|
||||
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await closeUsageFromBackdrop(page);
|
||||
await page.setViewportSize(WIDE);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await openUsageFromItem(page);
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "usage-settings-expanded");
|
||||
await page.getByTestId("usage-options-toggle").click();
|
||||
await closeUsageOptions(page);
|
||||
});
|
||||
|
||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
||||
const usageUrl = page.url();
|
||||
const workspaceUrl = page.url();
|
||||
await closeUsage(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const section = page.getByTestId("usage-card");
|
||||
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
|
||||
await page.goto(usageUrl);
|
||||
await page.goto(workspaceUrl);
|
||||
await openUsageFromItem(page);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
@@ -248,6 +312,7 @@ test.describe("Usage item", () => {
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectNoUsageItem(page);
|
||||
await page.reload();
|
||||
await openUsageFromIcon(page);
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoUsageItem(page);
|
||||
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
|
||||
@@ -255,6 +320,7 @@ test.describe("Usage item", () => {
|
||||
await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
|
||||
await qaScreenshot(page, "explicit-empty-pins-after-reload");
|
||||
await closeUsage(page);
|
||||
});
|
||||
|
||||
await test.step("Settings > Sidebar lists the Usage item", async () => {
|
||||
@@ -298,8 +364,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
|
||||
await expectNoUsageItem(page);
|
||||
await expectFooterSeparator(page, false);
|
||||
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromIcon(page);
|
||||
});
|
||||
|
||||
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
|
||||
@@ -313,13 +378,13 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
|
||||
const claude = screen.getByTestId("usage-report-claude:default");
|
||||
|
||||
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
|
||||
await openUsageScreenFromIcon(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectNoUsageItem(page);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await qaScreenshot(page, "usage-screen-summary-off");
|
||||
await qaScreenshot(page, "usage-summary-off");
|
||||
});
|
||||
|
||||
await test.step("on: the Usage item and the pins appear", async () => {
|
||||
@@ -328,7 +393,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "usage-screen-summary-on");
|
||||
await qaScreenshot(page, "usage-summary-on");
|
||||
});
|
||||
|
||||
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
|
||||
@@ -336,7 +401,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
|
||||
await expectNoUsageItem(page);
|
||||
await expectUnpinnableRows(claude);
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
@@ -364,8 +429,7 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
|
||||
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageFromItem(page);
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude").locator("svg path").first(),
|
||||
@@ -373,5 +437,5 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-codex").locator("svg path").first(),
|
||||
).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-usage-screen");
|
||||
await qaScreenshot(page, "released-host-usage-modal");
|
||||
});
|
||||
|
||||
@@ -203,6 +203,41 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a workspace usable when its setup config cannot be parsed", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-invalid-config-", {
|
||||
files: [{ path: "paseo.json", content: "{ invalid json\n" }],
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed",
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-invalid-config",
|
||||
});
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.error).toContain("Failed to parse paseo.json");
|
||||
expect(failedPayload.detail.commands).toEqual([]);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||
await expect(page.getByText(/Failed to parse paseo\.json at/)).toBeVisible();
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "paseo.json");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow: ../../../maestro/sidebar-scroll-retention.yaml
|
||||
@@ -13,6 +13,11 @@ bash packages/app/e2e/mobile/native-text-reflow/ios.sh
|
||||
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
|
||||
and resizes its content container from 500 to 240 and back to 500 points. It
|
||||
asserts the inner frame follows both widths and the same selection survives.
|
||||
It then recycles the view the way Fabric's pool does and remounts it 30 points
|
||||
wider and at the same size; the inner frame must still match the container.
|
||||
A zeroed frame on recycle comes back 30 or 0 points wide. This assertion checks
|
||||
the intermediate recycled geometry; it does not reproduce visible clipping in a
|
||||
mounted Fabric paragraph or exercise the subsequent `drawRect` call.
|
||||
It uses public UIView/UITextView operations, without mocking React Native.
|
||||
LLDB detaches after the probe. An assertion failure or evaluation error fails the
|
||||
shell command; the log distinguishes them.
|
||||
|
||||
@@ -2,10 +2,17 @@ import lldb
|
||||
|
||||
|
||||
def __lldb_init_module(debugger, _internal_dict):
|
||||
process = debugger.GetSelectedTarget().GetProcess()
|
||||
main_threads = [thread for thread in process if thread.GetQueueName() == "com.apple.main-thread"]
|
||||
if len(main_threads) != 1 or not process.SetSelectedThread(main_threads[0]):
|
||||
raise RuntimeError("Could not select the UIKit main thread")
|
||||
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
|
||||
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
|
||||
frame = process.GetSelectedThread().GetSelectedFrame()
|
||||
options = lldb.SBExpressionOptions()
|
||||
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
|
||||
is_main_thread = frame.EvaluateExpression("(BOOL)[NSThread isMainThread]", options)
|
||||
if is_main_thread.GetError().Fail() or is_main_thread.GetValueAsUnsigned() != 1:
|
||||
raise RuntimeError("Refusing to run UIKit probe outside the main thread")
|
||||
result = frame.EvaluateExpression(
|
||||
r'''({
|
||||
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
|
||||
@@ -39,14 +46,28 @@ def __lldb_init_module(debugger, _internal_dict):
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat wide = text.frame.size.width;
|
||||
auto keptSelection = text.selectedRange;
|
||||
// Fabric recycles the view: the container keeps its size in the pool,
|
||||
// and the next paragraph mounts it 30 points wider (a list-item paragraph
|
||||
// reused by a full-width one) or at the same size.
|
||||
[host performSelector:@selector(prepareForRecycle)];
|
||||
frame.size.width = 530;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat reusedWider = text.frame.size.width;
|
||||
[host performSelector:@selector(prepareForRecycle)];
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat reusedSame = text.frame.size.width;
|
||||
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
|
||||
selection.location == text.selectedRange.location &&
|
||||
selection.length == text.selectedRange.length &&
|
||||
reusedWider == 530 && reusedSame == 530 &&
|
||||
selection.location == keptSelection.location &&
|
||||
selection.length == keptSelection.length &&
|
||||
selection.location == 12 && selection.length == 9;
|
||||
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
|
||||
ok ? @"PASS" : @"FAIL", narrow, wide,
|
||||
(unsigned long)text.selectedRange.location,
|
||||
(unsigned long)text.selectedRange.length];
|
||||
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f recycled=%.0f,%.0f selection=%lu:%lu",
|
||||
ok ? @"PASS" : @"FAIL", narrow, wide, reusedWider, reusedSame,
|
||||
(unsigned long)keptSelection.location,
|
||||
(unsigned long)keptSelection.length];
|
||||
})''',
|
||||
options,
|
||||
)
|
||||
|
||||
@@ -15,4 +15,4 @@ xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
|
||||
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
|
||||
|
||||
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 recycled=530,530 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
|
||||
export const test = base.extend<{
|
||||
startup: { release(): Promise<void>; fail(): Promise<void> };
|
||||
promptRejection: void;
|
||||
creation: Awaited<ReturnType<typeof createCreationScenario>>;
|
||||
promptRetry: ReturnType<typeof createPromptRetryScenario>;
|
||||
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
|
||||
@@ -65,6 +66,37 @@ export default function contribute(server) {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
promptRejection: async ({ page }, provide) => {
|
||||
void page;
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const plugin = await copyPluginExample("lifecycle-logger");
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(plugin.directory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
return server.before("agent.create", ({ request }) => ({
|
||||
...request,
|
||||
config: {
|
||||
...request.config,
|
||||
model: "e2e-fast-stream",
|
||||
featureValues: { ...request.config.featureValues, mockPromptRejections: 1 },
|
||||
},
|
||||
}));
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
await provide();
|
||||
} finally {
|
||||
await client.removePlugin("lifecycle-logger");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await plugin.cleanup();
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
creation: async ({ page }, provide) => {
|
||||
const creation = await createCreationScenario(page);
|
||||
try {
|
||||
|
||||
@@ -98,7 +98,6 @@ async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, i
|
||||
settings: { modeId: "full-access", thinkingOptionId: "low" },
|
||||
title: "Codex worktree restore reproduction",
|
||||
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
|
||||
background: true,
|
||||
}),
|
||||
);
|
||||
agentId = agent.agentId;
|
||||
|
||||
@@ -52,6 +52,10 @@ export async function expectComposerFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
export async function expectComposerNotFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).not.toBeFocused();
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
@@ -59,6 +63,12 @@ export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
/** The Send button works on compact screens, where Enter inserts a newline. */
|
||||
export async function submitMessageWithButton(page: Page, text: string): Promise<void> {
|
||||
await fillComposerDraft(page, text);
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
@@ -26,38 +26,50 @@ export async function openAgent(page: Page, session: MockAgentSession): Promise<
|
||||
}
|
||||
|
||||
export async function reloadAgent(page: Page): Promise<void> {
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
// Reload must not reopen the card under a stationary pointer.
|
||||
await page.mouse.move(0, 0);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
/** Wide screens show the meter's details in a tooltip while the pointer is on it. */
|
||||
export async function hoverContextWindowMeter(page: Page): Promise<Locator> {
|
||||
await page.getByRole("img", { name: METER_NAME }).hover({ timeout: 30_000 });
|
||||
const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" });
|
||||
await expect(tooltip).toBeVisible();
|
||||
return tooltip;
|
||||
/** Wide screens show the meter's details in a hover card while the pointer is on it. */
|
||||
export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 });
|
||||
const card = contextWindowDetails(page);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toBeInViewport();
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
|
||||
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
|
||||
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
|
||||
const box = await card.boundingBox();
|
||||
if (!meter || !box) throw new Error("The meter and its hover card must be on screen.");
|
||||
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
|
||||
}
|
||||
|
||||
export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0);
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Compact screens open the meter's details in a sheet. */
|
||||
export async function pressContextWindowMeter(page: Page): Promise<Locator> {
|
||||
await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowSheet(page);
|
||||
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowDetails(page);
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||
const sheet = contextWindowSheet(page);
|
||||
const sheet = contextWindowDetails(page);
|
||||
await sheet.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export function contextWindowSheet(page: Page): Locator {
|
||||
/** The hover card on wide screens and the sheet on compact ones are both this dialog. */
|
||||
export function contextWindowDetails(page: Page): Locator {
|
||||
return page.getByRole("dialog", { name: "Context window" });
|
||||
}
|
||||
|
||||
@@ -122,8 +134,8 @@ export interface AgentUsageScript {
|
||||
stopReportingUsage(): void;
|
||||
/** The agent of each agent usage request, in order. */
|
||||
agentRequests(): string[];
|
||||
/** Each report Refresh, by report ID. */
|
||||
refreshedReports(): string[][];
|
||||
/** Each scoped agent Refresh, by agent ID. */
|
||||
refreshedAgents(): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise<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] : [])),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,7 +173,7 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
export async function qaScreenshot(page: Page, name: string): Promise<void> {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let the sheet and tooltip animations settle.
|
||||
// Let the sheet and hover card animations settle.
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
|
||||
@@ -182,6 +182,47 @@ export async function createCreationScenario(page: Page) {
|
||||
await this.expectWorkspaceReadyBeforeAgentCompletion();
|
||||
expect((await project.client.fetchAgents()).entries).toHaveLength(0);
|
||||
},
|
||||
async expectCreatedAgentError() {
|
||||
await requests.settled();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("assistant-message")
|
||||
.filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEditable();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
},
|
||||
async expectPromptBeforeError(prompt: string) {
|
||||
const messages = page
|
||||
.getByTestId("user-message")
|
||||
.or(page.getByTestId("assistant-message"))
|
||||
.filter({ visible: true });
|
||||
await expect(messages).toHaveCount(2);
|
||||
await expect(messages.nth(0)).toContainText(prompt);
|
||||
await expect(messages.nth(1)).toContainText("[System Error]");
|
||||
await expect(messages.nth(1)).toBeInViewport();
|
||||
},
|
||||
async reloadAgent() {
|
||||
await page.reload();
|
||||
await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
|
||||
await this.expectCreatedAgentError();
|
||||
},
|
||||
async expectAssistantReply() {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("assistant-message")
|
||||
.filter({ visible: true, hasNotText: "[System Error]" })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectStartupFailure() {
|
||||
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
import { en } from "@/i18n/resources/en";
|
||||
import { openPullRequestPanel } from "./workspace-tabs";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
|
||||
page: Page,
|
||||
state: "open" | "merged" | "closed" | "draft",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface SeedDaemonClient {
|
||||
worktreeSlug?: string;
|
||||
};
|
||||
title?: string;
|
||||
background?: boolean;
|
||||
}): Promise<{
|
||||
workspace: SeedWorkspaceDescriptor | null;
|
||||
error: string | null;
|
||||
@@ -223,6 +224,7 @@ export interface SeededWorkspace {
|
||||
export async function seedWorkspace(options: {
|
||||
repoPrefix: string;
|
||||
title?: string;
|
||||
background?: boolean;
|
||||
port?: number;
|
||||
/** Repo fixture options; only applies to git projects (the default). */
|
||||
repo?: Parameters<typeof createTempGitRepo>[1];
|
||||
@@ -238,6 +240,7 @@ export async function seedWorkspace(options: {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: project.path },
|
||||
title: options.title,
|
||||
background: options.background,
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||
|
||||
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
|
||||
* Settings together at the end.
|
||||
*/
|
||||
export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||
const boxes = (
|
||||
await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
)
|
||||
).map((box) => box!);
|
||||
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
|
||||
const boxes = await page
|
||||
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
return { testID: element.getAttribute("data-testid"), x, y, width, height };
|
||||
}),
|
||||
);
|
||||
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
|
||||
const [first] = boxes;
|
||||
for (const box of boxes) {
|
||||
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
|
||||
|
||||
@@ -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 () => {};
|
||||
}`,
|
||||
@@ -51,10 +58,6 @@ export default function contribute(server) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openUsage(page: Page) {
|
||||
await page.goto("/usage");
|
||||
}
|
||||
|
||||
export async function refreshLoginUsage(page: Page) {
|
||||
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
|
||||
/** Real usage-source plugin; its long report makes the Usage modal scroll. */
|
||||
export async function installTallUsageSource() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
@@ -141,13 +142,9 @@ export function usageItem(page: Page): Locator {
|
||||
return visible(page, "sidebar-usage");
|
||||
}
|
||||
|
||||
/** The compact usage sheet the Usage item opens. */
|
||||
export function usageSheet(page: Page): Locator {
|
||||
return visible(page, "usage-expanded");
|
||||
}
|
||||
|
||||
export async function expectOnUsageScreen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
/** The Usage modal's body: a dialog on wide layouts, a bottom sheet on compact ones. */
|
||||
export function usageModal(page: Page): Locator {
|
||||
return visible(page, "usage-modal-body");
|
||||
}
|
||||
|
||||
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
|
||||
@@ -188,17 +185,54 @@ export async function togglePin(scope: Locator, source: string, window: string)
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
/** The footer's Usage icon, which is there whether or not the Usage item is on. */
|
||||
export async function openUsageScreenFromIcon(page: Page): Promise<void> {
|
||||
/**
|
||||
* The footer's Usage icon, which is there whether or not the Usage item is on. On a phone the
|
||||
* sidebar drawer has to be open.
|
||||
*/
|
||||
export async function openUsageFromIcon(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
|
||||
await expectOnUsageScreen(page);
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** The footer's Usage item, its summary of pinned windows. */
|
||||
export async function openUsageFromItem(page: Page): Promise<void> {
|
||||
await usageItem(page).click();
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
|
||||
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||
const body = await usageModal(page).boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
if (!body || !viewport) throw new Error("Usage must be open before scrolling it.");
|
||||
// The middle of the part of the body on screen; the body itself runs past the viewport.
|
||||
const top = Math.max(body.y, 0);
|
||||
const bottom = Math.min(body.y + body.height, viewport.height);
|
||||
await page.mouse.move(body.x + body.width / 2, (top + bottom) / 2);
|
||||
await page.mouse.wheel(0, deltaY);
|
||||
}
|
||||
|
||||
/** The modal's own Close button. */
|
||||
export async function closeUsage(page: Page): Promise<void> {
|
||||
await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** A tap on the backdrop above the bottom sheet. */
|
||||
export async function closeUsageFromBackdrop(page: Page): Promise<void> {
|
||||
const sheet = usageModal(page);
|
||||
await waitForSettledPosition(sheet);
|
||||
const bounds = await visible(page, "usage-modal").boundingBox();
|
||||
if (!bounds) throw new Error("Usage must be open before closing it.");
|
||||
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
function summaryInSidebarSwitch(page: Page): Locator {
|
||||
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
|
||||
}
|
||||
|
||||
/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */
|
||||
/** Turns the sidebar Usage summary on or off from the Usage modal's Settings. */
|
||||
export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await summaryInSidebarSwitch(page).click();
|
||||
@@ -208,15 +242,39 @@ export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void
|
||||
export async function expectSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on });
|
||||
await closeUsageOptions(page);
|
||||
}
|
||||
|
||||
/** Expand the inline Settings row when its controls are folded. */
|
||||
/** Open the cog's settings popover or compact sheet. */
|
||||
export async function openUsageOptions(page: Page): Promise<void> {
|
||||
const toggle = visible(page, "usage-options-toggle");
|
||||
if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click();
|
||||
if (!(await visible(page, "usage-display-used").isVisible())) {
|
||||
await visible(page, "usage-options-menu").click();
|
||||
}
|
||||
await expect(visible(page, "usage-display-used")).toBeVisible();
|
||||
}
|
||||
|
||||
export function usageOptionsSurface(page: Page): Locator {
|
||||
return page
|
||||
.locator(
|
||||
'[data-testid="usage-options-surface"]:visible, [data-testid="usage-options-surface-content"]:visible',
|
||||
)
|
||||
.first();
|
||||
}
|
||||
|
||||
export async function expectUsageOptionsFitContent(page: Page): Promise<void> {
|
||||
await waitForSettledPosition(usageOptionsSurface(page));
|
||||
const surface = await usageOptionsSurface(page).boundingBox();
|
||||
const fields = await page.getByTestId("usage-options-fields").boundingBox();
|
||||
if (!surface || !fields) throw new Error("Settings must be visible before measuring its fit.");
|
||||
expect(surface.y + surface.height - (fields.y + fields.height)).toBeLessThanOrEqual(10);
|
||||
}
|
||||
|
||||
export async function closeUsageOptions(page: Page): Promise<void> {
|
||||
await waitForSettledPosition(usageOptionsSurface(page));
|
||||
await page.mouse.click(0, 0);
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
|
||||
await openUsageOptions(page);
|
||||
await visible(page, `usage-display-${displayAs}`).click();
|
||||
@@ -224,6 +282,7 @@ export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await closeUsageOptions(page);
|
||||
}
|
||||
|
||||
export async function refreshAllUsage(page: Page): Promise<void> {
|
||||
@@ -234,9 +293,3 @@ export async function refreshAllUsage(page: Page): Promise<void> {
|
||||
export async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
}
|
||||
|
||||
/** On a phone the Usage screen has a back header instead of the sidebar menu. */
|
||||
export async function leaveUsageScreen(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).first().click();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
# Start in a workspace with the sidebar closed. Both workspace IDs come from
|
||||
# the connected host: TOP_WORKSPACE is near the top; SCROLLED_WORKSPACE starts
|
||||
# below the viewport. The list must remain unchanged throughout the flow.
|
||||
- tapOn:
|
||||
id: menu-button
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
direction: UP
|
||||
timeout: 60000
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
direction: DOWN
|
||||
timeout: 60000
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- waitForAnimationToEnd
|
||||
- assertNotVisible:
|
||||
id: sidebar-project-workspace-list-scroll
|
||||
- swipe:
|
||||
start: "5%,50%"
|
||||
end: "90%,50%"
|
||||
duration: 300
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
|
||||
# Selecting the current workspace removes workspace switching as a cause.
|
||||
# Reopening by button removes horizontal movement as a cause.
|
||||
- tapOn:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- waitForAnimationToEnd
|
||||
- tapOn:
|
||||
id: menu-button
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
- tapOn:
|
||||
id: sidebar-close
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.3",
|
||||
"version": "0.11.2",
|
||||
"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");
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("react-native-unistyles", () => ({
|
||||
withUnistyles: (Component: React.ComponentType) => Component,
|
||||
}));
|
||||
|
||||
function userMessage(index: number): StreamItem {
|
||||
function userMessage(index: number): Extract<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id: `message-${index}`,
|
||||
@@ -55,6 +55,30 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
|
||||
};
|
||||
}
|
||||
|
||||
function MeasuredTestRow({ item }: { item: StreamItem }) {
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const load = React.useCallback(() => setLoaded(true), []);
|
||||
const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24;
|
||||
const measure = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node?.parentElement) return;
|
||||
Object.defineProperty(node.parentElement, "offsetHeight", {
|
||||
configurable: true,
|
||||
value: height,
|
||||
});
|
||||
},
|
||||
[height],
|
||||
);
|
||||
return (
|
||||
<div ref={measure}>
|
||||
<button type="button" onClick={load}>
|
||||
{loaded ? "Loaded" : "Load"}
|
||||
</button>
|
||||
{item.id}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("createWebStreamStrategy", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
@@ -155,6 +179,75 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
|
||||
});
|
||||
|
||||
it("retains a row's DOM and local state when recent history becomes virtualized", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const item = userMessage(0);
|
||||
const renderRow = (rowItem: StreamItem) => <MeasuredTestRow item={rowItem} />;
|
||||
const input: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
|
||||
boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
|
||||
renderers: {
|
||||
renderHistoryVirtualizedRow: renderRow,
|
||||
renderHistoryMountedRow: renderRow,
|
||||
renderLiveHeadRow: renderRow,
|
||||
renderLiveAuxiliary: () => null,
|
||||
},
|
||||
listEmptyComponent: null,
|
||||
viewportRef: React.createRef<StreamViewportHandle>(),
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
act(() => root?.render(strategy.render(input)));
|
||||
const row = container.querySelector('[data-history-row-id="message-0"]')!;
|
||||
act(() => row.querySelector("button")!.click());
|
||||
expect(row.querySelector("button")!.textContent).toBe("Loaded");
|
||||
const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!;
|
||||
Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 });
|
||||
act(() =>
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...input,
|
||||
segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] },
|
||||
boundary: { ...input.boundary, hasVirtualizedHistory: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row);
|
||||
expect(row.isConnected).toBe(true);
|
||||
expect(row.querySelector("button")!.textContent).toBe("Loaded");
|
||||
act(() =>
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...input,
|
||||
segments: {
|
||||
historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)],
|
||||
historyMounted: [userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: { ...input.boundary, hasVirtualizedHistory: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform,
|
||||
).toBe("translateY(120px)");
|
||||
expect(row.querySelector("button")!.textContent).toBe("Loaded");
|
||||
});
|
||||
|
||||
it("keeps the timeline width stable with an overlay scrollbar", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
container = document.createElement("div");
|
||||
|
||||
@@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
@@ -76,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = {
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
|
||||
|
||||
const streamRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -268,6 +271,23 @@ function isScrollContainerOverscrolledPastBottom(
|
||||
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
|
||||
}
|
||||
|
||||
// Position updates do not change a row's content. Keep renderer work separate
|
||||
// from virtual measurements while retaining the same component across lanes.
|
||||
const StreamRowContent = React.memo(function StreamRowContent({
|
||||
item,
|
||||
index,
|
||||
items,
|
||||
render,
|
||||
}: {
|
||||
revision?: StreamRenderInput["liveHeadRowRevision"];
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
items: StreamItem[];
|
||||
render: StreamRenderInput["renderers"]["renderHistoryVirtualizedRow"];
|
||||
}) {
|
||||
return render(item, index, items);
|
||||
});
|
||||
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
const {
|
||||
segments: inputSegments,
|
||||
@@ -425,6 +445,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
initialRect: {
|
||||
width: scrollContainerRef.current?.clientWidth ?? 0,
|
||||
height: scrollContainerRef.current?.clientHeight ?? 0,
|
||||
},
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
@@ -536,25 +560,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scheduler.schedule();
|
||||
});
|
||||
|
||||
// ResizeObserver runs before paint. Batch measurements and commit React's new
|
||||
// positions here so a resized image cannot overlap rows for an extra frame.
|
||||
// Use resizeItem exclusively; measureElement would install a second observer.
|
||||
const measureVirtualizedRows = useStableEvent(() => {
|
||||
let resized = false;
|
||||
for (const element of contentRef.current?.querySelectorAll<HTMLElement>(
|
||||
"[data-history-row-id][data-index]",
|
||||
) ?? []) {
|
||||
const index = Number(element.dataset.index);
|
||||
const height = measureVirtualElement(element, undefined, rowVirtualizer);
|
||||
if (rowVirtualizer.measurementsCache[index]?.size === height) continue;
|
||||
rowVirtualizer.resizeItem(index, height);
|
||||
resized = true;
|
||||
}
|
||||
return resized;
|
||||
});
|
||||
|
||||
// Measure the committed DOM rather than queued observer entries: flushSync can
|
||||
// also commit an image's pending intrinsic dimensions, invalidating those entries.
|
||||
const virtualRowObserver = useMemo(
|
||||
() =>
|
||||
new ResizeObserver((entries) => {
|
||||
new ResizeObserver(() => {
|
||||
if (!isActiveRef.current) return;
|
||||
flushSync(() => {
|
||||
for (const entry of entries) {
|
||||
const element = entry.target as HTMLElement;
|
||||
if (!element.isConnected) continue;
|
||||
rowVirtualizer.resizeItem(
|
||||
Number(element.dataset.index),
|
||||
measureVirtualElement(element, entry, rowVirtualizer),
|
||||
);
|
||||
}
|
||||
measureVirtualizedRows();
|
||||
});
|
||||
}),
|
||||
[rowVirtualizer],
|
||||
[measureVirtualizedRows],
|
||||
);
|
||||
const measureVirtualizedRowElement = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -731,8 +761,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Apply row positions and their scroll correction in the same paint, including
|
||||
// the ordinary-to-virtualized handoff and measurements of newly mounted rows.
|
||||
useLayoutEffect(() => {
|
||||
// Commit positions for every resized row before accepting reading geometry.
|
||||
// Child layout can change during this commit, ahead of ResizeObserver delivery.
|
||||
if (!isActive || measureVirtualizedRows()) return;
|
||||
reconcileReadingPosition();
|
||||
}, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]);
|
||||
}, [
|
||||
contentWidth,
|
||||
isActive,
|
||||
segments,
|
||||
virtualRows,
|
||||
measureVirtualizedRows,
|
||||
reconcileReadingPosition,
|
||||
]);
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
cancelPendingStickToBottom();
|
||||
@@ -915,6 +955,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
flushSync(() => {
|
||||
measureVirtualizedRows();
|
||||
});
|
||||
const nextGeometry = getObservedViewportGeometry(scrollContainer);
|
||||
if (pendingResumeGeometryCheckRef.current) {
|
||||
pendingResumeGeometryCheckRef.current = false;
|
||||
@@ -953,6 +996,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scheduleHistoryStartPrependSettle,
|
||||
scheduleStickToBottom,
|
||||
updateScrollMetrics,
|
||||
measureVirtualizedRows,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -1171,13 +1215,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const virtualRowsContainerStyle = useMemo((): CSSProperties => {
|
||||
return {
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
const virtualRowsContainerStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
height: virtualTotalSize,
|
||||
};
|
||||
}, [virtualTotalSize]);
|
||||
flexShrink: 0,
|
||||
}),
|
||||
[virtualTotalSize],
|
||||
);
|
||||
const renderVirtualRowStyle = useCallback(
|
||||
(start: number): CSSProperties => ({
|
||||
position: "absolute",
|
||||
@@ -1198,12 +1242,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||
<StreamRowContent
|
||||
item={item}
|
||||
index={index}
|
||||
items={segments.historyMounted}
|
||||
render={renderHistoryMountedRow}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}, [renderHistoryMountedRow, segments.historyMounted]);
|
||||
const liveHeadRows = useMemo(() => {
|
||||
void liveHeadRowRevision;
|
||||
return segments.liveHead.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
@@ -1211,7 +1259,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
{renderLiveHeadRow(item, index, segments.liveHead)}
|
||||
<StreamRowContent
|
||||
item={item}
|
||||
index={index}
|
||||
items={segments.liveHead}
|
||||
revision={liveHeadRowRevision}
|
||||
render={renderLiveHeadRow}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
|
||||
@@ -1241,7 +1295,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"
|
||||
@@ -1251,13 +1305,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
{shouldUseVirtualizer ? (
|
||||
<div style={virtualRowsContainerStyle}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
<div style={streamRowsHostStyle}>
|
||||
<div style={virtualRowsContainerStyle} />
|
||||
{[
|
||||
...virtualRows.map((virtualRow) => {
|
||||
const item = segments.historyVirtualized[virtualRow.index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
if (!item) return null;
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
@@ -1267,17 +1320,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
{renderHistoryVirtualizedRow(
|
||||
item,
|
||||
virtualRow.index,
|
||||
segments.historyVirtualized,
|
||||
)}
|
||||
<StreamRowContent
|
||||
item={item}
|
||||
index={virtualRow.index}
|
||||
items={segments.historyVirtualized}
|
||||
render={renderHistoryVirtualizedRow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{mountedRows}
|
||||
}),
|
||||
...mountedRows,
|
||||
]}
|
||||
</div>
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
|
||||
@@ -792,6 +792,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
detail={data.detail}
|
||||
cwd={context.cwd}
|
||||
metadata={data.metadata}
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
|
||||
@@ -863,7 +863,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
pathname === "/new" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/schedules" ||
|
||||
pathname === "/usage" ||
|
||||
routeHasKnownHost);
|
||||
|
||||
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||
@@ -896,7 +895,6 @@ function RootStack() {
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="schedules" />
|
||||
<Stack.Screen name="usage" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { UsageScreen } from "@/usage";
|
||||
|
||||
export default function UsageRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<UsageScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
? assistantFileLinkQueryKey({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
token: resolution.token,
|
||||
})
|
||||
: DISABLED_QUERY_KEY,
|
||||
[resolution, serverId, workspaceRoot],
|
||||
@@ -179,7 +179,7 @@ function openAssistantFileLink(input: {
|
||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||
serverId: capturedConfig.serverId,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||
token: capturedResolution.token,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
@@ -245,14 +245,9 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
|
||||
function assistantFileLinkQueryKey(input: {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
}): AssistantFileLinkQueryKey {
|
||||
return [
|
||||
"assistantFileLink",
|
||||
input.serverId ?? null,
|
||||
input.workspaceRoot ?? null,
|
||||
input.ambiguousQuery,
|
||||
];
|
||||
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
|
||||
}
|
||||
|
||||
async function dispatchResolvedLink(input: {
|
||||
|
||||
@@ -143,6 +143,52 @@ describe("assistant selection copy ranges", () => {
|
||||
expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond");
|
||||
});
|
||||
|
||||
// Chrome anchors a drag that starts in the gap below an image at the start of the
|
||||
// image's rendered internals: the background-image layer, ahead of the hidden `img`.
|
||||
// Only the text after it is highlighted.
|
||||
it("leaves out an image when the selection starts in the gap below it", () => {
|
||||
const transcript = mountTranscript([
|
||||
{
|
||||
messageId: "message-1",
|
||||
blocks: [
|
||||
"Before the image.",
|
||||
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><div><div style="background-image: url(blob:https://example.test/preview)"></div><img src="blob:https://example.test/preview" alt=""></div></div>',
|
||||
"After the image.",
|
||||
],
|
||||
},
|
||||
]);
|
||||
const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]');
|
||||
const range = document.createRange();
|
||||
range.setStart(frame.firstElementChild!.firstElementChild!, 0);
|
||||
range.setEnd(textNode(fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 2)), 9);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
expect(copiedMarkdown(selection)).toBe("After the");
|
||||
});
|
||||
|
||||
it("keeps a failed image when the selection starts in its error text", () => {
|
||||
const transcript = mountTranscript([
|
||||
{
|
||||
messageId: "message-1",
|
||||
blocks: [
|
||||
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><div>Image failed to load</div></div>',
|
||||
"After the image.",
|
||||
],
|
||||
},
|
||||
]);
|
||||
const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]');
|
||||
const selection = selectRange(
|
||||
frame.firstElementChild!,
|
||||
6,
|
||||
fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 1),
|
||||
9,
|
||||
);
|
||||
|
||||
expect(copiedMarkdown(selection)).toBe("\n\nAfter the");
|
||||
});
|
||||
|
||||
it("does not replace the browser clipboard for a selection reaching a second message", () => {
|
||||
const transcript = mountTranscript([
|
||||
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
|
||||
@@ -320,6 +366,32 @@ describe("assistant selection copy ranges", () => {
|
||||
).toBe("6. Second bullet text");
|
||||
});
|
||||
|
||||
it("keeps the next number when a drag crosses a code block nested in an item", () => {
|
||||
const message = mountFixture();
|
||||
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
|
||||
list.outerHTML = [
|
||||
'<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 +457,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 +491,19 @@ describe("assistant selection copy ranges", () => {
|
||||
* Turndown, so those cases need a fixture shaped like the real thing.
|
||||
*/
|
||||
function highlightedFixture(language: string | null): string {
|
||||
return [
|
||||
'<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 +514,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 +691,65 @@ describe("assistant selection copy inside highlighted code", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// A drag that starts or ends in the gap beside a block anchors the selection at the
|
||||
// edge of the neighbouring block. Nothing outside the code is highlighted or selected.
|
||||
it("copies code without a fence when the selection only touches the end of the block before it", () => {
|
||||
const message = mountHighlighted();
|
||||
const before = tokenText(message, "Before the block.");
|
||||
|
||||
const content = copyBetween(
|
||||
[before, before.length],
|
||||
[tokenText(message, " doThing();"), 14],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
|
||||
});
|
||||
|
||||
it("copies code without a fence when the selection only touches the start of the block after it", () => {
|
||||
const message = mountHighlighted();
|
||||
|
||||
const content = copyBetween(
|
||||
[tokenText(message, "const"), 0],
|
||||
[tokenText(message, "After the block."), 0],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
|
||||
});
|
||||
|
||||
it("copies code without a fence when the selection only touches the row before the block", () => {
|
||||
const transcript = mountTranscript([{ messageId: "message-1", blocks: ["Run this:", "code"] }]);
|
||||
const codeRow = transcript.querySelectorAll('[data-testid="assistant-message"]')[1]!;
|
||||
codeRow.innerHTML = highlightedCodeBlock("typescript");
|
||||
const before = tokenText(transcript, "Run this:");
|
||||
|
||||
const content = copyBetween(
|
||||
[before, before.length],
|
||||
[tokenText(transcript, " doThing();"), 14],
|
||||
);
|
||||
|
||||
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
|
||||
});
|
||||
|
||||
it("keeps an image selected beside the code in the copy", () => {
|
||||
const message = mountHighlighted();
|
||||
const image = document.createElement("div");
|
||||
image.setAttribute("data-paseo-markdown-tag", "p");
|
||||
// The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown.
|
||||
image.innerHTML =
|
||||
'<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,
|
||||
@@ -22,6 +24,7 @@ const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
|
||||
const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
|
||||
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
|
||||
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
|
||||
const IMAGE_FRAME_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
|
||||
|
||||
const turndown = new TurndownService({
|
||||
bulletListMarker: "-",
|
||||
@@ -53,9 +56,7 @@ turndown.addRule("compactListItem", {
|
||||
if (parent?.nodeName !== "OL") {
|
||||
return `${options.bulletListMarker} ${item}\n`;
|
||||
}
|
||||
const start = Number(parent.getAttribute("start") ?? 1);
|
||||
const index = Array.from(parent.children).indexOf(node);
|
||||
return `${start + index}. ${item}\n`;
|
||||
return `${node.getAttribute("value")}. ${item}\n`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,9 +67,9 @@ export function createAssistantSelectionClipboardContent(
|
||||
return null;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const parts = selectedMessageParts(range);
|
||||
if (!parts) {
|
||||
const range = startAfterImageFrame(selection.getRangeAt(0));
|
||||
const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range));
|
||||
if (!parts?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -93,6 +94,33 @@ export function createAssistantSelectionClipboardContent(
|
||||
return { ...content, html: flattenClipboardListMarkup(content.html) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag that starts in the gap below an image anchors at the start of the image's
|
||||
* rendered internals, ahead of the hidden `img`, while only the text after it is
|
||||
* highlighted. Pressing on the image itself opens it instead of starting a selection,
|
||||
* so a selection that starts inside an image, before any of its text, and ends past it
|
||||
* starts after the image. A failed image shows its error as text, and a selection
|
||||
* starting in that text keeps the image.
|
||||
*/
|
||||
function startAfterImageFrame(range: Range): Range {
|
||||
const start =
|
||||
range.startContainer instanceof Element
|
||||
? range.startContainer
|
||||
: range.startContainer.parentElement;
|
||||
const frame = start?.closest(IMAGE_FRAME_SELECTOR);
|
||||
if (!frame || frame.contains(range.endContainer)) {
|
||||
return range;
|
||||
}
|
||||
const insideFrame = range.cloneRange();
|
||||
insideFrame.setEnd(frame, frame.childNodes.length);
|
||||
if (insideFrame.toString()) {
|
||||
return range;
|
||||
}
|
||||
const afterFrame = range.cloneRange();
|
||||
afterFrame.setStartAfter(frame);
|
||||
return afterFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partly-selected code copies as the code itself, never through Turndown.
|
||||
*
|
||||
@@ -103,10 +131,12 @@ export function createAssistantSelectionClipboardContent(
|
||||
*
|
||||
* A selection contained inside code always copies as code, even when it contains every
|
||||
* character. A selection that crosses the code boundary stays on the Markdown path so
|
||||
* a complete block retains its fence.
|
||||
* a complete block retains its fence. Crossing means selecting content outside the code:
|
||||
* a drag that starts or ends in the gap beside a block anchors at the edge of the
|
||||
* neighbouring block, which selects nothing there.
|
||||
*/
|
||||
function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null {
|
||||
const region = closestCodeRegion(range.commonAncestorContainer, message);
|
||||
const region = selectedCodeRegion(range, message);
|
||||
if (!region) {
|
||||
return null;
|
||||
}
|
||||
@@ -130,6 +160,18 @@ function createPartialCodeContent(range: Range, message: Element): MarkdownClipb
|
||||
});
|
||||
}
|
||||
|
||||
function selectedCodeRegion(range: Range, message: Element): Element | null {
|
||||
const edgeRegions = [range.startContainer, range.endContainer].map((node) =>
|
||||
closestCodeRegion(node, message),
|
||||
);
|
||||
return (
|
||||
edgeRegions.find(
|
||||
// Measure against the whole block, so its own hover Copy button is not outside it.
|
||||
(edge) => edge && selectsNothingOutside(range, edge.closest(CODE_BLOCK_SELECTOR) ?? edge),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function closestCodeRegion(node: Node, message: Element): Element | null {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
const region = element?.closest(CODE_REGION_SELECTOR) ?? null;
|
||||
@@ -281,7 +323,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
return false;
|
||||
}
|
||||
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (tag === "p" || isTableStructure(tag)) {
|
||||
// An image has no part to select, so touching it selects all of it.
|
||||
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
@@ -384,6 +427,31 @@ function hasSelectedAllContents(range: Range, element: Element, includeIgnored =
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectsContent(range: Range): boolean {
|
||||
return hasMarkdownContent(range.cloneContents(), true);
|
||||
}
|
||||
|
||||
function selectsNothingOutside(range: Range, element: Element): boolean {
|
||||
const contents = document.createRange();
|
||||
contents.selectNodeContents(element);
|
||||
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, contents) < 0) {
|
||||
const before = range.cloneRange();
|
||||
before.setEnd(contents.startContainer, contents.startOffset);
|
||||
if (selectsContent(before)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, contents) > 0) {
|
||||
const after = range.cloneRange();
|
||||
after.setStart(contents.endContainer, contents.endOffset);
|
||||
if (selectsContent(after)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
|
||||
if (!includeIgnored) {
|
||||
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
||||
@@ -393,8 +461,9 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean)
|
||||
if (fragment.textContent) {
|
||||
return true;
|
||||
}
|
||||
const visibleVoidSelector = ["br", "hr"]
|
||||
const visibleVoidSelector = ["br", "hr", "img"]
|
||||
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
|
||||
.concat("img")
|
||||
.join(",");
|
||||
return Boolean(fragment.querySelector(visibleVoidSelector));
|
||||
}
|
||||
@@ -488,14 +557,19 @@ function restoreMarkdownElements(container: HTMLElement): void {
|
||||
continue;
|
||||
}
|
||||
const semanticElement = document.createElement(tagName);
|
||||
if (tagName !== "br") {
|
||||
if (tagName !== "br" && tagName !== "img") {
|
||||
semanticElement.append(...element.childNodes);
|
||||
}
|
||||
if (tagName === "img") {
|
||||
semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? "");
|
||||
semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? "");
|
||||
}
|
||||
if (tagName === "ol") {
|
||||
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
|
||||
if (start) {
|
||||
semanticElement.setAttribute("start", start);
|
||||
}
|
||||
numberOrderedListItems(semanticElement, Number(start ?? 1));
|
||||
}
|
||||
if (tagName === "pre") {
|
||||
const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE);
|
||||
@@ -528,6 +602,20 @@ function restoreMarkdownElements(container: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each item carries its number before Turndown re-parses the HTML. Until then every
|
||||
* child of the list is one item, including a partly selected item demoted to a `p`.
|
||||
* The parser splits a `p` that holds a code block into several siblings, so counting
|
||||
* siblings afterwards numbers the next item too high.
|
||||
*/
|
||||
function numberOrderedListItems(list: Element, start: number): void {
|
||||
Array.from(list.children).forEach((child, index) => {
|
||||
if (child.tagName === "LI") {
|
||||
child.setAttribute("value", String(start + index));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unwrapIncompleteTables(container: HTMLElement): void {
|
||||
for (const table of container.querySelectorAll("table")) {
|
||||
if (hasUsableTableColumnContract(table)) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getTurnDurationLabel } from "./assistant-turn-footer-label";
|
||||
|
||||
describe("getTurnDurationLabel", () => {
|
||||
it("says how long the turn worked", () => {
|
||||
expect(getTurnDurationLabel(14_000, i18n.t)).toBe("Worked for 14s");
|
||||
});
|
||||
|
||||
it("reads in the active app language", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
try {
|
||||
expect(getTurnDurationLabel(14_000, i18n.t)).toBe("A travaillé pendant 14s");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { formatDuration } from "@/utils/time";
|
||||
|
||||
export function getTurnDurationLabel(durationMs: number, t: TFunction): string {
|
||||
return t("message.turnFooter.workedFor", { duration: formatDuration(durationMs) });
|
||||
}
|
||||
@@ -7,26 +7,26 @@ import { formatTokenCount } from "./context-window-meter.utils";
|
||||
interface ContextWindowDetailsProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
percentage: number;
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
context: {
|
||||
percentage: number;
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
} | null;
|
||||
sessionCost: string | null;
|
||||
/** The tooltip titles itself; the sheet's header carries the title instead. */
|
||||
/** The hover card and tooltip title themselves; the sheet's header carries the title instead. */
|
||||
showTitle: boolean;
|
||||
/** Only the sheet can be pressed, so only its usage cards have a Refresh button. */
|
||||
/** Whether the usage cards have a Refresh button; without one they show their freshness. */
|
||||
refreshable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the context window meter opens: how full the window is, then the usage of the agent's
|
||||
* account. The wide tooltip and the compact sheet both render it.
|
||||
* account. The desktop hover card, the native wide tooltip, and the compact sheet all render it.
|
||||
*/
|
||||
export function ContextWindowDetails({
|
||||
serverId,
|
||||
agentId,
|
||||
percentage,
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
context,
|
||||
sessionCost,
|
||||
showTitle,
|
||||
refreshable,
|
||||
@@ -36,13 +36,21 @@ export function ContextWindowDetails({
|
||||
<>
|
||||
<View style={styles.summary}>
|
||||
{showTitle ? <Text style={styles.title}>{t("contextWindow.title")}</Text> : null}
|
||||
<Text style={styles.text}>{t("contextWindow.used", { percentage })}</Text>
|
||||
<Text style={styles.detail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(usedTokens),
|
||||
max: formatTokenCount(maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
{context ? (
|
||||
<>
|
||||
<Text style={styles.text}>
|
||||
{t("contextWindow.used", { percentage: context.percentage })}
|
||||
</Text>
|
||||
<Text style={styles.detail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(context.usedTokens),
|
||||
max: formatTokenCount(context.maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.detail}>{t("contextWindow.noData")}</Text>
|
||||
)}
|
||||
{sessionCost ? (
|
||||
<Text style={styles.detail}>{t("contextWindow.sessionCost", { cost: sessionCost })}</Text>
|
||||
) : null}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useHostReportsUsage } from "@/usage";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ContextWindowDetails } from "./context-window-details";
|
||||
import { ContextWindowSheet } from "./context-window-sheet";
|
||||
|
||||
@@ -16,19 +19,16 @@ interface ContextWindowMeterProps {
|
||||
usedTokens: number | null;
|
||||
totalCostUsd?: number | null;
|
||||
showPercentage?: boolean;
|
||||
/** Reserve the meter footprint and show a loading ring while usage is pending. */
|
||||
pending?: boolean;
|
||||
/** Optional glyph envelope for icon-toolbar alignment. */
|
||||
glyphSize?: number;
|
||||
}
|
||||
|
||||
const SVG_SIZE = 14;
|
||||
const USAGE_POPOVER_WIDTH = 300;
|
||||
const COMPACT_SVG_SIZE = 12;
|
||||
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
|
||||
const COMPACT_RADIUS = 5;
|
||||
const STROKE_WIDTH = 2;
|
||||
const COMPACT_STROKE_WIDTH = 1.75;
|
||||
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
|
||||
|
||||
function isValidMaxTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
@@ -59,28 +59,22 @@ function formatSessionCost(value: number): string | null {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function getMeterColors(
|
||||
percentage: number,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): { progress: string; track: string } {
|
||||
const track = theme.colors.surface3;
|
||||
function getProgressColor(percentage: number, theme: Theme): string {
|
||||
if (percentage > 90) {
|
||||
return { progress: theme.colors.destructive, track };
|
||||
return theme.colors.destructive;
|
||||
}
|
||||
if (percentage >= 70) {
|
||||
return { progress: theme.colors.palette.amber[500], track };
|
||||
return theme.colors.palette.amber[500];
|
||||
}
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
|
||||
if (showPercentage) {
|
||||
return {
|
||||
svgSize: COMPACT_SVG_SIZE,
|
||||
center: COMPACT_CENTER,
|
||||
radius: COMPACT_RADIUS,
|
||||
strokeWidth: COMPACT_STROKE_WIDTH,
|
||||
circumference: COMPACT_CIRCUMFERENCE,
|
||||
containerStyle: styles.containerWithLabel,
|
||||
};
|
||||
}
|
||||
@@ -88,14 +82,65 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
|
||||
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
|
||||
return {
|
||||
svgSize: resolvedSize,
|
||||
center: resolvedSize / 2,
|
||||
radius: (resolvedSize - resolvedStrokeWidth) / 2,
|
||||
strokeWidth: resolvedStrokeWidth,
|
||||
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
|
||||
containerStyle: styles.container,
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap the whole SVG: withUnistyles adds a div on web, which cannot sit inside an SVG.
|
||||
const ContextWindowRing = withUnistyles(function ContextWindowRing({
|
||||
size,
|
||||
radius,
|
||||
strokeWidth,
|
||||
percentage,
|
||||
trackColor,
|
||||
progressColor,
|
||||
}: {
|
||||
size: number;
|
||||
radius: number;
|
||||
strokeWidth: number;
|
||||
percentage: number | null;
|
||||
trackColor: string;
|
||||
progressColor: string;
|
||||
}) {
|
||||
const center = size / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={trackColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{percentage !== null ? (
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={progressColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference - (clampPercentage(percentage) / 100) * circumference}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
) : null}
|
||||
</Svg>
|
||||
);
|
||||
});
|
||||
|
||||
export function ContextWindowMeter({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -103,15 +148,13 @@ export function ContextWindowMeter({
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
showPercentage = false,
|
||||
pending = false,
|
||||
glyphSize,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
|
||||
const showsUsage = useHostReportsUsage(serverId);
|
||||
const popoverWidth = Math.min(360, width - 24);
|
||||
const popoverWidth = Math.min(USAGE_POPOVER_WIDTH, width - 24);
|
||||
// Compact screens open the details in a sheet, which can hold a pressable Refresh.
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
@@ -121,79 +164,39 @@ export function ContextWindowMeter({
|
||||
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
||||
const geometry = getMeterGeometry(showPercentage, glyphSize);
|
||||
|
||||
// No usage yet: reserve the footprint with a track-only ring while a session is
|
||||
// active so the real ring fades in without shifting siblings. Render nothing when
|
||||
// no usage is expected.
|
||||
if (percentage === null || maxTokens === null || usedTokens === null) {
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={geometry.containerStyle}>
|
||||
<Svg
|
||||
width={geometry.svgSize}
|
||||
height={geometry.svgSize}
|
||||
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={geometry.center}
|
||||
cy={geometry.center}
|
||||
r={geometry.radius}
|
||||
fill="none"
|
||||
stroke={theme.colors.surface3}
|
||||
strokeWidth={geometry.strokeWidth}
|
||||
/>
|
||||
</Svg>
|
||||
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
|
||||
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
const context = useMemo(
|
||||
() =>
|
||||
percentage !== null && maxTokens !== null && usedTokens !== null
|
||||
? { percentage: Math.round(percentage), maxTokens, usedTokens }
|
||||
: null,
|
||||
[percentage, maxTokens, usedTokens],
|
||||
);
|
||||
const meterColors = useCallback(
|
||||
(theme: Theme) => ({
|
||||
progressColor: getProgressColor(percentage ?? 0, theme),
|
||||
trackColor: theme.colors.surface3,
|
||||
}),
|
||||
[percentage],
|
||||
);
|
||||
const formattedSessionCost =
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
|
||||
const containerStyle = geometry.containerStyle;
|
||||
const ring = (
|
||||
<Svg
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
<ContextWindowRing
|
||||
size={geometry.svgSize}
|
||||
radius={geometry.radius}
|
||||
strokeWidth={geometry.strokeWidth}
|
||||
percentage={percentage}
|
||||
uniProps={meterColors}
|
||||
/>
|
||||
);
|
||||
const percentageLabel = showPercentage ? (
|
||||
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
|
||||
) : null;
|
||||
const accessibilityLabel = t("contextWindow.accessibility", { percentage: roundedPercentage });
|
||||
const percentageLabel =
|
||||
showPercentage && context ? (
|
||||
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
|
||||
) : null;
|
||||
const accessibilityLabel = context
|
||||
? t("contextWindow.accessibility", { percentage: context.percentage })
|
||||
: t("contextWindow.accessibilityNoData");
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
@@ -212,9 +215,7 @@ export function ContextWindowMeter({
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
percentage={roundedPercentage}
|
||||
usedTokens={usedTokens}
|
||||
maxTokens={maxTokens}
|
||||
context={context}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle={false}
|
||||
refreshable
|
||||
@@ -224,10 +225,51 @@ export function ContextWindowMeter({
|
||||
);
|
||||
}
|
||||
|
||||
const popoverStyle = showsUsage
|
||||
? [styles.usagePopover, { width: popoverWidth }]
|
||||
: styles.plainPopover;
|
||||
|
||||
// Native wide screens have no hover, so the details open in a tooltip on press. The tooltip
|
||||
// takes no presses, so its usage cards have no Refresh.
|
||||
if (isNative) {
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
{ring}
|
||||
{percentageLabel}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
offset={8}
|
||||
maxWidth={showsUsage ? popoverWidth : undefined}
|
||||
style={popoverStyle}
|
||||
testID="context-window-meter-tooltip"
|
||||
>
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
context={context}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle
|
||||
refreshable={false}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
<HoverCard>
|
||||
<HoverCardTrigger focusable accessibilityLabel={accessibilityLabel}>
|
||||
<View
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="image"
|
||||
@@ -235,28 +277,26 @@ export function ContextWindowMeter({
|
||||
>
|
||||
{ring}
|
||||
{percentageLabel}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
</View>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
placement="top"
|
||||
offset={8}
|
||||
maxWidth={showsUsage ? popoverWidth : undefined}
|
||||
style={showsUsage ? [styles.popover, { width: popoverWidth }] : undefined}
|
||||
testID="context-window-meter-tooltip"
|
||||
role="dialog"
|
||||
accessibilityLabel={t("contextWindow.title")}
|
||||
testID="context-window-details"
|
||||
style={popoverStyle}
|
||||
>
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
percentage={roundedPercentage}
|
||||
usedTokens={usedTokens}
|
||||
maxTokens={maxTokens}
|
||||
context={context}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle
|
||||
refreshable={false}
|
||||
refreshable
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,11 +321,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
skeletonLabel: {
|
||||
width: 22,
|
||||
height: theme.fontSize.base,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
popover: { padding: theme.spacing[4], gap: theme.spacing[4] },
|
||||
// Plain details use a small inset; account usage cards have their own content density.
|
||||
plainPopover: { paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2] },
|
||||
usagePopover: { padding: theme.spacing[3], gap: theme.spacing[3] },
|
||||
}));
|
||||
|
||||
@@ -26,6 +26,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -52,7 +53,12 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "@/usage";
|
||||
import {
|
||||
UsageSidebarItem,
|
||||
UsageSidebarRoot,
|
||||
useHasUsageSummary,
|
||||
useOpenSidebarUsage,
|
||||
} from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
@@ -275,7 +281,7 @@ function FooterIconButton({
|
||||
testID,
|
||||
label,
|
||||
icon: Icon,
|
||||
iconSize,
|
||||
iconSizeAdjustment = 0,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
@@ -284,17 +290,20 @@ function FooterIconButton({
|
||||
label: string;
|
||||
icon: typeof FolderPlus;
|
||||
/** Only for a glyph that reads larger than the others at the same size. */
|
||||
iconSize?: number;
|
||||
iconSizeAdjustment?: number;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
buttonRef?: RefObject<View | null>;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton}
|
||||
style={styles.footerIconButton(isCompact)}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
@@ -305,7 +314,7 @@ function FooterIconButton({
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize ?? theme.iconSize.md}
|
||||
size={iconSize + iconSizeAdjustment}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
@@ -367,7 +376,7 @@ function SidebarHostPicker({
|
||||
label={label}
|
||||
icon={Server}
|
||||
// Server's two boxes fill more of the square than the other glyphs.
|
||||
iconSize={theme.iconSize.md - 1}
|
||||
iconSizeAdjustment={-1}
|
||||
theme={theme}
|
||||
/>
|
||||
</HostPicker>
|
||||
@@ -414,46 +423,54 @@ function SidebarFooter({
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
const openUsageScreen = useOpenUsageScreen();
|
||||
|
||||
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
|
||||
return (
|
||||
<View style={styles.footerContainer} testID="sidebar-footer">
|
||||
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
|
||||
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={openUsageScreen}
|
||||
testID="sidebar-usage-icon"
|
||||
label={labels.usage}
|
||||
icon={CircleGauge}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<View style={styles.footerSpacer} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<UsageSidebarRoot>
|
||||
<View style={styles.footerContainer} testID="sidebar-footer">
|
||||
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
|
||||
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarUsageIcon label={labels.usage} theme={theme} />
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<View style={styles.footerSpacer} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</UsageSidebarRoot>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
return (
|
||||
<FooterIconButton
|
||||
onPress={openUsage}
|
||||
testID="sidebar-usage-icon"
|
||||
label={label}
|
||||
icon={CircleGauge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -923,7 +940,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
// The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
// Buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -940,14 +957,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
gap: 2,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
footerIconButton: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
}),
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { TaskListRow } from "@/components/task-list-row";
|
||||
@@ -74,6 +75,7 @@ import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
|
||||
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import { getTurnDurationLabel } from "./assistant-turn-footer-label";
|
||||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
|
||||
@@ -108,6 +110,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import {
|
||||
markdownCopyDataSet,
|
||||
markdownCopyImageDataSet,
|
||||
markdownCopyOrderedListDataSet,
|
||||
markdownCopyTableCellDataSet,
|
||||
type MarkdownCopyInlineTag,
|
||||
@@ -628,6 +631,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
durationMs,
|
||||
onFork,
|
||||
}: AssistantTurnFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [pressedReveal, setPressedReveal] = useState(false);
|
||||
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -643,10 +647,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
const durationLabel = useMemo(
|
||||
() =>
|
||||
durationMs !== undefined && durationMs !== null
|
||||
? `Worked for ${formatDuration(durationMs)}`
|
||||
: "",
|
||||
[durationMs],
|
||||
durationMs !== undefined && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "",
|
||||
[durationMs, t],
|
||||
);
|
||||
const timestampLabel = useMemo(
|
||||
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
|
||||
@@ -884,10 +886,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 +898,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"
|
||||
@@ -3020,6 +3023,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
}
|
||||
|
||||
interface ToolCallProps {
|
||||
agentMessage?: AgentMessage;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
@@ -3039,6 +3043,7 @@ interface ToolCallProps {
|
||||
}
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
agentMessage,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
@@ -3079,6 +3084,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
buildToolCallPresentation({
|
||||
agentMessage,
|
||||
toolName,
|
||||
status,
|
||||
error: error ?? null,
|
||||
@@ -3087,7 +3093,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
cwd,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd],
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
|
||||
);
|
||||
const handleOpenFile = useMemo(() => {
|
||||
const openFilePath = presentation.openFilePath;
|
||||
@@ -3103,7 +3109,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
toolName,
|
||||
displayName: presentation.displayName,
|
||||
summary: presentation.summary,
|
||||
detail: effectiveDetail,
|
||||
detail: presentation.detail,
|
||||
errorText: presentation.errorText,
|
||||
icon: presentation.icon,
|
||||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||||
@@ -3115,12 +3121,12 @@ export const ToolCall = memo(function ToolCall({
|
||||
shouldRenderInline,
|
||||
openToolCall,
|
||||
toolName,
|
||||
presentation.detail,
|
||||
presentation.displayName,
|
||||
presentation.summary,
|
||||
presentation.errorText,
|
||||
presentation.icon,
|
||||
presentation.isLoadingDetails,
|
||||
effectiveDetail,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3156,7 +3162,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
toolName={toolName}
|
||||
detail={effectiveDetail}
|
||||
detail={presentation.detail}
|
||||
errorText={presentation.errorText}
|
||||
maxHeight={maxDetailHeight}
|
||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||
@@ -3165,7 +3171,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
toolName,
|
||||
effectiveDetail,
|
||||
presentation.detail,
|
||||
presentation.errorText,
|
||||
presentation.isLoadingDetails,
|
||||
maxDetailHeight,
|
||||
|
||||
@@ -63,11 +63,7 @@ import {
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import {
|
||||
hasActiveSidebarLabelFilter,
|
||||
useSidebarViewStore,
|
||||
type SidebarGroupMode,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -1919,9 +1915,6 @@ export function SidebarWorkspaceList({
|
||||
const onToggleWorkspacePin = useSidebarWorkspacePinController();
|
||||
const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder);
|
||||
const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder);
|
||||
const hasActiveLabelFilter = useSidebarViewStore((state) =>
|
||||
hasActiveSidebarLabelFilter(state.labelFilter),
|
||||
);
|
||||
const handlePinnedWorkspaceReorder = useCallback(
|
||||
(reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
||||
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
||||
@@ -1953,11 +1946,10 @@ export function SidebarWorkspaceList({
|
||||
// this whole subtree, which unmounted the header — and the header is where the display menu's
|
||||
// trigger lives, so filtering the last row away closed the menu you were filtering from.
|
||||
//
|
||||
// Only the label filter can get here. The project filter resolves against the projects it can
|
||||
// see and falls back to "all projects" when nothing matches, so it either keeps at least one
|
||||
// project or is not applied at all — it can narrow this list but never empty it.
|
||||
const sidebarFilterEmpty =
|
||||
hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0;
|
||||
// Background visibility can also hide the last project. Keep the header mounted
|
||||
// whenever a local filter empties an otherwise populated directory, so users can
|
||||
// reach the display menu to reveal their work again.
|
||||
const sidebarFilterEmpty = hasProjectsBeforeFilter && projects.length === 0;
|
||||
|
||||
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
|
||||
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
|
||||
|
||||
@@ -301,6 +301,13 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
sheetTitle={t("sidebar.display.heading")}
|
||||
testID="sidebar-display-preferences-content"
|
||||
>
|
||||
<MenuItem
|
||||
selected={preferences.showBackground}
|
||||
closeOnSelect={false}
|
||||
onSelect={preferences.toggleBackground}
|
||||
>
|
||||
{t("sidebar.display.showBackground")}
|
||||
</MenuItem>
|
||||
<MenuSubTrigger
|
||||
id="grouping"
|
||||
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems }
|
||||
export type SidebarTrailingChoice = Exclude<SidebarWorkspaceTrailing, "none">;
|
||||
|
||||
export interface SidebarDisplayPreferences {
|
||||
showBackground: boolean;
|
||||
toggleBackground: () => void;
|
||||
grouping: SidebarGroupMode;
|
||||
setGrouping: (mode: SidebarGroupMode) => void;
|
||||
titleSource: WorkspaceTitleSource;
|
||||
@@ -48,6 +50,8 @@ export interface SidebarDisplayPreferences {
|
||||
* this for a value and set it; where it lands is this module's problem.
|
||||
*/
|
||||
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
const showBackground = useSidebarViewStore((state) => state.showBackground);
|
||||
const toggleBackground = useSidebarViewStore((state) => state.toggleBackground);
|
||||
const grouping = useSidebarViewStore((state) => state.groupMode);
|
||||
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
@@ -104,6 +108,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
showBackground,
|
||||
toggleBackground,
|
||||
grouping,
|
||||
setGrouping,
|
||||
titleSource: workspaceTitleSource,
|
||||
@@ -125,6 +131,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
clearLabelFilter,
|
||||
}),
|
||||
[
|
||||
showBackground,
|
||||
toggleBackground,
|
||||
grouping,
|
||||
setGrouping,
|
||||
workspaceTitleSource,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ActivityIndicator, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
||||
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import {
|
||||
@@ -170,6 +171,7 @@ function ProjectStatusBadge({
|
||||
statusBucket: SidebarStateBucket;
|
||||
backdrop: SidebarSurfaceBackdrop;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
|
||||
// so nesting it inside would clip it against the very thing that was meant to separate it from
|
||||
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
|
||||
@@ -177,7 +179,7 @@ function ProjectStatusBadge({
|
||||
return (
|
||||
<View
|
||||
role="status"
|
||||
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
|
||||
accessibilityLabel={getStatusBucketLabel(statusBucket, t)}
|
||||
style={styles.statusRingAnchor}
|
||||
testID="project-status-badge"
|
||||
>
|
||||
@@ -188,7 +190,7 @@ function ProjectStatusBadge({
|
||||
return (
|
||||
<View
|
||||
role="status"
|
||||
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
|
||||
accessibilityLabel={getStatusBucketLabel(statusBucket, t)}
|
||||
style={[styles.statusBadge, getStatusBadgeBackdropStyle(backdrop)]}
|
||||
testID="project-status-badge"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
@@ -77,6 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
@@ -103,14 +106,14 @@ export function SidebarHelpMenu() {
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
style={styles.trigger}
|
||||
style={styles.trigger(isCompact)}
|
||||
testID="sidebar-help"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("sidebar.help.trigger")}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<ThemedCircleHelp
|
||||
size={ICON_SIZE.md}
|
||||
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
@@ -181,14 +184,14 @@ export function SidebarHelpMenu() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
trigger: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
}),
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
useSidebarWorkspacesList,
|
||||
@@ -54,7 +55,9 @@ export function SidebarModelProvider({
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const list = useSidebarWorkspacesList({ enabled: active });
|
||||
const showBackground = useSidebarViewStore((state) => state.showBackground);
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
||||
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
|
||||
@@ -105,9 +108,12 @@ export function SidebarModelProvider({
|
||||
workspaces: [...workspaceEntriesByKey.values()],
|
||||
projectFilters: resolvedProjectFilters,
|
||||
});
|
||||
const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter });
|
||||
const filtered = filterWorkspacesByLabels({
|
||||
workspaces: byProject.filter((workspace) => showBackground || !workspace.background),
|
||||
...labelFilter,
|
||||
});
|
||||
return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace]));
|
||||
}, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
|
||||
}, [showBackground, labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
|
||||
const visibleWorkspaceKeys = useMemo(
|
||||
() => new Set(filteredWorkspaceEntriesByKey.keys()),
|
||||
[filteredWorkspaceEntriesByKey],
|
||||
@@ -122,16 +128,19 @@ export function SidebarModelProvider({
|
||||
const included = new Set(resolvedProjectFilters);
|
||||
projects = projects.filter((project) => included.has(project.viewKey));
|
||||
}
|
||||
if (hasActiveLabelFilter) {
|
||||
if (hasActiveLabelFilter || !showBackground) {
|
||||
projects = projects.flatMap((project) => {
|
||||
const workspaces = project.workspaces.filter((workspace) =>
|
||||
visibleWorkspaceKeys.has(workspace.workspaceKey),
|
||||
);
|
||||
return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
|
||||
return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0)
|
||||
? [{ ...project, workspaces }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
return projects;
|
||||
}, [
|
||||
showBackground,
|
||||
hasActiveLabelFilter,
|
||||
hasActiveProjectFilter,
|
||||
resolvedProjectFilters,
|
||||
@@ -150,6 +159,7 @@ export function SidebarModelProvider({
|
||||
pinnedCollapsed,
|
||||
collapsedProjectKeys,
|
||||
collapsedWorkspaceGroupKeys,
|
||||
t,
|
||||
}),
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
@@ -161,6 +171,7 @@ export function SidebarModelProvider({
|
||||
pinnedKeys,
|
||||
pinnedWorkspaceOrder,
|
||||
filteredWorkspaceEntriesByKey,
|
||||
t,
|
||||
],
|
||||
);
|
||||
const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SidebarSection } from "@/sidebar-nav/model";
|
||||
|
||||
/**
|
||||
* The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on
|
||||
* compact ones. Plugin items and the built-in Usage item open it the same way:
|
||||
* compact ones. Plugin items open it this way:
|
||||
* `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to,
|
||||
* and `SidebarPopoverSurface` for the body.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
@@ -85,6 +86,7 @@ function projectionInput(options?: {
|
||||
pinnedCollapsed: options?.pinnedCollapsed ?? false,
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
collapsedWorkspaceGroupKeys: new Set<string>(),
|
||||
t: i18n.t,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
import {
|
||||
splitPinnedSidebarGroups,
|
||||
@@ -45,6 +46,7 @@ export interface SidebarProjectionInput {
|
||||
pinnedCollapsed: boolean;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
collapsedWorkspaceGroupKeys: ReadonlySet<string>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection {
|
||||
@@ -100,7 +102,7 @@ function buildWorkspaceGroups(
|
||||
return [];
|
||||
case "status":
|
||||
return statusWorkspaceGroups(
|
||||
buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey),
|
||||
buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey, input.t),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +408,7 @@ function StatusGroupHeader({
|
||||
group: SidebarWorkspaceGroup;
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const toggleWorkspaceGroupCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.toggleWorkspaceGroupCollapsed,
|
||||
@@ -431,7 +432,7 @@ function StatusGroupHeader({
|
||||
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
|
||||
<Pressable
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel={`${group.label} group`}
|
||||
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
|
||||
accessibilityState={accessibilityState}
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
|
||||
@@ -380,6 +380,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
serviceLabel: serviceSummary
|
||||
? t(workspaceServiceLabelKey(serviceSummary), { name: serviceSummary.name })
|
||||
: null,
|
||||
t,
|
||||
});
|
||||
const workspaceTarget = useMemo<WorkspaceLabelTarget>(
|
||||
() => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveSidebarWorkspaceAccessibilityLabel,
|
||||
@@ -39,6 +40,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
|
||||
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
|
||||
workspaceTitleSource: "title",
|
||||
hostBadgeLabel: "Build host",
|
||||
t: i18n.t,
|
||||
});
|
||||
|
||||
expect(label).toBe("Investigate search, Build host");
|
||||
@@ -56,6 +58,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
|
||||
hostBadgeLabel: "Build host",
|
||||
pullRequestLabel: "Pull request 42",
|
||||
serviceLabel: "Service web running",
|
||||
t: i18n.t,
|
||||
});
|
||||
|
||||
expect(label).toBe(
|
||||
@@ -69,6 +72,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
|
||||
workspaceTitleSource: "title",
|
||||
leadingProjectName: "Search project",
|
||||
hostBadgeLabel: "Build host",
|
||||
t: i18n.t,
|
||||
});
|
||||
|
||||
expect(label).toBe("Search project, Investigate search, Build host");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user