Compare commits

..
Author SHA1 Message Date
Mohamed Boudra b4af508e2a chore(release): cut 0.10.3 2026-10-02 14:10:43 +02:00
Mohamed Boudra d75fa81e93 docs(changelog): add 0.10.3 release notes 2026-10-02 13:45:36 +02:00
Mohamed Boudra 21cfd75222 Keep native dialogs and sheets usable when stacked (#5772)
* fix(app): stack adaptive sheets across native presentations

Root-owned dialogs could not present over a sibling native dialog on iOS. Use the existing app-wide sheet stack for both native presentation forms, and release the underlying keyboard when a new sheet opens.

* test(app): verify pairing sheet autofocus

* docs(app): clarify stacked sheet journey prerequisites

(cherry picked from commit 318a84a570)
2026-10-02 13:43:39 +02:00
Mohamed Boudra 3834773414 Add confirmation for new hosts (#5753)
* Add confirmation for new hosts

Opening a pairing link (an `#offer=` URL, a scanned QR code, or a pasted
link) now asks the user to confirm before the host is added or connected.
The prompt shows the host id, its key fingerprint, and the relay. Cancel
leaves no new host and no connection.

The host runtime owns the check: `upsertConnectionFromOfferUrl` and
`probeAndUpsertConnectionFromOfferUrl` ask a registered confirmer before
doing anything. `HostTrustPromptModal`, mounted at the app root, registers
the confirmer and renders the prompt with the shared confirm sheet. Without
a registered confirmer, pairing links are declined.

Adds a test that an offer link with the confirmation declined adds no host
and attempts no connection.

* Only ask for new hosts or changed connections

A pairing link now connects without the confirmation when it matches a
saved host exactly (same server id, key and relay). It still asks for a new
host, and for a saved host whose key or relay differs from what is saved,
with the "This link changes how you connect to this host" text.

After the user approves a link that then needs a password, the password
retry no longer asks again. The approval is an `OfferApproval` owned by one
pairing flow (one QR scan, or one open pair-link or add-host modal) and
only covers the exact offer that was approved.

Adds tests for a matching saved host, a changed key or relay, and the
password retry.

* Move host confirmation into the host runtime

The host runtime now owns the pending host confirmation and the decision to
ask. The sheet reads the pending confirmation and sends the answer; nothing
registers into a module variable.

A pairing flow is a LinkPairing the runtime hands out, so the password retry
for a confirmed link doesn't ask again without views creating or resetting
approval objects. Cancel is a result instead of an error. Pairing waits for
saved hosts to load before deciding whether to ask.

* Stop QR scanning after an attempt ends

The scan screen now keeps its workflow in a small model. When the user
cancels the host confirmation, closes the password prompt, or the attempt
fails, scanning stops until they tap Scan QR code again. Before, the code
still in front of the camera started a new attempt on the next frame.

A host confirmation answer is now tied to the request it answers, so a tap
on one sheet never answers a newer one. Relay hosts can no longer be added
through HostMutations without the confirmation, the add-host modal drops
the reset its per-open mount made redundant, and the fingerprint comment
says what the shortened form shows.

(cherry picked from commit 364e6456e0)
2026-10-02 13:39:09 +02:00
Mohamed Boudra 919c737c19 chore(release): cut 0.10.2 2026-09-30 01:13:57 +02:00
Mohamed Boudra c5b4b7bae3 docs(changelog): add 0.10.2 release notes 2026-09-30 01:09:50 +02:00
paseo-bot[bot] 81fc6134a9 fix(opencode): fill the context meter during v2 turns (#5710)
OpenCode v2 sessions never reported context used or the model's context
limit, so the composer meter was an empty ring during a turn and vanished
when it ended. Each finished step now reports its context against the
selected model's limit.

Closes #5701

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Matt Cowger <matt@cowger.us>
(cherry picked from commit d3c902e9fa)
2026-09-30 00:33:59 +02:00
paseo-bot[bot] 9744da6e4f fix(opencode): show v2 patch edits as edit diffs (#5696)
OpenCode 2 reports GPT models' apply_patch tool calls under the name
patch. The OpenCode tool-call parser only recognized apply_patch, so
these calls fell through to an unknown tool card with the raw patch
text. Map patch payloads the same way as apply_patch.

Closes #5643

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit 5764863f63)
2026-09-30 00:33:59 +02:00
paseo-bot[bot] 86df0587ba fix(opencode): show the question text and typed answer in v2 question cards (#5679)
OpenCode v2 sends each question as a form field whose title is the short
header, whose description is the question, and whose custom flag allows a
typed answer. The mapper used the field key as the header and the title as
the question, and dropped custom, so the card showed only the header and
had no answer field.

Closes #5667

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit 2838922ade)
2026-09-30 00:33:59 +02:00
Mohamed Boudra e26eb483e2 fix(opencode): keep long v2 turns alive until execution completes (#5674)
The long-lived session wait hit Node’s headers timeout while OpenCode kept working. Reconcile execution events against active state and the durable log so transport observation failures do not fail healthy turns, including across event gaps and reconnects.

(cherry picked from commit be2fb1cab9)
2026-09-30 00:33:59 +02:00
paseo-bot[bot] ee89f4917f fix(opencode): keep v2 edit text in completed edit details (#5609)
OpenCode v2's edit tool takes `path` instead of `filePath`, so the
OpenCode edit schema missed it and the shared fallback dropped the
camelCase oldString/newString. Completed edits reached the timeline as
{type: "edit", filePath} with nothing to diff.

Closes #5607

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit d91171954b)
2026-09-30 00:33:59 +02:00
Mohamed Boudra c5236c00d8 chore(release): cut 0.10.1 2026-09-29 00:06:23 +02:00
Mohamed Boudra d1443064db docs(changelog): add 0.10.1 release notes 2026-09-29 00:03:24 +02:00
Mohamed Boudra 9f387873b0 fix(opencode): clear unsupported thinking when changing models (#5587)
Resolve retained variants against the destination model before applying a model change. Publish the effective thinking choice and refresh the provider resume handle immediately so a reload cannot restore the old selection.

(cherry picked from commit 940dbfd242)
2026-09-28 23:37:09 +02:00
Qi 12cd5345d2 feat(claude): add Sonnet 5.5 with its model capabilities (#5583)
Claude Code 2.1.284 introduces Sonnet 5.5 with a native 1M context
window, medium default effort, and adaptive thinking that cannot be
turned off, so it takes the same shape as Opus 5.5 in the manifest.

Sonnet 5.5 rejects `thinking: {"type": "disabled"}` with a 400, so its
entry omits supportsThinkingDisabled and its thinking options have no
off. It also takes the Sonnet family tagline, which moves Sonnet 5 to
Previous release.

The catalog gates the entry on Claude Code 2.1.284, the version that
recognizes the model, so older installs keep their current catalog.
The existing major-minor runtime matching already resolves dashed
gateway IDs such as us.anthropic.claude-sonnet-5-5-20260928-v1:0 to
the new entry without collapsing them onto Sonnet 5.

(cherry picked from commit 4340607093)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 2c6d4e5283 fix(app): keep line breaks in streamed replies when a chunk ends in indentation (#5577)
While a reply streams, presentation reparses only the growing last Markdown
block: the cached block text plus the new chunk. splitMarkdownBlocks drops a
trailing whitespace-only line, and only trailing "\n"s were added back, so a
reply that paused on "]\n   " cached the block as "]". The next chunk " B -->"
then continued the previous line, joining two lines of a code block. Codex
streams a line break and the next line's indent as separate deltas, so its
Mermaid diagrams hit this and failed to render. History reparses the whole
text, which is why a reload showed the reply correctly.

Keep every trailing blank line the split drops on the last block.

Closes #5456

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit f4ba16a0b3)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 006bae2897 Archive a custom Codex provider's session when its agent is archived (#5572)
A provider that extends Codex runs through the registry's client wrapper,
which forwarded session listing and import but not native archive or
unarchive. Archiving such an agent left its Codex thread unarchived, so
the session stayed in Import session and could be imported again. The
wrapper now forwards both, the same as stock Codex.

Closes #5454

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit 9d010211a2)
2026-09-28 23:36:50 +02:00
Mohamed Boudra 437aef1a7b Show Windows file links relative to their workspace (#1987)
* fix(app): show Windows file links relative to workspace

Tooltip formatting only handled matching POSIX separators, so Windows paths could remain absolute. Reuse the workspace file-path resolver for normalized relative paths.

* fix(app): preserve workspace-root link tooltips

Workspace file resolution deliberately rejects the root as an openable file, so tooltip formatting preserves its display-only dot form separately.

* fix(app): share workspace path rules for file link tooltips

(cherry picked from commit cf4509631b)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] b44e7b0b90 Open a subagent in the split pane it was opened from (#5451)
* fix(app): open a subagent in the pane it was opened from

Opening a subagent from an agent's composer placed the new tab in the
focused pane. Clicking the subagent row does not focus its pane, so in a
split view the subagent landed in whichever pane last had focus.

Place an implicit main-location open that has a parent tab in that tab's
pane.

Refs #5433

* test(app): cover subagent split-pane placement in browser

* test(app): use browser repro for subagent pane placement

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
(cherry picked from commit 7bb7d4ed08)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 09f87df1b2 Report a background send_agent_prompt's started turn as running (#5386)
* Report a background send_agent_prompt's started turn as running

The background branch read the target's status right after dispatch,
before the provider acknowledged the turn, so an idle target read as
idle. Wait for the run to start first, as create_agent and the app's
send path already do.

* Check the caller still gets the finish notification after a background send

* Acknowledge the held turn start explicitly in the send_agent_prompt test

* Cover immediate completion after background turn start

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
(cherry picked from commit 8de52a3c99)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] b013e0d428 fix(codex): read custom prompts from the provider's own CODEX_HOME (#5450)
A provider that extends codex and sets CODEX_HOME in its env runs Codex
against that home, but the daemon listed, expanded, and fell back to
skills from its own CODEX_HOME or ~/.codex. The / menu offered another
home's prompts and never the provider's own, and invoking one of the
provider's prompts sent the raw slash text to Codex.

Resolve the Codex home from the same environment the app-server is
launched with, and give it to the session.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
(cherry picked from commit dffde6ae8b)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] c938e1cf44 Show the Cursor icon for terminal profiles that run cursor-agent (#5379)
* Show the Cursor icon for terminal profiles that run cursor-agent

The Cursor CLI binary is cursor-agent, so a profile created in settings
with that command matched no known icon and fell back to the generic
terminal icon. Map cursor-agent to the cursor icon while keeping
command matching exact.

Closes #4702

* Verify Cursor terminal profile icons in real browser

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
(cherry picked from commit a43c8d888f)
2026-09-28 23:36:50 +02:00
paseo-bot[bot] 0c18144967 Keep a custom Codex provider and Paseo tools after rewinding
Rewound Codex forks receive the same provider and runtime MCP config as thread start and resume. Covers paginated and legacy rewind paths.\n\nCloses #4542\nCloses #3205\n\nCo-authored-by: zbaibg <zbai29@wisc.edu>

(cherry picked from commit 827178df90)
2026-09-28 23:36:50 +02:00
1412 changed files with 15734 additions and 76086 deletions
-1
View File
@@ -34,7 +34,6 @@ hub:
- "packages/server/src/server/hub/**"
server:
- "plugins/**"
- "packages/server/**"
- "packages/app/e2e/support/fixtures/recording.*"
-10
View File
@@ -117,15 +117,9 @@ jobs:
- name: Build server stack
run: npm run build:server
- name: Verify built-in plugins from built output
run: node --test scripts/builtin-plugins-dist.test.mjs
- name: Typecheck all packages
run: npm run typecheck
- name: Run website tests
run: npx vitest run packages/website/src --bail=1
- name: Install website test browsers
run: npx playwright install --with-deps chromium webkit
@@ -175,10 +169,6 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- name: Run built-in plugin tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/builtin-plugins
- name: Run Hub CLI/daemon contract
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
+1 -5
View File
@@ -7,7 +7,6 @@ on:
- "CHANGELOG.md"
- "public-docs/**"
- "packages/website/**"
- "packages/protocol/**"
- "package.json"
- "package-lock.json"
- "patches/**"
@@ -30,10 +29,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root
- name: Build protocol
run: npm run build:protocol
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/website
-1
View File
@@ -9,7 +9,6 @@ on:
branches: [main]
paths:
- "packages/desktop/**"
- "plugins/**"
- "nix/**"
- ".github/workflows/desktop-packages.yml"
+4 -19
View File
@@ -36,7 +36,7 @@ jobs:
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-cli.log"
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.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 PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 &
PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
@@ -57,22 +57,7 @@ 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" ]]
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
exit 0
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
@@ -87,7 +72,7 @@ jobs:
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo daemon run stdout/stderr"
echo "::group::paseo-server stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
-39
View File
@@ -84,45 +84,6 @@
"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": {
-174
View File
@@ -1,179 +1,5 @@
# Changelog
## 0.11.2 - 2026-10-09
### Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445))
## 0.11.1 - 2026-10-07
### Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332))
### Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308))
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316))
- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301))
## 0.11.0 - 2026-10-07
Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry).
**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory.
### Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
- Added 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 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
- 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))
- Changed relative timestamps in agent, schedule, and Import session rows to keep advancing while on screen ([#5340](https://github.com/getpaseo/paseo/pull/5340))
### 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 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))
- Fixed Codex custom prompt arguments containing `$` sequences reaching Codex altered ([#5669](https://github.com/getpaseo/paseo/pull/5669) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed Codex structured output failing when the schema has a field named `properties` ([#5684](https://github.com/getpaseo/paseo/pull/5684))
- Fixed a Codex agent created without a thinking option losing its default effort after a daemon restart ([#5686](https://github.com/getpaseo/paseo/pull/5686))
- Fixed only one permission showing when Codex asks to approve two commands from one item ([#5758](https://github.com/getpaseo/paseo/pull/5758))
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#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 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 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
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
## Repository map
+11 -25
View File
@@ -29,7 +29,7 @@
</a>
</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
@@ -39,33 +39,21 @@
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
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.
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **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.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
## Plugins
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
iOS, and Android. Write a plugin once and it is on your phone.
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>`.
- **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.
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.
## Getting Started
@@ -80,8 +68,6 @@ You need at least one agent CLI installed and configured with your credentials:
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
- [Antigravity](https://paseo.sh/docs/supported-providers#antigravity)
- [Muse Code](https://paseo.sh/docs/muse-code)
### Desktop app (recommended)
@@ -89,16 +75,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**.
### Server
### CLI / headless
For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon:
Install the CLI and start Paseo:
```bash
npm install -g @getpaseo/cli
paseo
```
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.
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.
For full setup and configuration, see:
@@ -209,7 +195,7 @@ npm run typecheck
## Sponsors
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.
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.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
+5 -32
View File
@@ -34,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul
agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but
between turns nothing is watching, so the agent sits at `idle` looking healthy while its background
work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry.
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
the unfinished turn is lost.
Only the Claude provider does this today; the others still report a death only when a turn happens to
be in flight.
### Cancellation
@@ -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 reuses that workspace unless a new workspace is explicitly requested.
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.
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 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 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.
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,32 +84,6 @@ 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.
@@ -223,11 +197,10 @@ 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 `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`.
- **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`.
- **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.
-4
View File
@@ -345,10 +345,6 @@ 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
-12
View File
@@ -43,18 +43,6 @@ 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
+9 -38
View File
@@ -31,35 +31,6 @@ agent catalog during startup, raise the limit in milliseconds:
The limit applies independently to every provider refresh and covers availability plus the entire
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
## Provider options
Set provider defaults in `agents.providers.<id>.options`. At agent creation, use
`providerOptions` on the wire or `config.options` in the SDK. Both are opaque
`Record<string, unknown>` values: the provider owns validation and application.
Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error.
Providers without option handling ignore the record.
The daemon deep-merges the agent's options over the provider defaults at each
session launch or resume. Plain objects merge recursively; arrays, scalars, and
`null` replace the base value. Only the agent's own options are persisted, so edits
to provider defaults apply on the next launch or resume. A profile inherits its
base provider's options unless it sets its own options record.
```json
{
"agents": {
"providers": {
"codex": {
"options": { "sandbox_mode": "read-only", "approval_policy": "never" }
}
}
}
}
```
See the [SDK provider options guide](../public-docs/sdk/provider-options.md) for
per-agent examples and provider-specific keys.
---
## Table of Contents
@@ -420,7 +391,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
"XDG_CONFIG_HOME": "~/.config/omp-work",
"XDG_STATE_HOME": "~/.local/state/omp-work"
},
"options": {
"params": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"rpcTimeoutMs": 60000,
"smolModel": "openai/gpt-5-mini",
@@ -433,7 +404,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
}
```
`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines.
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
@@ -445,7 +416,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
"extends": "pi",
"label": "My Pi Fork",
"command": ["my-pi-fork"],
"options": {
"params": {
"sessionDir": "~/.my-pi-fork/sessions",
"rpcTimeoutMs": 60000
}
@@ -455,7 +426,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
}
```
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
---
@@ -513,7 +484,7 @@ Required fields for ACP providers:
- `label`
- `command` — the command to spawn the agent process (must support ACP over stdio)
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`:
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
```json
{
@@ -523,7 +494,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "acp"],
"options": {
"params": {
"supportsMcpServers": false
}
}
@@ -534,7 +505,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
ACP agents execute filesystem operations in their own environment by default,
while terminal operations run through Paseo on the host. To customize which
operations Paseo handles, configure client capabilities in provider options:
operations Paseo handles, configure client capabilities in provider params:
```json
{
@@ -544,7 +515,7 @@ operations Paseo handles, configure client capabilities in provider options:
"extends": "acp",
"label": "Container Agent",
"command": ["container-agent", "acp"],
"options": {
"params": {
"clientCapabilities": {
"fs": {
"readTextFile": false,
@@ -713,7 +684,7 @@ Every entry under `agents.providers` accepts these fields:
| `description` | `string` | No | Short description shown in the UI |
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `options` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
+2 -4
View File
@@ -182,10 +182,8 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
Single file, validated with `PersistedConfigSchema`.
`agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from the shared
`~/.agents/skills` and Claude skill directories and keeps config plus filesystem convergence behind
one serialized owner. Codex discovers the shared directory. Updates retire unchanged files from
Paseo-managed copies in `~/.codex/skills`, preserving user edits and untracked files.
`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from its three managed
skill directories and keeps config plus filesystem convergence behind one serialized owner.
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
+2 -2
View File
@@ -193,7 +193,7 @@ Terminology:
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `xs` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
@@ -217,7 +217,7 @@ Changing state must not move the layout. A row that grows when its badge arrives
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
+7 -11
View File
@@ -15,10 +15,8 @@ fixed-target labels and icons from that registration, filter by host, and never
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
a compatible pane.
Files and Changes are the Explorer defaults. Their panel manifests mark them as singletons,
so a pane’s + menu omits each while that pane already contains it. Closing one makes its menu
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
can move between Explorer and main panes.
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
@@ -43,13 +41,11 @@ The persisted layout still contains the Explorer pane so tabs survive reloads. T
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
hide their close buttons through the panel manifest; close them from the tab context menu.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
does not change workspace focus.
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared
launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs
can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace
focus.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
+2 -18
View File
@@ -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/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + 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. 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
focus when it closes. 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
@@ -302,22 +302,6 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
includes native centered dialogs: separate React Native Modals cannot present a root-owned
sibling dialog over an existing iOS presenter.
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
and restoration then silently do nothing. Callers may use callback refs on the wrapper, which
forwards commands to the current Gorhom handle.
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
keeps its press target until portal teardown and asks the provider to dismiss the top modal.
Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
## Gotcha 7 — A sheet cannot read context from its call site
React cannot copy contexts reflectively, so the only way across the teleport in
+1 -5
View File
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -31,10 +31,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **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 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
View File
@@ -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 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.
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`.
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) and the user can point at it, it is a `HoverCard`.
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
- [ ] 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 -4
View File
@@ -191,13 +191,11 @@ its own.
[floating-panels.md](floating-panels.md).
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
card, and are unit-tested. Fix positioning bugs there, not at a call site.
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` 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).
+2 -4
View File
@@ -32,10 +32,8 @@ 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. 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
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
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).
-20
View File
@@ -65,26 +65,6 @@ 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:
+32 -188
View File
@@ -36,8 +36,7 @@ The daemon stores directory sources under the root `plugins` object:
}
```
Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active.
Changing that root field is
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits.
@@ -86,82 +85,33 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
never deletes it. The global `pluginsEnabled` switch remains available.
## Built-in plugins
Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.server.ts`,
`server/`, and optional client entry and icon. Add the plugin ID to `builtinPlugins` in
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load.
Desktop packaging ships the entire built-in plugin directory as an external resource,
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
Built-ins can import only host modules (`@getpaseo/plugin/*`, `zod`) and Node built-ins:
outside the archive, nothing resolves an npm dependency, and the dist build test cannot catch
one because it resolves through the repository's `node_modules`.
The packaged-app smoke check requires every listed built-in to start without relying on
account credentials.
Built-ins run in process and remain active independently of `pluginsEnabled`. They are
absent from the installed plugin list and source configuration; their client bundles
appear in the plugin catalog. Editing one in development requires a daemon restart.
Directory, Git, and npm installs cannot use a built-in ID.
Provider plugins use separate installation and provider IDs. `muse-provider` registers the
selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports
availability and a diagnostic after the daemon resolves the executable. Configure command,
environment, or enablement overrides under `agents.providers.muse`. Omit `extends` to keep
the bundled integration; an entry with `extends` shadows it with a custom provider. See
[provider contributions](#contribute-a-provider) for the contract and
[Muse Code](../public-docs/muse-code.md) for setup, per-agent options, and version limitations.
## Install from a registry
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own.
Registry overviews are untrusted author content, even before installation. Render them with
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
URLs, and isolated external links. The website adapter and corpus tests live in
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
this policy. Installed-plugin descriptions in the app remain plain text.
Use `host/owner/slug` for a registry at `https://host`, or set
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
Private registry credentials live in daemon config under
`pluginRegistries: { "host": { "authorization": "Bearer token" } }`.
Restart your daemon after changing these startup settings. Credentials go only to the registry,
never artifact hosts or redirects. Git/npm use their own host authentication.
The [open registry protocol](https://github.com/getpaseo/plugins/blob/main/PROTOCOL.md)
owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
URL.
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
directory always wins over shorthand resolution.
```bash
paseo plugin install git:owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install git:owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
paseo plugin add owner/repository
paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo:plugins/review
paseo plugin add owner/repository --ref main
paseo plugin ls
paseo plugin update review
paseo plugin update --all
```
Append `:relative/path` for a plugin below the repository root. `--ref` chooses the initial
branch, tag, or commit once. Updates of explicit Git sources resolve the remote's default HEAD
and ask for approval. `ls` reports the installed commit without contacting the remote.
Append `:relative/path` to the source when the plugin lives below the repository root.
`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's
current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote.
Removing a Git source deletes Paseo's managed checkout.
## 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. 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.
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.
`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
@@ -253,7 +203,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 contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | 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 |
@@ -299,23 +249,12 @@ export default function contribute(server: PluginServerContext) {
```tsx
// index.client.tsx
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Greeting } from "./client/greeting";
function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
return (
<SidebarRow
icon="MessageCircle"
active={currentScreen?.screenId === "main"}
onPress={() => openScreen({ screenId: "main" })}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addScreen({ id: "main", title: "Greeting", Component: Greeting });
client.addSidebarHeaderItem({ id: "main", title: "Greeting", Component: GreetingItem });
client.addSurface("main", Greeting);
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
return () => {};
}
```
@@ -328,33 +267,15 @@ errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
item's title and icon instead, as before screens had titles; the `addSurface` alias titles its
screen with the id. A `/sidebar/<id>` route with no legacy item of that id opens the screen with
that id, so links saved before a plugin migrated keep working.
Screen params ride in the screen route's query string (`plugins/routes.ts`), not in app state, so
reload, history, links, and the host switcher keep them without a store. Expo Router merges the
route's segments (`serverId`, `pluginId`, ...) into the same search params as the query, so param
keys are stored under a `param.` prefix; plugins never see it and may use any key.
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
`usePaseo()` is the plugin's one client: `InstalledPlugin.paseo`, created with the installation in
`packages/app/src/plugins/registry.ts` over the host's existing connection and passed to setup as
`client.paseo`. Authors release their subscriptions in their own cleanup, and teardown (disable,
reload, removal, host removal) disposes the client, which ends the rest; nothing is created or
disposed per mounted surface. See the
[public example](../public-docs/plugins/reference.md#use-the-paseo-sdk). Switching the screen's host
changes both `usePaseo()` and `useRpc()` to that host's installation. An offline selected host fails there and never falls through to another
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
`useRpc()` to that host. An offline selected host fails there and never falls through to another
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
@@ -390,7 +311,7 @@ existing agent-context instances, but it cannot create an agent panel without an
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items)
See the public [navigation fields](../public-docs/plugins/reference.md#surfaces-and-sidebar-items)
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
@@ -421,38 +342,9 @@ export default function contribute(server: PluginServerContext) {
}
```
Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies
`agents.providers.<id>.command` and `env`, resolves the executable against the effective PATH, and
removes parent-session and daemon-control environment variables. `connect({ launch })` receives
`{ command, args, env }`: an executable path, effective arguments, and the complete sanitized
environment. Spawn with those values; do not merge the plugin process's environment back in.
The launch travels as data through the same provider protocol for built-in and subprocess plugins.
Without a declared command, existing providers keep their connection behavior and receive no launch.
Implement optional `status({ launch })` to check credentials, versions, or other prerequisites using
that effective launch. Return `{ available: true }` or
`{ available: false, diagnostic: "Run agent login" }`. The daemon checks executable availability
before calling status; it exposes your diagnostic through the normal provider diagnostic command.
A command-backed provider without status is available when its executable resolves. Providers with
neither command nor status retain connection-based availability.
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
description, and model configuration through the normal provider registry. `enabled: false` disables
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
and logs a warning. Overrides validate before plugins load; an override for an absent plugin stays
inactive and logs a warning naming the unmatched ID after built-in and configured plugin startup
settles (including disabled plugins), then on each installed registry generation.
Use IDs matching `/^[a-z][a-z0-9-]*$/` for configurable providers. Dots and underscores
remain valid for plugin registration but cannot be used as config provider IDs.
Provider config changes rebuild the affected registry entry and invalidate its catalogue; the next
connection receives the new launch without restarting the daemon. Running sessions keep their
current launch until refreshed, as native providers do.
Implement optional `ProviderRegistration.getCatalogCacheKey(options)` to share equivalent catalogue
probes. The callback runs in the plugin process before discovery and receives the actual global or
workspace target plus the resolved `launch` when a command is declared. Return a key covering
effective configuration and execution environment, or
workspace target. Return a key covering effective configuration and execution environment, or
`undefined` for target-specific caching. Ignore `force` when choosing identity. Existing providers
need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh-contract).
@@ -460,22 +352,15 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`.
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
the opaque `providerOptions` config object.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
no provider reload input.
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
`session.open`; there is no provider reload input.
For an ACP command, register `runAcpProvider({ id, label, command })` from
`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
whole provider event stream. The shim uses the resolved launch for every probe and session, overlaying
only the supplied per-session env for sessions. The direct and ACP examples live in
`plugin-examples/provider-direct`
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
and `plugin-examples/provider-acp-transformer`.
Provider-emitted plugin timeline items use the same renderer registration as transformed and
@@ -491,47 +376,6 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
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, 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.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
[public reference](../public-docs/plugins/reference.md#sidebar-items) owns the author API. The host
lives in `packages/app/src/plugins/sidebar-items/`; `packages/app/src/sidebar-nav/model.ts` resolves
one section's order from built-ins, plugin groups, and the section's preference
(`sidebarNavItems` or `sidebarFooterItems`).
- The item's `Component` renders directly in the section, with no wrapper, so a fragment or array
of rows lays out like separate items while Settings keeps one entry for the block. Footer items
are rows between Add project and the footer's icon row. The icon row (Hosts,
Help and support, Settings) is fixed app code, not a contribution slot, so the kit has no icon
button.
- `openPopover` opens through the same `PluginPopoverSurface` as header buttons
(`plugins/popover.tsx`). Any press inside a `SidebarRow`, including its `trailing` content, moves
the menu anchor to that row: a capture-phase `onStartShouldSetResponderCapture` runs before the
pressed child claims the touch on native and web, and returns false. Keyboard activation skips
the responder system, so the row's own press anchors too. Until a press, the first mounted row
holds the anchor. An item that renders no kit component
anchors to its section container.
- `SidebarSeparator` reuses the app's separator and cancels the footer's horizontal padding, so
the line runs edge to edge in both sections.
- `SidebarRow.trailing` sits in a second `Pressable` beside the row's button, since web cannot nest
buttons. Its `onPress` is the row's, so a press on non-interactive trailing content presses the
row; a button inside it wins its own press because only the innermost pressable responds.
- A new-API item renders from the current route's host, else the host remembered under
`pluginScreensHostKey(pluginId)`, which the screen's host switcher and the item's `openScreen`
set. One key per plugin: switching host on any of its screens moves all its items.
- `addSurface`, `openSurface`, and `addSidebarItem` are undocumented aliases, tagged
`COMPAT(pluginSidebarAliases)`. `addSidebarItem` is recorded only in `legacySidebarItems` and
groups as a `legacy` sidebar group, which the app draws as its own row: its registered icon, in
the sidebar and in Settings > Sidebar, opening `/plugin/<id>/sidebar/<item>` on the host it
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
@@ -601,7 +445,7 @@ be rendered intact. The daemon advertises this RPC through
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
the same `paseo`, `rpc`, `openScreen`, workspace, agent, and `openPanel` capabilities as the matching
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
Command Center callback.
```ts
+65 -43
View File
@@ -1,40 +1,52 @@
# Adding a New Provider to Paseo
Add new providers through the plugin SDK. The core adapter patterns below describe the existing
server integrations.
## Plugin providers
Keep a bundled provider in `plugins/<id>/` and register it through
`@getpaseo/plugin/server/provider`. Antigravity and Muse Code follow this pattern. Built-in loading and SDK
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
The plugin owns the CLI transport, session state, catalog, and capabilities. 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.
| Provider | Transport | Setup and limitations |
| ----------- | ------------------------------------------ | ---------------------------------------------------------------- |
| Antigravity | Installed `agy` CLI | [Antigravity](../public-docs/supported-providers.md#antigravity) |
| Muse Code | MSP over one `muse serve` host per session | [Muse Code](../public-docs/muse-code.md) |
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
## Provider-native session options
The provider owns validation and application of the opaque record in
`AgentSessionConfig.providerOptions`. The registry supplies the effective options
at session startup. See [provider configuration](custom-providers.md#provider-options)
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
`AgentSessionConfig.providerOptions` carries JSON-safe configuration for the selected provider. The
names and nesting are the provider's native contract; options are not portable between providers.
Paseo validates the object with the selected provider's strict schema before constructing a session.
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
top-level provider options.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
This Paseo version accepts these keys:
## Core adapter patterns
- **Codex:** `approval_policy`, `sandbox_mode`,
`sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`,
`web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may
contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`,
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
`settings`. `providerOptions.extraArgs` passes the SDK's documented
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
`permissions.{allow,ask,deny}` and sandbox settings. See the
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
and [Claude settings reference](https://code.claude.com/docs/en/settings).
- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule
object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`,
`external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`,
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
application policy, not an OS sandbox.
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
@@ -57,12 +69,10 @@ model; it does not override the model list returned by a resolver.
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
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.
@@ -75,9 +85,7 @@ Paseo's per-agent and daemon-wide system prompts are appended by its generated P
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
Probe Pi MCP support with Pi RPC `get_commands` for the agent cwd. Pi 0.99 and later ship MCP as a built-in extension, which registers an extension command named `mcp` with `sourceInfo.path` `builtin:mcp`. Register injected servers through `pi.registerMcpServer` in Paseo's generated extension; the built-in extension rejects SSE servers and names with characters other than letters, digits, `_`, and `-`, and a server of the same name in Pi's `mcp.json` takes precedence.
The open-source `pi-mcp-adapter` extension replaces the built-in one and registers its own `mcp` command (often with `sourceInfo.source` containing `pi-mcp-adapter`). When it is loaded, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
@@ -105,7 +113,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()`. 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.
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.
`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.
@@ -189,12 +197,26 @@ promise for completion: equal results, including equal discovery timestamps, emi
---
## Usage sources
## Provider Usage Fetchers
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
contract, account and window identity, provider-derived period names, login fallback, and
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
renders their names and resolves pins without provider-specific duration guesses.
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
- `providerId`, `displayName`, `status`, and optional `planLabel`
- any number of `windows` such as Session, Weekly, or Biweekly
- optional `balances` for credits, USD, requests, or tokens
- optional `details` for provider-specific rows
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
### Usage fetchers are read-only on credentials
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
---
@@ -282,7 +304,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
### 2. Add to the provider manifest
In `packages/protocol/src/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
First, define the modes with visual metadata:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

+17 -15
View File
@@ -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 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`.
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`.
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.
- **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.
- **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.
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: 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 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 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,20 +521,17 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
## Changelog policy
- `CHANGELOG.md` holds stable releases and at most one entry for the current beta series.
- `CHANGELOG.md` includes stable releases and every entry in 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 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.
- 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.
## 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.
- 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.
- 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.
## Changelog wording
@@ -566,7 +563,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.
- **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.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
@@ -634,7 +631,12 @@ Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. T
Changelog scope follows the release being described:
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.
- **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.
## Completion checklist
@@ -642,7 +644,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] 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
- [ ] 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
- [ ] 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
- [ ] 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
@@ -665,7 +667,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] 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 renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
- [ ] 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
- [ ] 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
+1 -7
View File
@@ -25,13 +25,7 @@ checkout.forge.set_auto_merge.request;
// -> checkout.forge.set_auto_merge.response
```
A finite streaming request can emit `.update` messages before its terminal `.response`.
`usage.list_reports.request` uses this shape: each `.update` carries one settled report, and
`.response` carries the request-level error or `null`. All three share `requestId`; no updates
follow the response. An empty successful stream is an empty list. Register updates as replies
of the originating request so source-owned delivery routes them to the requesting connection.
Most new RPCs should follow these shapes. If a request does not have a one-to-one terminal response, call that out in the code near the schema.
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
## Message Shape
+1 -1
View File
@@ -51,7 +51,7 @@ Claude hook mapping:
- `UserPromptSubmit` → `running`
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
- `Notification` with `reason` or `matcher` 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
+5 -19
View File
@@ -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
human prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
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,25 +238,11 @@ 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. 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
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
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
@@ -264,7 +250,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.
For human prompts, the daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
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,10 @@
Added
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
Fixed
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
- Fixed a subagent opened from a split pane opening in a different pane
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Added
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
Fixed
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
- Fixed a subagent opened from a split pane opening in a different pane
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Added
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
Fixed
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
- Fixed a subagent opened from a split pane opening in a different pane
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Added
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
Fixed
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
- Fixed a subagent opened from a split pane opening in a different pane
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Fixed
- Fixed OpenCode v2 turns longer than five minutes failing with UNDERRHEADERS_TIMEOUT
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff
- Fixed completed OpenCode v2 edits showing no diff
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Fixed
- Fixed OpenCode v2 turns longer than five minutes failing with UNDERRHEADERS_TIMEOUT
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff
- Fixed completed OpenCode v2 edits showing no diff
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Fixed
- Fixed OpenCode v2 turns longer than five minutes failing with UNDERRHEADERS_TIMEOUT
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff
- Fixed completed OpenCode v2 edits showing no diff
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Fixed
- Fixed OpenCode v2 turns longer than five minutes failing with UNDERRHEADERS_TIMEOUT
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff
- Fixed completed OpenCode v2 edits showing no diff
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Added
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed
Full notes: https://paseo.sh/changelog
@@ -1,7 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,4 +0,0 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
-4
View File
@@ -1,10 +1,6 @@
{
"$schema": "./node_modules/knip/schema.json",
"workspaces": {
"plugins": {
"entry": ["**/index.{server,client}.{ts,tsx}", "**/*.test.ts"],
"project": ["**/*.{ts,tsx}"]
},
".": {
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
-3
View File
@@ -157,9 +157,6 @@ 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
View File
@@ -1 +1 @@
sha256-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ=
sha256-wHz2O/y9bAQsZVQGUMnSYbg40h5W5ogv0hg2Lq+/UrM=
-3
View File
@@ -120,9 +120,6 @@ 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/
+39 -90
View File
@@ -1,16 +1,15 @@
{
"name": "paseo",
"version": "0.11.2",
"version": "0.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.2",
"version": "0.10.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
"plugins",
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
@@ -6330,23 +6329,10 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/geist": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@getpaseo/app": {
"resolved": "packages/app",
"link": true
},
"node_modules/@getpaseo/builtin-plugins": {
"resolved": "plugins",
"link": true
},
"node_modules/@getpaseo/cli": {
"resolved": "packages/cli",
"link": true
@@ -26270,6 +26256,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lucide-react-native": {
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
"integrity": "sha512-v/qe5JnOHKIhccqfE7Ln78wd0mn5YgmCBvkTX2ca8sWeoym3ZYZcY/mapFsxssarutO4bMI/bRtTqiusGbpOUw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-native": "*",
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -36413,7 +36410,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.11.2",
"version": "0.10.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36478,7 +36475,7 @@
"fast-deep-equal": "^3.1.3",
"htmlparser2": "^12.0.0",
"i18next": "^26.3.0",
"lucide-react-native": "^1.50.0",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
"mermaid": "^11.16.0",
"mnemonic-id": "^3.2.7",
@@ -37333,23 +37330,6 @@
"node": ">=20.19.0"
}
},
"packages/app/node_modules/lucide-react-native": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.50.0.tgz",
"integrity": "sha512-uMtIW3I6jfHbUp2FnUwxbDJc7XFJDz2g5YVcw6T/De8Sp9f21vTO7HlJNhxpBynDYlqLyWHaXeR0dBSiw6TqOw==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-native": "*",
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"packages/app/node_modules/miniflare": {
"version": "4.20260625.0",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
@@ -37496,12 +37476,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.11.2",
"version": "0.10.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/server": "0.11.2",
"@getpaseo/client": "0.10.3",
"@getpaseo/protocol": "0.10.3",
"@getpaseo/server": "0.10.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37748,10 +37728,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.11.2",
"version": "0.10.3",
"dependencies": {
"@getpaseo/protocol": "0.11.2",
"@getpaseo/relay": "0.11.2",
"@getpaseo/protocol": "0.10.3",
"@getpaseo/relay": "0.10.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37762,7 +37742,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.11.2",
"version": "0.10.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38006,7 +37986,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.11.2",
"version": "0.10.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38902,7 +38882,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.11.2",
"version": "0.10.3",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38931,14 +38911,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.11.2",
"version": "0.10.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/client": "0.10.3",
"@getpaseo/protocol": "0.10.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38948,8 +38928,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/client": "0.10.3",
"@getpaseo/protocol": "0.10.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38971,7 +38951,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.11.2",
"version": "0.10.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38998,7 +38978,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.11.2",
"version": "0.10.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39216,16 +39196,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.11.2",
"version": "0.10.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@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",
"@getpaseo/client": "0.10.3",
"@getpaseo/highlight": "0.10.3",
"@getpaseo/plugin": "0.10.3",
"@getpaseo/protocol": "0.10.3",
"@getpaseo/relay": "0.10.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39258,7 +39238,6 @@
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"yaml": "2.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -40117,21 +40096,6 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"packages/server/node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"packages/server/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
@@ -40146,12 +40110,10 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.11.2",
"version": "0.10.3",
"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",
@@ -40174,7 +40136,6 @@
"@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",
@@ -41751,18 +41712,6 @@
"optional": true
}
}
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.11.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"vitest": "^4.1.6"
}
}
}
}
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.11.2",
"version": "0.10.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -21,7 +21,6 @@
"url": "https://github.com/getpaseo/paseo.git"
},
"workspaces": [
"plugins",
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
@@ -67,7 +66,7 @@
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/builtin-plugins && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present",
"format": "oxfmt .",
"format:files": "oxfmt",
@@ -284,6 +284,7 @@ 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;
@@ -294,21 +295,13 @@ async function expectRejectedSubmissionRestored(
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
const attempts = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(attempts).toHaveCount(2);
const userMessage = attempts.last();
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toHaveCount(1);
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`);
@@ -1180,14 +1173,13 @@ test.describe("Agent message submission", () => {
await toasts.expectNeverShown("agent-updating-toast");
});
test("retains a rejected submission before its error and accepts its retry", async ({
test("restores a rejected submission 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);
});
@@ -1217,7 +1209,6 @@ 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);
@@ -1251,7 +1242,6 @@ test.describe("Agent message submission", () => {
await gate.waitForHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
await expect(page.getByText(/^Worked for/)).toHaveCount(0);
await expectAgentReadyToInterrupt(page);
gate.releaseHeldServerMessage("send_agent_message_response");
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
} finally {
@@ -1,47 +0,0 @@
import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
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";
test("agent list age advances and command center says just now for a fresh agent", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "agent-relative-time-",
title: "Relative time agent",
});
try {
await session.client.waitForAgentUpsert(session.agentId, (agent) => agent.status === "idle");
await resetSeededPageState(page);
await page.clock.install({ time: Date.now() });
await openSessions(page);
const row = page.getByTestId(`agent-row-${getServerId()}-${session.agentId}`);
await expect(row).toContainText("just now", { timeout: 30_000 });
const commandCenter = await openCommandCenter(page);
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
const agentResult = commandCenter.getByTestId(
`command-center-agent-${getServerId()}:${session.agentId}`,
);
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
"just now",
);
await page.keyboard.press("Escape");
await page.clock.fastForward("03:00");
await expect(row).toContainText("3m ago");
await openAgentRoute(page, session);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().hover();
await expect(page.getByTestId(`workspace-tab-tooltip-agent_${session.agentId}`)).toContainText(
"3m ago",
);
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
} finally {
await session.cleanup();
}
});
@@ -1,152 +0,0 @@
import { test } from "../support/fixtures";
import { expect } from "@playwright/test";
import {
findScrollJumps,
expectImageSpaceReserved,
type ScrollFrame,
observeTimelinePages,
openOnlyTimelineTail,
recordUpwardTraversal,
reportScrollJumps,
scrollCadences,
withVariedTimeline,
} from "../support/helpers/timeline-scroll-smoothness";
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
const before: ScrollFrame = {
at: 1000,
scrollTop: 1000,
scrollHeight: 5000,
viewportHeight: 800,
virtualized: true,
loading: false,
rows: [
{ id: "image", top: -150, height: 150 },
{ id: "reading", top: 0, height: 100 },
],
anchor: "reading",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 0,
unmounted: 0,
};
const after: ScrollFrame = {
...before,
at: 1400,
scrollTop: 900,
wheelTotal: 100,
lastWheelAt: 1050,
rows: [
{ id: "image", top: -50, height: 1150 },
{ id: "reading", top: 1100, height: 100 },
],
};
expect(findScrollJumps([before, after])).toEqual([]);
expect(
findScrollJumps([
{ ...before, scrollTop: 200 },
{
...after,
wheelTotal: 1000,
scrollTop: 0,
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
},
]),
).toEqual([]);
expect(
findScrollJumps([
before,
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
]),
).toHaveLength(1);
expect(
findScrollJumps([
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
]),
).toHaveLength(1);
});
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
const before: ScrollFrame = {
at: 1000,
scrollTop: 2000,
scrollHeight: 5000,
viewportHeight: 800,
virtualized: true,
loading: false,
rows: [
{ id: "new-reader", top: -520, height: 278 },
{ id: "image", top: -242, height: 184 },
{ id: "old-reader", top: -58, height: 130 },
],
anchor: "old-reader",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 0,
unmounted: 0,
};
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
const after: ScrollFrame = {
...wheel,
at: 1220,
scrollTop: 1520,
scrollHeight: 5041,
anchor: "new-reader",
rows: [
{ id: "new-reader", top: -40, height: 278 },
{ id: "image", top: 238, height: 225 },
{ id: "old-reader", top: 463, height: 130 },
],
};
expect(findScrollJumps([before, wheel, after])).toEqual([]);
// The previous 480 px input has already moved the viewport; the recent
// budget includes it, but only the next 480 px belongs to this frame.
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
const steadyAfter = {
...after,
at: 1100,
wheelTotal: 960,
lastWheelAt: 1099,
scrollHeight: 6000,
rows: [
{ id: "new-reader", top: -40, height: 278 },
{ id: "image", top: 238, height: 1184 },
{ id: "old-reader", top: 1422, height: 130 },
],
};
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
expect(
findScrollJumps([
before,
wheel,
{
...after,
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
},
]),
).toHaveLength(1);
});
for (const cadence of scrollCadences) {
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
await withVariedTimeline(async (agent, newestPrompt) => {
const pages = observeTimelinePages(page, agent.agentId);
await openOnlyTimelineTail(page, agent, newestPrompt, pages);
const frames = await recordUpwardTraversal(page, cadence, testInfo);
await reportScrollJumps(page, testInfo, frames, pages);
});
});
}
test("reserves image space before its response arrives", async ({ page }, testInfo) => {
await expectImageSpaceReserved(page, testInfo);
});
@@ -120,84 +120,6 @@ 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);
}
@@ -643,19 +565,6 @@ 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);
@@ -692,55 +601,3 @@ test("copying an assistant selection preserves Markdown structure and links", as
await agent.cleanup();
}
});
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
context,
page,
}) => {
const image =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-copy-image-",
title: "Assistant selection copy image",
initialPrompt: "Render the image fixture.",
featureValues: {
mockAssistantResponse: [
"Before the image.",
"",
`![chart](${image})`,
"",
"After the image.",
].join("\n"),
},
});
try {
await allowRichClipboard(context);
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await openAgentRoute(page, agent);
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
await selectAssistantTextRange(page, "Before the image.", "After the image.");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
`Before the image.\n\n![chart](${image})\n\nAfter the image.`,
);
await selectAssistantImage(page);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(`![chart](${image})`);
await dragFromBelowImageTo(page, "After the");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe("After the");
} finally {
await agent.cleanup();
}
});
@@ -84,18 +84,6 @@ async function openModelSelector(page: Page) {
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible({ timeout: 10_000 });
}
async function dismissStackedModelPickerWithBackdrop(page: Page) {
await page
.getByTestId("agent-controls-settings-list")
.getByRole("button", { name: /Select model/ })
.click();
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
}
async function openAndCloseTabSwitcherTwice(page: Page) {
await openTabSwitcher(page);
await closeBottomSheetWithBackdrop(page);
@@ -126,8 +114,6 @@ test.describe("mobile bottom sheet reopen", () => {
await openModelSelector(page);
const sheet = page.getByTestId("agent-controls-model-sheet");
await dismissStackedModelPickerWithBackdrop(page);
await page.getByTestId("model-search-all-input").click();
const model = page.getByRole("button", { name: /^Ten second stream/ });
await expect(model).toBeVisible({
@@ -17,7 +17,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
await page.keyboard.press(CHANGES_SHORTCUT);
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible({
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
timeout: 30_000,
});
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
+1 -10
View File
@@ -428,16 +428,7 @@ async function findFromComposer(page: Page, shortcut: string, testInfo: 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",
});
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
await findFromComposer(page, "Control+f", testInfo);
});
@@ -17,14 +17,12 @@ import {
hoverChatOutlinePrompt,
chatOutlineRail,
movePointerOffChatOutline,
observePromptIndexRequests,
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -268,21 +266,3 @@ test.describe("desktop chat outline", () => {
}
});
});
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
const promptIndex = await observePromptIndexRequests(page);
const creation = await createCreationScenario(page);
try {
await page.setViewportSize(WIDE_VIEWPORT);
await creation.openAgentDraft();
await creation.submitPrompt("Start an agent with an outline.");
await creation.expectPromptVisible();
const agentIds = await creation.agentIds();
expect(agentIds).toHaveLength(1);
await promptIndex.waitForRequestFor(agentIds[0]!);
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
} finally {
await creation.cleanup();
}
});
@@ -1,139 +0,0 @@
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only.
// The real daemon discovers both through the same Codex model/list boundary.
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
omp: { enabled: false },
},
},
},
});
// 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 });
const modelSettings = page.getByRole("button", { name: /^Select model \(/ });
await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
if (!(await speed.isVisible())) {
if (await features.isVisible()) await features.click();
else await modelSettings.click();
}
await expect(speed).toBeVisible({ timeout: 30_000 });
await speed.click();
await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible();
}
async function selectSpeed(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).last().click();
const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true });
const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true });
await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible();
if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText("");
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();
if (choices.includes(choice)) {
await expect(option).toBeVisible();
await expect(option).toBeInViewport({ ratio: 1 });
} else await expect(option).not.toBeVisible();
}
}
async function switchAgentModel(agentId: string, modelId: string): Promise<void> {
const client = await connectDaemonClient<
Pick<DaemonClient, "setAgentModel" | "close" | "connect">
>({
clientIdPrefix: "codex-speed-model",
});
try {
await client.setAgentModel(agentId, modelId);
} finally {
await client.close();
}
}
for (const viewport of [
{ name: "desktop", width: 1280, height: 850 },
{ name: "compact", width: 390, height: 844 },
]) {
test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => {
test.setTimeout(180_000);
await page.setViewportSize(viewport);
const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" });
try {
const agent = await workspace.client.createAgent({
provider: "codex",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Speed settings",
model: "gpt-6.1-sol",
modeId: "full-access",
initialPrompt: "Get ready to work on this project.",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
if (viewport.name === "desktop") {
const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toHaveText("");
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);
await expectSpeedChoices(page, ["Normal", "Fast"]);
await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") });
}
} finally {
await workspace.cleanup();
}
});
}
@@ -87,25 +87,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
});
test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "commit-relative-time-" });
const committedAt = new Date();
await createFeatureCommit(workspace.repoPath, committedAt.toISOString());
await page.clock.install({ time: committedAt });
await workspace.navigateTo();
await openChangesTreePanel(page);
await page.getByRole("button", { name: /Commits/i }).click();
const row = page.locator('[data-testid^="commit-row-"]').filter({ hasText: COMMIT_SUBJECT });
await expect(row).toContainText("just now", { timeout: 30_000 });
await page.clock.fastForward("03:00");
await expect(row).toContainText("3m ago");
});
async function createFeatureCommit(
repoPath: string,
authorDate = "2020-01-15T12:00:00Z",
): Promise<void> {
async function createFeatureCommit(repoPath: string): Promise<void> {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
@@ -114,8 +96,8 @@ async function createFeatureCommit(
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_DATE: authorDate,
GIT_COMMITTER_DATE: authorDate,
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
},
});
}
@@ -10,7 +10,6 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
{
@@ -339,16 +338,6 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
).toBeUndefined();
}
function recordCommandRequestsByAgentId(page: Page): string[] {
const requests: string[] = [];
const record = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
if (!payload.includes("draftConfig")) requests.push(payload);
};
page.on("websocket", (socket) => socket.on("framesent", record));
return requests;
}
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
const firstVisibleIndex = frames.findIndex(
(frame) =>
@@ -370,25 +359,6 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
page,
}) => {
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
await page.goto("/new");
await expectNewWorkspaceProjectSelected(page, "Choose project");
await composerLocator(page).fill("/");
const popover = page
.getByTestId("composer-autocomplete-popover")
.filter({ visible: true })
.first();
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
await expect(popover).not.toContainText("/clear");
await expect(popover).not.toContainText("/exit");
expect(commandRequestsByAgentId).toEqual([]);
});
test("stays visible after returning from app-wide routes", async ({ page }) => {
await installListCommandsStub(page);
const serverId = getServerId();
@@ -1,8 +1,7 @@
import { expect, test, type Page } from "../support/fixtures";
import { test } from "../support/fixtures";
import {
expectComposerDraft,
expectComposerFocused,
expectComposerNotFocused,
expectComposerVisible,
submitMessage,
typeIntoFocusedComposer,
@@ -28,51 +27,3 @@ 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,70 +0,0 @@
import { test } from "../support/fixtures";
import {
expectContentColumnWidth,
expectCustomContentWidthSetting,
expectDefaultContentWidthSetting,
openAppearanceSettings,
resetContentWidth,
setContentWidth,
} from "../support/helpers/content-width";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const ULTRAWIDE_VIEWPORT = { width: 2560, height: 1080 };
test("widens chat and Markdown files to the chosen content width and resets to the default", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "content-width-",
title: "Content width",
initialPrompt: "Write a long paragraph.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.setViewportSize(ULTRAWIDE_VIEWPORT);
const assistantMessage = page.getByTestId("assistant-message").first();
const markdownPreview = page.getByTestId("markdown-preview-frame").first();
await test.step("chat starts at the default width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
});
await test.step("settings show the default without a reset", async () => {
await openAppearanceSettings(page);
await expectDefaultContentWidthSetting(page, 820);
});
await test.step("a custom width is saved and offers a reset", async () => {
await setContentWidth(page, 1600);
await expectCustomContentWidthSetting(page, 1600);
await page.screenshot({ path: testInfo.outputPath("settings-custom-width.png") });
});
await test.step("chat and Markdown files use the custom width", async () => {
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("chat-custom-width.png") });
await openFileExplorer(page);
await openFileFromExplorer(page, "README.md");
await expectContentColumnWidth(markdownPreview, { min: 1200, max: 1600 });
await page.screenshot({ path: testInfo.outputPath("markdown-custom-width.png") });
});
await test.step("reset returns to the default without storing it", async () => {
await openAppearanceSettings(page);
await resetContentWidth(page);
await expectDefaultContentWidthSetting(page, 820);
await openAgentRoute(page, agent);
await expectContentColumnWidth(assistantMessage, { max: 820 });
await page.screenshot({ path: testInfo.outputPath("chat-default-width.png") });
});
} finally {
await agent.cleanup();
}
});
@@ -1,455 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test as base } from "../support/fixtures";
import {
composerLocator,
expectComposerVisible,
submitMessageWithButton,
} from "../support/helpers/composer";
import {
type AgentUsageScript,
closeContextWindowSheet,
contextWindowDetails,
expectRefreshButtons,
expiredLogin,
gate,
hoverContextWindowMeter,
leaveContextWindowMeter,
type MockAgentSession,
moveOntoContextWindowDetails,
onPersonalLogin,
onWorkLogin,
openAgent,
pressContextWindowMeter,
qaScreenshot,
refreshUsageCard,
reloadAgent,
scriptAgentUsage,
seedAgentWithContextWindow,
usageCard,
} from "../support/helpers/context-window";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item";
import { installLoginUsage } from "../support/helpers/usage-login";
const test = base.extend<{ agent: MockAgentSession }>({
agent: async ({ page: _page }, provide) => {
const agent = await seedAgentWithContextWindow();
try {
await provide(agent);
} finally {
await agent.cleanup();
}
},
});
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
const ring = meter.locator("svg");
const progressColor = await ring
.locator("circle")
.last()
.evaluate((circle) => getComputedStyle(circle).stroke);
const screenshot = await ring.screenshot();
return ring.evaluate(
async (_svg, { png, color }) => {
const image = await createImageBitmap(
await (await fetch(`data:image/png;base64,${png}`)).blob(),
);
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const { data } = context.getImageData(0, 0, image.width, image.height);
const [r, g, b] = color.match(/\d+/g)!.map(Number);
let sumX = 0;
let sumY = 0;
let count = 0;
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < image.width; x += 1) {
const i = (y * image.width + x) * 4;
const distance =
Math.abs(data[i] - r) + Math.abs(data[i + 1] - g) + Math.abs(data[i + 2] - b);
if (distance < 40) {
sumX += x;
sumY += y;
count += 1;
}
}
}
if (count === 0) {
throw new Error(`No pixels painted in the progress colour ${color}`);
}
return { x: sumX / count - image.width / 2, y: sumY / count - image.height / 2 };
},
{ png: screenshot.toString("base64"), color: progressColor },
);
}
test.describe("context window meter", () => {
test("draws usage clockwise from twelve o'clock", async ({ page }) => {
test.setTimeout(180_000);
// 32,000 of the mock's 128,000-token window: a quarter, from twelve to three o'clock.
const session = await seedMockAgentWorkspace({
repoPrefix: "context-window-meter-",
title: "Context window meter e2e",
initialPrompt: "emit 32000 byte file agent stream payload",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1);
} finally {
await session.cleanup();
}
});
});
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>;
/** Two reports on the agent's login stream in; the details show each card as it arrives. */
async function expectCardsToStreamIn(
page: Page,
usage: AgentUsageScript,
openDetails: OpenDetails,
shot: Shot,
): Promise<Locator> {
const [claude, codex] = claudeAndCodexReports();
const first = gate();
const second = gate();
usage.answerNext({
stream: [first.promise, onWorkLogin(claude!), second.promise, onWorkLogin(codex!)],
});
const details = await openDetails(page);
await expect(details.getByText("Loading usage...", { exact: true })).toBeVisible();
await shot("loading");
first.open();
await expect(
usageCard(details, "claude:work").getByText("work@example.com", { exact: true }),
).toBeVisible();
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
await expect(usageCard(details, "codex:work")).toHaveCount(0);
await shot("streaming");
second.open();
await expect(
usageCard(details, "codex:work").getByText("Session", { exact: true }),
).toBeVisible();
// Only the agent's login, never the host's default one, and no pins.
await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0);
await expectUnpinnableRows(details);
return details;
}
async function expectFailedRequestSentence(
page: Page,
usage: AgentUsageScript,
openDetails: OpenDetails,
shot: Shot,
): Promise<void> {
usage.answerNext({ error: "Unknown agent" });
await reloadAgent(page);
const details = await openDetails(page);
await expect(
details.getByText("Unable to load usage: Unknown agent", { exact: true }),
).toBeVisible();
await shot("error");
}
async function expectOnlyContextWindowWithoutUsage(
page: Page,
usage: AgentUsageScript,
openDetails: OpenDetails,
shot: Shot,
): Promise<void> {
usage.stopReportingUsage();
await reloadAgent(page);
const details = await openDetails(page);
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
await expect(details.getByText("work@example.com", { exact: true })).toHaveCount(0);
await shot("unsupported");
}
for (const theme of ["light", "dark"] as const) {
test(`the context window hover card shows the agent's usage (${theme})`, async ({
page,
agent,
}) => {
test.setTimeout(240_000);
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.emulateMedia({ colorScheme: theme });
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const shot: Shot = (state) => qaScreenshot(page, `hover-card-desktop-${theme}-${state}`);
await test.step("reports stream in one card at a time, each with Refresh", async () => {
const card = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
await expectRefreshButtons(card, 2);
await shot("ready");
expect(usage.agentRequests()).toEqual([agent.agentId]);
});
await test.step("the card stays open while the pointer moves onto it", async () => {
const card = contextWindowDetails(page);
await moveOntoContextWindowDetails(page, card);
await page.waitForTimeout(300);
await expect(card).toBeVisible();
});
await test.step("Refresh keeps the card open and replaces it with the new report", async () => {
const refreshed = gate();
usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] });
const card = contextWindowDetails(page);
await refreshUsageCard(card, "Claude");
await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled();
// Past the card's close grace, with the pointer still on the card.
await page.waitForTimeout(500);
await expect(card).toBeVisible();
refreshed.open();
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
});
await test.step("after a resume under another login, only that login shows", async () => {
// Refresh can retain focus; return to the composer before testing pointer dismissal.
await composerLocator(page).focus();
// Closed while the old login's report is still on its way.
await leaveContextWindowMeter(page);
const slowSource = gate();
usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] });
const closedEarly = await hoverContextWindowMeter(page);
await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible();
await leaveContextWindowMeter(page);
const resumed = gate();
const finished = gate();
usage.answerNext({
stream: [resumed.promise, onPersonalLogin(claude!), finished.promise],
});
const card = await hoverContextWindowMeter(page);
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0);
// The old login's report lands during the new request, before the new login's.
slowSource.open();
resumed.open();
await expect(
usageCard(card, "claude:personal").getByText("personal@example.com", { exact: true }),
).toBeVisible();
await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0);
finished.open();
});
await test.step("a report with a problem shows it on the card", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
await reloadAgent(page);
const card = await hoverContextWindowMeter(page);
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
await shot("problem");
});
await test.step("a failed request says so in a sentence", async () => {
await expectFailedRequestSentence(page, usage, hoverContextWindowMeter, shot);
});
await test.step("a host without usage reports shows only the context window", async () => {
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
// One request per open that had usage; this one sends none.
expect(usage.agentRequests()).toHaveLength(6);
});
});
test(`the context window sheet shows the agent's usage and refreshes it (${theme})`, async ({
page,
agent,
}) => {
test.setTimeout(240_000);
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.emulateMedia({ colorScheme: theme });
await page.setViewportSize(COMPACT);
await openAgent(page, agent);
const shot: Shot = (state) => qaScreenshot(page, `sheet-compact-${theme}-${state}`);
await test.step("reports stream in one card at a time, each with Refresh", async () => {
const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot);
await expectRefreshButtons(sheet, 2);
// Without hover, each card prints its freshness instead of a Refresh tooltip.
await expect(sheet.getByText("Updated just now", { exact: true })).toHaveCount(2);
await shot("ready");
expect(usage.agentRequests()).toEqual([agent.agentId]);
});
await test.step("Refresh replaces the card with the source's new report", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
const sheet = contextWindowDetails(page);
await refreshUsageCard(sheet, "Claude");
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
await shot("problem");
});
await test.step("Close dismisses the sheet", async () => {
await closeContextWindowSheet(page);
});
await test.step("a failed request says so in a sentence", async () => {
await expectFailedRequestSentence(page, usage, pressContextWindowMeter, shot);
});
await test.step("a host without usage reports shows only the context window", async () => {
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
expect(usage.agentRequests()).toHaveLength(3);
});
});
}
test("context details support keyboard entry and dismiss when the viewport moves", async ({
page,
agent,
}) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stop).toBeVisible();
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const meter = page.getByTestId("context-window-meter");
const trigger = page.getByRole("button", {
name: (await meter.getAttribute("aria-label"))!,
exact: true,
});
await expect(trigger).toHaveAttribute("tabindex", "0");
await trigger.focus();
const details = contextWindowDetails(page);
await expect(details).toBeVisible();
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeVisible();
await trigger.press("ArrowDown");
await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(details).toHaveCount(0);
await expect(stop).toBeVisible();
await expect(trigger).toBeFocused();
for (const key of ["Enter", " "]) {
usage.answerNext([onPersonalLogin(claude!)]);
await trigger.press(key);
await expect(details).toBeVisible();
await page.keyboard.press("Escape");
await expect(details).toHaveCount(0);
await expect(stop).toBeVisible();
await expect(trigger).toBeFocused();
}
usage.answerNext([onPersonalLogin(claude!)]);
await trigger.press("ArrowDown");
await expect(details).toBeVisible();
await page.setViewportSize({ width: 1280, height: 850 });
await expect(details).toHaveCount(0);
});
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const input = composerLocator(page);
await input.focus();
await hoverContextWindowMeter(page);
await expect(input).toBeFocused();
await leaveContextWindowMeter(page);
await expect(input).toBeFocused();
});
test("Find remains available after dismissing context details", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const [claude] = claudeAndCodexReports();
usage.answerNext([onPersonalLogin(claude!)]);
const meter = page.getByTestId("context-window-meter");
const trigger = page.getByRole("button", {
name: (await meter.getAttribute("aria-label"))!,
exact: true,
});
await trigger.focus();
await expect(contextWindowDetails(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(contextWindowDetails(page)).toHaveCount(0);
await expect(trigger).toBeFocused();
await page.keyboard.press("ControlOrMeta+f");
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
});
@@ -128,46 +128,3 @@ 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);
});
}
@@ -30,7 +30,7 @@ test.describe("New tab keyboard launcher", () => {
await waitForWorkspaceTabsVisible(page);
const explorer = await openExplorerWithKeyboard(page);
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
} finally {
await workspace.cleanup();
@@ -25,16 +25,13 @@ function draftTabChip(page: Page): Locator {
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
}
/** Scope the shared tab row to the Explorer dock. */
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
function explorerTabRow(page: Page): Locator {
return visible(page, "workspace-explorer-sidebar").getByTestId("workspace-tabs-row");
return visible(page, "explorer-sidebar-tab-rail");
}
function mainTabRow(page: Page): Locator {
return page
.locator('[data-testid^="workspace-pane-"]')
.getByTestId("workspace-tabs-row")
.filter({ visible: true });
return visible(page, "workspace-tabs-row");
}
async function selectExplorerChanges(page: Page): Promise<void> {
@@ -176,7 +173,7 @@ test.describe("explorer pane tab placement", () => {
contentType: "image/png",
});
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
await expect(mainTabRow(page)).toHaveCount(2, { timeout: 10_000 });
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
await expect(explorerTabRow(page)).toHaveCount(1);
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
});
@@ -236,14 +233,14 @@ async function closeOnlyDraft(page: Page): Promise<void> {
}
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
await mainTabRow(page).getByRole("button", { name: "More actions", exact: true }).click();
await page.getByRole("menuitem", { name: /^Split pane right/ }).click();
await page.getByRole("button", { name: "More actions", exact: true }).click();
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
const target = await emptyPaneBox(page);
await dragChipTo(page, draftTabChip(page), {
x: target.x + target.width / 2,
y: target.y + target.height / 2,
});
await expect(mainTabRow(page)).toHaveCount(1);
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
}
async function expectNewLauncher(page: Page): Promise<void> {
@@ -3,20 +3,20 @@ import {
withExplorerPanelPlugins,
expectWorkspacePanelsInExplorerLauncher,
openWorkspacePanelFromExplorerMenu,
expectIndependentExplorerPanelTabs,
expectIndependentExplorerPanelToggles,
expectExplorerPanelWithFocusedAgent,
} from "../support/helpers/explorer-plugin-menu";
test("Explorer opens and closes workspace panel tabs independently without inheriting agent context", async ({
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
page,
}, testInfo) => {
await withExplorerPanelPlugins(page, async (workspace) => {
await test.step("the + menu offers compatible workspace panels", () =>
await test.step("the New Tab launcher offers compatible workspace panels", () =>
expectWorkspacePanelsInExplorerLauncher(page));
await test.step("the + menu opens a workspace panel in Explorer", () =>
await test.step("the rail menu offers the same workspace panels", () =>
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
await test.step("different panels and plugins keep independent selection and closing", () =>
expectIndependentExplorerPanelTabs(page, workspace));
expectIndependentExplorerPanelToggles(page, workspace));
await test.step("focusing an agent does not add agent panels to Explorer", () =>
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
});
@@ -1,9 +1,5 @@
import { expect, test } from "../support/fixtures";
import {
gotoWorkspace,
pressNewTabShortcut,
expectTabTitleFits,
} from "../support/helpers/launcher";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
ensureExplorerSidebar,
@@ -15,25 +11,6 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
async function expectExplorerActiveTabForeground(
page: Parameters<typeof ensureExplorerSidebar>[0],
) {
await openFilesPanel(page);
const main = page.getByTestId("workspace-pane-main");
await main.hover();
const activeMainLabel = main
.getByTestId("workspace-tabs-row")
.locator('[aria-selected="true"]')
.getByText("New tab", { exact: true });
const foreground = await activeMainLabel.evaluate((element) => getComputedStyle(element).color);
const activeFiles = explorerSidebar(page).getByRole("button", {
name: "Browse workspace files",
exact: true,
});
await expect(activeFiles).toHaveAttribute("aria-selected", "true");
await expect(activeFiles.getByText("Files", { exact: true })).toHaveCSS("color", foreground);
}
test.describe("Explorer sidebar", () => {
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
page,
@@ -49,14 +26,14 @@ test.describe("Explorer sidebar", () => {
.count();
const explorer = await ensureExplorerSidebar(page);
await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible();
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(1);
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
await expectExplorerActiveTabForeground(page);
await openFilesPanel(page);
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await explorer.getByTestId("workspace-tab-changes_tree").click();
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
await page.getByTestId("workspace-explorer-toggle").first().click();
@@ -69,181 +46,3 @@ test.describe("Explorer sidebar", () => {
}
});
});
async function launchExplorerPanel(
page: Parameters<typeof ensureExplorerSidebar>[0],
name: string,
) {
const explorer = explorerSidebar(page);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await page.getByRole("menuitem", { name: new RegExp(`^${name}`) }).click();
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await expect(page.getByRole("menuitem", { name: new RegExp(`^${name}`) })).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function closeExplorerFilesFromContextMenu(
page: Parameters<typeof ensureExplorerSidebar>[0],
) {
const explorer = explorerSidebar(page);
const files = explorer.getByRole("button", { name: "Browse workspace files", exact: true });
await files.hover();
await expect(explorer.getByTestId("workspace-files-close")).toHaveCount(0);
await explorer.getByTestId("workspace-tab-changes_tree").hover();
await expect(explorer.getByTestId("workspace-working-diff-close-changes_tree")).toHaveCount(0);
await files.click({ button: "right", position: { x: 12, y: 13 } });
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect(files).toHaveCount(0);
}
async function expectWorkspaceCloseOnHover(page: Parameters<typeof ensureExplorerSidebar>[0]) {
const main = page.getByTestId("workspace-pane-main");
const tab = main.locator('[data-testid^="workspace-tab-"][aria-selected="true"]');
const close = main.getByRole("button", { name: "Close", exact: true });
await explorerSidebar(page).hover();
await expect(close.locator("..")).toHaveCSS("opacity", "0");
await tab.hover();
await expect(close.locator("..")).toHaveCSS("opacity", "1");
}
async function closeOtherExplorerTabs(page: Parameters<typeof ensureExplorerSidebar>[0]) {
const files = explorerSidebar(page).getByRole("button", {
name: "Browse workspace files",
exact: true,
});
await files.click({ button: "right", position: { x: 12, y: 13 } });
const confirmation = page.waitForEvent("dialog").then((dialog) => {
expect(dialog.message()).toContain("close 1 tab");
return dialog.accept();
});
await page.getByRole("menuitem", { name: "Close other tabs", exact: true }).click();
await confirmation;
}
test("Explorer keeps Files and Changes close actions in the context menu", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-shared-tabs-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const main = page.getByTestId("workspace-pane-main");
const mainTabsBefore = await main.getByTestId("workspace-tabs-row").getByRole("button").count();
const explorer = await ensureExplorerSidebar(page);
await expectTabTitleFits(page, "Files", { min: 64, max: 90 });
await test.step("Explorer's + menu excludes Agent and terminal profiles", async () => {
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0);
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible();
const entries = await menu.getByRole("menuitem").allTextContents();
expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan(
entries.findIndex((entry) => entry.startsWith("Diff")),
);
await page.keyboard.press("Escape");
await main.getByTestId("workspace-new-tab-button").click();
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toBeVisible();
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
});
await test.step("Files and Changes omit X, and + restores Files after context-menu close", async () => {
await expectWorkspaceCloseOnHover(page);
await closeExplorerFilesFromContextMenu(page);
await launchExplorerPanel(page, "Files");
await expect(
explorer.getByRole("button", { name: "Browse workspace files", exact: true }),
).toBeVisible();
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
});
await test.step("standard bulk-close menus affect only Explorer tabs", async () => {
await closeOtherExplorerTabs(page);
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toHaveCount(0);
await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible();
await expect(main.getByTestId("workspace-tabs-row").getByRole("button")).toHaveCount(
mainTabsBefore,
);
await launchExplorerPanel(page, "Changes");
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
});
await test.step("Cmd+T still belongs to the focused workspace pane", async () => {
await pressNewTabShortcut(page);
await expect(
main.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
).toBeVisible();
await expect(explorer.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
});
await testInfo.attach("shared-explorer-tabs", {
body: await page.screenshot(),
contentType: "image/png",
});
} finally {
await workspace.cleanup();
}
});
async function dragAgentIntoExplorer(
page: Parameters<typeof ensureExplorerSidebar>[0],
agentId: string,
) {
const source = page
.getByTestId("workspace-pane-main")
.getByTestId(`workspace-tab-agent_${agentId}`);
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
const sourceBox = await source.boundingBox();
const destinationBox = await destination.boundingBox();
if (!sourceBox || !destinationBox)
throw new Error("Tab drag requires visible source and destination");
const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 12, start.y + 4);
await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, {
steps: 20,
});
await page.mouse.up();
}
test("agents can still be dragged into Explorer when absent from its + menu", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
const explorer = await ensureExplorerSidebar(page);
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Dragged agent",
modeId: "load-test",
model: "ten-second-stream",
});
const main = page.getByTestId("workspace-pane-main");
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
await dragAgentIntoExplorer(page, agent.id);
await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await testInfo.attach("agent-in-explorer", {
body: await page.screenshot(),
contentType: "image/png",
});
} finally {
await workspace.cleanup();
}
});
@@ -64,8 +64,10 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
const toggle = visible(page, "workspace-explorer-toggle").first();
await toggle.click();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
await expect(explorerSidebar(page).getByTestId("workspace-tab-changes_tree")).toBeVisible();
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
).toBeVisible();
await toggle.click();
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
@@ -100,7 +102,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await test.step("open the explorer so both sides are showing", async () => {
await toggle.click();
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
await expect(agentList).toHaveCount(1);
});
@@ -114,7 +116,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
await page.keyboard.press(`${modifier}+.`);
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
});
} finally {
await workspace.cleanup();
+1 -36
View File
@@ -96,36 +96,6 @@ 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,
}) => {
@@ -309,12 +279,7 @@ test.describe("CodeMirror workspace file editing", () => {
await openAgentRoute(page, session);
await openWorkspaceFile(page, "target.ts");
await expect(
page
.locator('[data-testid^="workspace-pane-"]')
.getByTestId("workspace-tabs-row")
.filter({ visible: true }),
).toHaveCount(2);
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
await page
.getByTestId(`workspace-tab-agent_${session.agentId}`)
@@ -1,111 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { openHostSection, selectSettingsHost } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import {
startRestartableHostDaemon,
type RestartableHostDaemon,
} from "../support/helpers/versioned-host-daemon";
const PREVIOUS_VERSION = "0.8.0";
const UPDATED_VERSION = "0.9.1";
const HOST_LABEL = "Version restart QA";
const NO_RELOAD_MARKER = "before-restart";
interface ReloadMarkerWindow {
__paseoE2eHostVersionMarker?: string;
}
test.describe.configure({ timeout: 120_000 });
let hostDaemon: RestartableHostDaemon | null = null;
test.afterEach(async () => {
await hostDaemon?.dispose();
hostDaemon = null;
});
test("host page shows the restarted daemon's version without reloading", async ({ page }) => {
const host = await startRestartableHostDaemon(PREVIOUS_VERSION);
hostDaemon = host;
const otherHostVersion =
await test.step("the connected host reports its current daemon version", async () => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: host.serverId,
label: HOST_LABEL,
port: host.port,
});
const version = await readHelpHostVersion(page, getServerId());
await expectHelpHostVersion(page, host.serverId, PREVIOUS_VERSION);
await openHostPage(page, host);
await expect(hostIdentity(page)).toContainText("Online");
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
await markPageForNoReloadCheck(page, NO_RELOAD_MARKER);
return version;
});
await test.step("the last known version remains on the host page when the host stops", async () => {
await host.stop();
await expect(hostIdentity(page)).toContainText("Error");
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
});
await test.step("the host daemon restarts on the new version", async () => {
await host.restartWithVersion(UPDATED_VERSION);
});
await test.step("the host page badge follows the new version", async () => {
await expect(hostVersionBadge(page, UPDATED_VERSION)).toBeVisible({ timeout: 30_000 });
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toHaveCount(0);
await page.getByRole("button", { name: "Back" }).click();
await expectHelpHostVersion(page, host.serverId, UPDATED_VERSION);
expect(await readHelpHostVersion(page, getServerId())).toBe(otherHostVersion);
await expectNoReloadSinceMarker(page);
});
});
async function readHelpHostVersion(page: Page, serverId: string): Promise<string> {
await page.getByTestId("sidebar-help").click();
const row = page.getByTestId(`sidebar-help-host-version-${serverId}`);
await expect(row).toBeVisible();
const text = await row.innerText();
await page.keyboard.press("Escape");
return text;
}
async function expectHelpHostVersion(page: Page, serverId: string, version: string): Promise<void> {
const text = await readHelpHostVersion(page, serverId);
expect(text).toContain(`v${version}`);
}
async function openHostPage(page: Page, host: RestartableHostDaemon): Promise<void> {
await openSettings(page);
await selectSettingsHost(page, host.serverId);
await openHostSection(page, host.serverId, "host");
}
function hostIdentity(page: Page) {
return page.getByTestId("host-page-identity");
}
function hostVersionBadge(page: Page, version: string) {
return hostIdentity(page).getByText(`v${version}`, { exact: true });
}
async function markPageForNoReloadCheck(page: Page, marker: string): Promise<void> {
// The marker has to cross into the page as an argument: the callback runs in the browser, so a
// captured Node-side constant is not defined there.
await page.evaluate((value) => {
(window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker = value;
}, marker);
}
async function expectNoReloadSinceMarker(page: Page): Promise<void> {
const marker = await page.evaluate(
() => (window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker ?? null,
);
expect(marker).toBe(NO_RELOAD_MARKER);
}
@@ -4,7 +4,7 @@ import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { test, type Page } from "../support/fixtures";
import { ImportSessionFlow } from "../support/helpers/import-session";
import {
connectNewWorkspaceDaemonClient,
@@ -49,7 +49,6 @@ interface ImportFlowScenario {
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
importSessionTimestamp: number;
repoCleanup(): Promise<void>;
unrelatedCleanup(): Promise<void>;
}
@@ -95,7 +94,7 @@ test.beforeAll(async () => {
};
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
const importSessionId = "fixture-custom-title";
const importSessionTimestamp = await seedClaudeSessions({
await seedClaudeSessions({
projectRoot: repo.path,
worktreeDirectory,
unrelatedDirectory: unrelated.path,
@@ -109,7 +108,6 @@ test.beforeAll(async () => {
worktreeDirectory,
unrelatedDirectory: unrelated.path,
importSessionId,
importSessionTimestamp,
repoCleanup: repo.cleanup,
unrelatedCleanup: unrelated.cleanup,
};
@@ -124,29 +122,17 @@ test.afterAll(async () => {
await rm(claudeConfigDirectory, { recursive: true, force: true });
});
test("an open Import Session row keeps its age current", async ({ page }) => {
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await flow.openGlobally();
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
await expect(row).toContainText("1m ago");
await page.clock.fastForward("03:00");
await expect(row).toContainText("4m ago");
});
test("captures the compact import-session journey", async ({ page }, testInfo) => {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await test.step("the new workspace screen offers import at the top", async () => {
await flow.revealNewWorkspaceEntryPoint();
await capture(page, testInfo, "01-mobile-new-workspace-import.png");
await test.step("the mobile sidebar exposes import in its footer", async () => {
await flow.revealMobileEntryPoint();
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
});
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
await flow.openFromNewWorkspace();
await flow.openGlobally();
await flow.expectScope("Sessions on", false);
await flow.expectRows({
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
@@ -239,7 +225,7 @@ async function seedClaudeSessions(input: {
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
}): Promise<number> {
}): Promise<void> {
const sessions = [
{
cwd: input.projectRoot,
@@ -304,5 +290,4 @@ async function seedClaudeSessions(input: {
const timestamp = new Date(newest - index * 60_000);
await utimes(sessionPath, timestamp, timestamp);
}
return newest;
}
@@ -337,7 +337,7 @@ test.describe("Tab transitions (no flash)", () => {
expect(counts.every((count) => count === initialCount)).toBe(true);
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
});
test("Terminal transition completes within visual budget", async ({ page }) => {
@@ -1,5 +1,5 @@
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
@@ -36,18 +36,9 @@ function ModalBody({ onSaved }) {
</View>;
}
function Surface({ playAudio }) {
const [audioStatus, setAudioStatus] = useState("Audio ready");
async function play(base64) {
setAudioStatus("Playing audio");
try { await playAudio({ base64, mimeType: "audio/wav" }); setAudioStatus("Audio finished"); }
catch { setAudioStatus("Audio rejected"); }
}
function Surface() {
const [open, setOpen] = useState(false);
return <View>
<Text>{audioStatus}</Text>
<Pressable accessibilityRole="button" onPress={() => play(AUDIO_BASE64)}><Text>Play plugin audio</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => play("UklGRg==")}><Text>Play invalid audio</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
<View style={{ flexDirection: "row" }}>
<Icon name="Pencil" size={18} />
@@ -68,7 +59,7 @@ function Surface({ playAudio }) {
}
export default function contribute(plugin) {
plugin.addSurface("main", () => <Surface playAudio={plugin.playAudio} />);
plugin.addSurface("main", Surface);
plugin.addSidebarItem({
id: "main",
title: "Host UI",
@@ -132,11 +123,7 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
const audio = await readFile(path.resolve(__dirname, "../../assets/audio/thinking-tone.wav"));
await writeFile(
path.join(directory, "index.client.tsx"),
`const AUDIO_BASE64 = ${JSON.stringify(audio.toString("base64"))};\n${PLUGIN_SOURCE}`,
);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
@@ -144,12 +131,6 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
await useNonCompactLayout(page);
await openHostUiPlugin(page);
await test.step("plugin audio finishes, rejects corrupt files, and recovers", async () => {
await playPluginAudio(page, "Play plugin audio", "Audio finished");
await playPluginAudio(page, "Play invalid audio", "Audio rejected");
await playPluginAudio(page, "Play plugin audio", "Audio finished");
});
await test.step("non-compact layouts use a centered dialog", async () => {
await openPluginModal(page);
await expectCenteredPluginDialog(page);
@@ -172,8 +153,3 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
await rm(directory, { recursive: true, force: true });
}
});
async function playPluginAudio(page: Page, button: string, result: string) {
await page.getByRole("button", { name: button, exact: true }).click();
await expect(page.getByText(result, { exact: true })).toBeVisible();
}
@@ -1,4 +1,3 @@
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";
@@ -558,7 +557,7 @@ async function retryGitInstallAndInspectRows(
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
`Plugin directory does not exist: ${missingSource}`,
"Plugin source is neither an existing directory nor a Git URL",
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
@@ -656,27 +655,3 @@ 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 });
}
@@ -14,7 +14,6 @@ import {
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
@@ -44,11 +43,6 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
await discardTitle(page);
});
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
await openDisplaySettings(page);
await returnToPluginsPage(page);
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -1,483 +0,0 @@
import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
BOTS_PLUGIN_ID,
COMPACT,
LEGACY_PLUGIN_ID,
SHOWCASE_PLUGIN_ID,
WIDE,
closeScreen,
expectRowActive,
footerItem,
headerRow,
installSidebarPlugins,
popoverBox,
runCommand,
visibleTestId,
} from "../support/helpers/plugin-sidebar-items";
import {
leaveSettings,
openSidebarNavSettings,
seedSidebarFooterPreferences,
setFooterItemVisible,
} from "../support/helpers/sidebar-nav-settings";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
const APP_SETTINGS_KEY = "@paseo:app-settings";
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
async function qaScreenshot(page: Page, name: string, area?: Locator) {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
// Let popover, sheet and drawer animations settle so the image shows the final state.
await page.waitForTimeout(600);
// Expo's fast-refresh indicator sits over the footer's Hosts icon.
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
const file = path.join(directory, `${name}.png`);
if (area) await area.screenshot({ path: file });
else await page.screenshot({ path: file });
}
/** Footer rows sit above the fixed bottom line, spanning it from Add project to Settings. */
async function expectFooterRow(page: Page, row: Locator) {
// Polled: on compact the drawer is still sliding in when the row first shows.
await expect
.poll(async () => {
const addProject = (await visibleTestId(page, "sidebar-add-project").boundingBox())!;
const settings = (await visibleTestId(page, "sidebar-settings").boundingBox())!;
const box = (await row.boundingBox())!;
return {
aboveBottomLine: box.y + box.height <= addProject.y,
alignedLeft: Math.abs(box.x - addProject.x) < 1,
alignedRight: Math.abs(box.x + box.width - (settings.x + settings.width)) < 1,
};
})
.toEqual({ aboveBottomLine: true, alignedLeft: true, alignedRight: true });
}
function botRow(page: Page, rowId: string): Locator {
return headerRow(page, BOTS_PLUGIN_ID, `bots-${rowId}`);
}
async function boxOf(locator: Locator) {
const result = await locator.boundingBox();
expect(result).not.toBeNull();
return result!;
}
/** The open screen's header title; a pushed screen keeps the one below it mounted. */
function screenTitle(page: Page): Locator {
return page.locator('[data-testid="plugin-surface-title"]:visible').last();
}
async function expectBotScreen(page: Page, botId: string) {
await expect(page).toHaveURL(
new RegExp(`/plugin/${BOTS_PLUGIN_ID}/surface/bot\\?param\\.botId=${botId}$`),
);
await expect(page.getByText(`Bot screen: ${botId}`, { exact: true })).toBeVisible();
// The plugin titles the screen from its params: the bot's name.
await expect(screenTitle(page)).toHaveText(botId === "bot-1" ? "Bot 1" : "Bot 2");
await expectRowActive(botRow(page, "bot-1"), botId === "bot-1");
await expectRowActive(botRow(page, "bot-2"), botId === "bot-2");
await expectRowActive(botRow(page, "status"), false);
}
/** The popover opens to the right of `row`, level with it, and away from `elsewhere`. */
async function expectPopoverBeside(page: Page, text: string, row: Locator, elsewhere: Locator) {
const popover = await popoverBox(page, text);
const rowBox = await boxOf(row);
const otherBox = await boxOf(elsewhere);
expect(popover.x).toBeGreaterThanOrEqual(rowBox.x + rowBox.width);
expect(Math.abs(popover.y - rowBox.y)).toBeLessThan(40);
expect(Math.abs(popover.y - otherBox.y)).toBeGreaterThan(20);
}
/** Subscription ids the daemon confirms released, collected from the page's websocket. */
function trackReleasedSubscriptions(page: Page): Set<string> {
const released = new Set<string>();
page.on("websocket", (socket) =>
socket.on("framereceived", ({ payload }) => collectRelease(released, payload)),
);
return released;
}
function collectRelease(released: Set<string>, payload: string | Buffer) {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.message?.type === "subscription.release.response")
released.add(frame.message.payload.subscriptionId);
}
async function expectReleased(released: Set<string>, subscriptionId: string) {
await expect.poll(() => released.has(subscriptionId)).toBe(true);
}
function readAlertsObservation(page: Page): Promise<string | null> {
return page.evaluate(
() => (globalThis as { __botAlertsObservation?: string }).__botAlertsObservation ?? null,
);
}
/** The id of the observation the Bot alerts item opened after `previous`. */
async function nextAlertsObservation(page: Page, previous: string | null): Promise<string> {
await expect.poll(() => readAlertsObservation(page)).not.toBe(previous);
return (await readAlertsObservation(page))!;
}
function sidebarFooter(page: Page): Locator {
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
}
test.describe("Plugin sidebar items", () => {
let workspaceId: string;
let cleanup: () => Promise<void>;
test.beforeEach(async () => {
const workspace = await seedWorkspace({ repoPrefix: "plugin-sidebar-items-" });
const plugins = await installSidebarPlugins();
workspaceId = workspace.workspaceId;
cleanup = async () => {
await plugins.cleanup();
await workspace.cleanup();
};
});
test.afterEach(async () => {
await cleanup();
});
test("a plugin's screen, header row, footer popover and command work together", async ({
page,
}) => {
test.setTimeout(180_000);
// The Usage item shows only with summary data, once it is turned on.
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
const sync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(sync).toBeVisible();
await test.step("a throwing item renders nothing and leaves the sidebar working", async () => {
await expect(visibleTestId(page, `plugin-sidebar-${SHOWCASE_PLUGIN_ID}-broken`)).toHaveCount(
0,
);
await expect(footerItem(page, SHOWCASE_PLUGIN_ID, "broken")).toHaveCount(0);
await expect(page.getByText("Plugin failed", { exact: false })).toHaveCount(0);
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
});
await test.step("the header row highlights while its screen is open", async () => {
await expectRowActive(row, false);
await row.click();
await expect(page).toHaveURL(new RegExp(`/plugin/${SHOWCASE_PLUGIN_ID}/surface/deploys`));
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await expect(screenTitle(page)).toHaveText("Deploys");
await expectRowActive(row, true);
await qaScreenshot(page, "phase2-desktop-sidebar-screen");
await closeScreen(page);
await expectRowActive(row, false);
});
await test.step("the trailing button presses on its own", async () => {
await page.getByRole("button", { name: "Refresh deploys", exact: true }).click();
await expect(row).toHaveAccessibleName("Deploys (refreshed 1)");
await expect(page).not.toHaveURL(/\/plugin\//);
await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0);
});
await test.step("the footer row renders as a row above the bottom line", async () => {
await expect(sync).toHaveAccessibleName("Sync");
await expectFooterRow(page, sync);
await expect(visibleTestId(page, "sidebar-usage")).toBeVisible();
await qaScreenshot(page, "phase5-desktop-footer", sidebarFooter(page));
});
await test.step("the footer row opens a popover anchored to it", async () => {
await sync.click();
const popover = await popoverBox(page);
const button = (await sync.boundingBox())!;
await expect(page.getByText("Presentation: wide", { exact: true })).toBeVisible();
expect(popover.y + popover.height).toBeLessThanOrEqual(button.y);
expect(button.y - popover.y).toBeLessThan(300);
expect(Math.abs(popover.x - button.x)).toBeLessThan(80);
await qaScreenshot(page, "phase2-desktop-popover");
await page.getByRole("button", { name: "Open deploys from popover", exact: true }).click();
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
await closeScreen(page);
});
await test.step("the command center item opens the screen", async () => {
await runCommand(page, "Open deploys");
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await expectRowActive(row, true);
await closeScreen(page);
});
await test.step("on a compact layout the popover is a bottom sheet", async () => {
await page.setViewportSize(COMPACT);
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(compactSync).toBeInViewport();
await expectFooterRow(page, compactSync);
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
await qaScreenshot(page, "phase2-compact-sidebar");
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
await compactSync.click();
await expect(page.getByText("Presentation: compact", { exact: true })).toBeVisible();
const sheet = await popoverBox(page);
expect(sheet.y).toBeGreaterThan(COMPACT.height / 2);
await qaScreenshot(page, "phase2-compact-sheet");
await page.getByRole("button", { name: "Close sync details", exact: true }).click();
await expect(page.getByText("Sync details", { exact: true })).toHaveCount(0);
});
});
test("a header row's shortcut hint presses the row", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const newWorkspace = visibleTestId(page, "sidebar-global-new-workspace");
await expect(newWorkspace).toBeVisible({ timeout: 30_000 });
await newWorkspace.hover();
const hint = page.getByText("Ctrl+N", { exact: true }).locator("visible=true");
await expect(hint).toBeVisible();
await qaScreenshot(page, "phase7-header-row-hint", newWorkspace.locator("xpath=../.."));
await hint.click();
await expect(page).toHaveURL(/\/new(\?|$)/);
});
test("a legacy plugin keeps its row, icon, order and visibility", async ({ page }) => {
test.setTimeout(180_000);
await page.addInitScript(
({ key, sidebarNavItems }) => localStorage.setItem(key, JSON.stringify({ sidebarNavItems })),
{
key: APP_SETTINGS_KEY,
sidebarNavItems: [
{ key: `plugin:${LEGACY_PLUGIN_ID}:entry`, visible: true },
{ key: "new-workspace", visible: true },
{ key: `plugin:${LEGACY_PLUGIN_ID}:hidden`, visible: false },
],
},
);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const entry = headerRow(page, LEGACY_PLUGIN_ID, "entry");
await expect(entry).toBeVisible({ timeout: 30_000 });
let entryIcon = "";
await test.step("the saved order and visibility still apply", async () => {
await expect(entry).toHaveAccessibleName("Legacy entry");
await expect(entry.locator("svg")).toHaveCount(1);
entryIcon = await entry.locator("svg").innerHTML();
await expect(headerRow(page, LEGACY_PLUGIN_ID, "hidden")).toHaveCount(0);
const entryBox = (await entry.boundingBox())!;
const newWorkspaceBox = (await visibleTestId(
page,
"sidebar-global-new-workspace",
).boundingBox())!;
expect(entryBox.y).toBeLessThan(newWorkspaceBox.y);
});
await test.step("the row opens its surface on its own sidebar route and highlights", async () => {
await entry.click();
await expect(page).toHaveURL(new RegExp(`/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry$`));
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
await expect(page.getByTestId("plugin-surface-close")).toBeVisible();
await expect(screenTitle(page)).toHaveText("Legacy entry");
await expectRowActive(entry, true);
await closeScreen(page);
});
await test.step("remembered sidebar routes keep resolving", async () => {
await page.goto(
`/h/${encodeURIComponent(getServerId())}/plugin/${LEGACY_PLUGIN_ID}/sidebar/entry`,
);
await expect(page.getByText("Legacy screen body", { exact: true })).toBeVisible();
await expectRowActive(headerRow(page, LEGACY_PLUGIN_ID, "entry"), true);
});
await test.step("links saved before a plugin moved to addScreen open the same-id screen", async () => {
const host = encodeURIComponent(getServerId());
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/sidebar/bot?param.botId=bot-2`);
await expect(page.getByText("Bot screen: bot-2", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(screenTitle(page)).toHaveText("Bot 2");
await page.goto(`/h/${host}/plugin/${BOTS_PLUGIN_ID}/bot`);
await expect(page).toHaveURL(new RegExp(`/plugin/${BOTS_PLUGIN_ID}/sidebar/bot$`));
await expect(screenTitle(page)).toHaveText("Bot");
});
await test.step("settings shows a legacy row's own icon and one generic icon for the rest", async () => {
await openSidebarNavSettings(page);
const legacyIcon = page
.getByTestId(`sidebar-nav-item-plugin:${LEGACY_PLUGIN_ID}:entry`)
.locator("svg")
.first();
const showcaseIcon = page
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:deploys`)
.locator("svg")
.first();
const footerIcon = page
.getByTestId("sidebar-nav-section-footer")
.getByTestId(`sidebar-nav-item-plugin:${SHOWCASE_PLUGIN_ID}:sync`)
.locator("svg")
.first();
const markup = await showcaseIcon.innerHTML();
expect(await footerIcon.innerHTML()).toBe(markup);
expect(await legacyIcon.innerHTML()).not.toBe(markup);
// The same registered icon the sidebar row draws.
expect(await legacyIcon.innerHTML()).toBe(entryIcon);
await qaScreenshot(page, "phase2-desktop-settings-sidebar");
await qaScreenshot(
page,
"phase5-settings-sidebar-footer",
page.getByTestId("sidebar-nav-section-footer"),
);
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("sidebar-nav-section-footer").first()).toBeAttached();
await qaScreenshot(page, "phase2-compact-settings-sidebar");
});
});
test("one item renders a group of rows, and each row opens its own screen params", async ({
page,
}) => {
test.setTimeout(180_000);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const bot1 = botRow(page, "bot-1");
const bot2 = botRow(page, "bot-2");
const status = botRow(page, "status");
await expect(bot1).toBeVisible({ timeout: 30_000 });
await test.step("the rows and the separator render in order in the header", async () => {
await expect(bot1).toHaveAccessibleName("Bot 1");
await expect(bot2).toHaveAccessibleName("Bot 2");
await expect(status).toHaveAccessibleName("Bot status");
const [first, second, third] = [await boxOf(bot1), await boxOf(bot2), await boxOf(status)];
expect(second.y).toBeGreaterThan(first.y);
// The separator adds its line and margins between Bot 2 and the status row.
expect(third.y - (second.y + second.height)).toBeGreaterThan(8);
expect(second.y - (first.y + first.height)).toBeLessThan(4);
});
await test.step("pressing Bot 2 opens the bot screen and highlights only its row", async () => {
await bot2.click();
await expectBotScreen(page, "bot-2");
await qaScreenshot(page, "phase6-desktop-sidebar-bots");
});
await test.step("a reload keeps the screen and its params", async () => {
await page.reload();
await expectBotScreen(page, "bot-2");
await qaScreenshot(page, "phase6-bot-screen");
});
await test.step("Bot 1 replaces Bot 2 on the same screen, and back returns to Bot 2", async () => {
await bot1.click();
await expectBotScreen(page, "bot-1");
await page.goBack();
await expectBotScreen(page, "bot-2");
});
await test.step("a popover opened from a later row anchors to that row", async () => {
await status.click();
await expectPopoverBeside(page, "Bot status details", status, bot1);
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
await expect(page.getByText("Bot status details", { exact: true })).toHaveCount(0);
});
await test.step("a trailing button's popover anchors to its own row, not the last pressed", async () => {
await page.getByRole("button", { name: "More for Bot 2", exact: true }).click();
await expectPopoverBeside(page, "Bot 2 options", bot2, status);
await qaScreenshot(page, "phase6-desktop-popover-row");
await page.getByRole("button", { name: "Close Bot 2 options", exact: true }).click();
await expect(page.getByText("Bot 2 options", { exact: true })).toHaveCount(0);
});
await test.step("a param named like a route segment reaches the screen", async () => {
await runCommand(page, "Open bot 3 on server x");
await expect(page.getByText("Bot screen: bot-3", { exact: true })).toBeVisible();
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
// No bot named bot-3, so the title function returns its own fallback.
await expect(screenTitle(page)).toHaveText("Bot");
await page.reload();
await expect(page.getByText("serverId param: x", { exact: true })).toBeVisible();
});
await test.step("Settings lists the group as one item", async () => {
await openSidebarNavSettings(page);
const header = page.getByTestId("sidebar-nav-section-header");
await expect(
header.getByTestId(`sidebar-nav-item-plugin:${BOTS_PLUGIN_ID}:bots`),
).toHaveCount(1);
await expect(header.getByText("Bots", { exact: true })).toHaveCount(1);
await expect(header.getByText("Bot 1", { exact: true })).toHaveCount(0);
await leaveSettings(page);
});
await test.step("the compact sidebar shows the same group", async () => {
await page.setViewportSize(COMPACT);
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
await expect(botRow(page, "bot-1")).toBeInViewport();
await expect(botRow(page, "status")).toBeInViewport();
await qaScreenshot(page, "phase6-compact-sidebar-bots");
});
});
test("a plugin adds and removes a footer item after setup", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
await expect(alerts).toHaveCount(0);
await runCommand(page, "Add bot alerts");
await expect(alerts).toBeVisible();
await expect(alerts).toHaveAccessibleName("Bot alerts");
await expectFooterRow(page, alerts);
await runCommand(page, "Remove bot alerts");
await expect(alerts).toHaveCount(0);
});
test("a sidebar item's observations close when it is hidden or removed", async ({ page }) => {
test.setTimeout(120_000);
const released = trackReleasedSubscriptions(page);
const alerts = footerItem(page, BOTS_PLUGIN_ID, "alerts");
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
await expect(botRow(page, "bot-1")).toBeVisible({ timeout: 30_000 });
await runCommand(page, "Add bot alerts");
await expect(alerts).toBeVisible();
const first = await nextAlertsObservation(page, null);
expect(released.has(first)).toBe(false);
await test.step("hiding the item in Settings closes its observation", async () => {
await openSidebarNavSettings(page);
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, false);
await expectReleased(released, first);
await setFooterItemVisible(page, `plugin:${BOTS_PLUGIN_ID}:alerts`, true);
await leaveSettings(page);
});
await test.step("removing the item through its remover closes its observation", async () => {
await expect(alerts).toBeVisible();
const second = await nextAlertsObservation(page, first);
expect(released.has(second)).toBe(false);
await runCommand(page, "Remove bot alerts");
await expect(alerts).toHaveCount(0);
await expectReleased(released, second);
});
});
});
@@ -119,25 +119,6 @@ async function exerciseProviderSettingsStack(page: Page) {
}
test.describe("provider settings overlay stack", () => {
test("provider model update age says just now", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-relative-time-",
title: "Provider relative time",
});
try {
await page.clock.install({ time: Date.now() });
await page.setViewportSize(MOBILE_VIEWPORT);
await openAgentRoute(page, session);
await expectComposerVisible(page);
await openProviderSettingsFromModelSelector(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await session.cleanup();
}
});
test("provider settings covers the desktop model selector without closing it", async ({
page,
}) => {
@@ -0,0 +1,152 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
test.describe("provider usage settings", () => {
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "available",
planLabel: "Max 20x",
windows: [{ id: "session", label: "Session", usedPct: 7 }],
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
},
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
sourceLabel: "OpenUsage 0.6.27",
windows: [
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
{ id: "daily", label: "Daily", remainingPct: 30 },
],
balances: [
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
],
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
expect(usageFixture.requestCount()).toBe(0);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
});
test("refresh invalidates and refetches usage", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
});
test("one provider error does not collapse the usage list", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "error",
planLabel: null,
windows: [],
error: "Claude auth expired",
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Error", { exact: true })).toBeVisible();
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("71%")).toBeVisible();
});
});
@@ -0,0 +1,113 @@
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgent(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-usage-tooltip-",
title: "Provider usage tooltip e2e",
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
return session;
}
test.describe("provider usage tooltip", () => {
test("fetches usage when the context tooltip opens and renders the active provider", async ({
page,
}) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [
{
id: "session",
label: "Session",
usedPct: 42,
remainingPct: 58,
resetsAt: "2026-06-19T05:00:00.000Z",
},
],
},
],
},
]);
const session = await openMockAgent(page);
try {
expect(usageFixture.requestCount()).toBe(0);
await page.getByTestId("context-window-meter").hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("Test plan")).toBeVisible();
await expect(page.getByText("Session", { exact: true })).toBeVisible();
await expect(page.getByText("42%")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 41 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 64 }],
},
],
},
]);
const session = await openMockAgent(page);
try {
const meter = page.getByTestId("context-window-meter");
await meter.hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
await page.mouse.move(0, 0);
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
await meter.hover();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
} finally {
await session.cleanup();
}
});
});
@@ -1,118 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { buildSchedulesRoute } from "../../src/utils/host-routes";
interface ScheduleSeedClient {
scheduleCreate(input: {
prompt: string;
name?: string;
cadence: { type: "cron"; expression: string };
target: {
type: "new-agent";
config: {
provider: "mock";
cwd: string;
model: string;
modeId: string;
title: string;
};
};
runOnCreate: boolean;
}): Promise<{ schedule: { id: string; createdAt: string } | null; error: string | null }>;
schedulePause(input: { id: string }): Promise<{ error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
}
interface SeededSchedule {
id: string;
createdAt: number;
}
async function seedPausedSchedule(
workspace: SeededWorkspace,
name: string,
): Promise<SeededSchedule> {
const client = workspace.client as unknown as ScheduleSeedClient;
const result = await client.scheduleCreate({
prompt: "Say hello from the scheduled agent.",
name,
cadence: { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: workspace.repoPath,
model: "ten-second-stream",
modeId: "load-test",
title: name,
},
},
runOnCreate: false,
});
if (!result.schedule) {
throw new Error(result.error ?? "Failed to seed schedule");
}
const { id, createdAt } = result.schedule;
const paused = await client.schedulePause({ id });
if (paused.error) {
throw new Error(paused.error);
}
return { id, createdAt: Date.parse(createdAt) };
}
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
const client = workspace.client as unknown as ScheduleSeedClient;
const result = await client.scheduleDelete({ id });
if (result.error) {
throw new Error(result.error);
}
}
/** Opens Schedules with the browser clock frozen at `time`, so the page cannot age it. */
async function openSchedulesAt(page: Page, time: number): Promise<void> {
await page.clock.install({ time });
await page.goto(buildSchedulesRoute());
}
async function letTimePass(page: Page, duration: string): Promise<void> {
await page.clock.fastForward(duration);
}
async function expectScheduleRowText(page: Page, scheduleId: string, text: string): Promise<void> {
await expect(page.getByTestId(`schedule-row-${scheduleId}`)).toContainText(text, {
timeout: 30_000,
});
}
test.describe("Schedule relative timestamps", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
const failures: unknown[] = [];
for (const cleanup of cleanupTasks.toReversed()) {
try {
await cleanup();
} catch (error) {
failures.push(error);
}
}
cleanupTasks.length = 0;
if (failures.length > 0) {
throw new AggregateError(failures, "Test cleanup failed");
}
});
test("an idle schedule row keeps its created age current", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-relative-time-", git: false });
cleanupTasks.push(() => workspace.cleanup());
const schedule = await seedPausedSchedule(workspace, `Relative time ${Date.now()}`);
cleanupTasks.push(() => deleteSeededSchedule(workspace, schedule.id));
await openSchedulesAt(page, schedule.createdAt);
await expectScheduleRowText(page, schedule.id, "Created just now");
await letTimePass(page, "03:00");
await expectScheduleRowText(page, schedule.id, "Created 3m ago");
});
});
@@ -155,18 +155,6 @@ 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();
@@ -177,29 +165,3 @@ 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 } });
}
@@ -1,83 +1,18 @@
import { expect, test } from "../support/fixtures";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
expectPinnedUsage,
openCompactSidebar,
} from "../support/helpers/usage-sidebar-item";
import { gotoAppShell } from "../support/helpers/app";
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
import {
expectFooterIconRow,
expectFooterSeparator,
hoverFooterAddProject,
footerScreenshot,
expectFooterItemHidden,
expectFooterOrder,
expectFooterSettingsKeys,
expectSidebarItemHidden,
expectSidebarNavSettingsOrder,
expectSidebarNavSettingsRow,
expectSidebarOrder,
expectStoredSidebarNav,
leaveSettings,
moveFooterItemUp,
moveSidebarNavItemUp,
openSidebarNavSettings,
seedSidebarFooterPreferences,
seedSidebarNavPreferences,
setFooterItemVisible,
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [
{ key: "add-project", visible: false },
{ key: "usage", visible: true },
]);
await page.setViewportSize({ width: 1440, height: 900 });
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
await footerScreenshot(page, "footer-desktop-with-rows");
await hoverFooterAddProject(page);
await footerScreenshot(page, "footer-desktop-add-project-tooltip");
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await openSidebarNavSettings(page);
await expectFooterSettingsKeys(page, ["usage"]);
await setFooterItemVisible(page, "usage", false);
await leaveSettings(page);
await expectFooterSeparator(page, false);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-desktop-without-rows");
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectFooterSeparator(page, false);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-compact-without-rows");
await page.locator('[data-testid="sidebar-add-project"]:visible').hover();
await expect(page.getByTestId("sidebar-add-project-tooltip")).toHaveCount(0);
await footerScreenshot(page, "footer-compact-add-project-hover");
await openAddProjectFlow(page);
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await page.setViewportSize({ width: 1440, height: 900 });
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", true);
await leaveSettings(page);
await page.setViewportSize({ width: 390, height: 844 });
await openCompactSidebar(page);
await expectFooterSeparator(page, true);
await expectFooterIconRow(page);
await footerScreenshot(page, "footer-compact-with-rows");
});
test.describe("Sidebar items in Appearance settings", () => {
test("owner reorders and hides top-level sidebar items", async ({ page }) => {
await gotoAppShell(page);
@@ -89,8 +24,8 @@ test.describe("Sidebar items in Appearance settings", () => {
await test.step("the Sidebar section lists every item in the same order", async () => {
await openSidebarNavSettings(page);
// The section explains itself through the header's info tooltip, not a paragraph.
await expect(page.getByTestId("sidebar-nav-section-header-info")).toHaveAccessibleName(
"About Header",
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
"About Sidebar",
);
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
@@ -171,69 +106,3 @@ test.describe("Sidebar items in Appearance settings", () => {
await expectSidebarItemHidden(page, "schedules");
});
});
test.describe("Sidebar footer rows in Appearance settings", () => {
const syncKey = `plugin:${SHOWCASE_PLUGIN_ID}:sync`;
const brokenKey = `plugin:${SHOWCASE_PLUGIN_ID}:broken`;
let cleanup: () => Promise<void>;
test.beforeEach(async () => {
({ cleanup } = await installSidebarPlugins());
});
test.afterEach(async () => {
await cleanup();
});
test("owner reorders and hides footer rows; the icon row stays fixed", async ({ page }) => {
test.setTimeout(120_000);
// The Usage item shows only with summary data.
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
// Keys for the fixed footer icon buttons are ignored.
await seedSidebarFooterPreferences(page, [
{ key: "usage", visible: true },
{ key: "help", visible: false },
{ key: "hosts", visible: false },
{ key: "import", visible: false },
]);
await gotoAppShell(page);
await test.step("the Footer card lists only the footer rows", async () => {
await openSidebarNavSettings(page);
await expect(page.getByTestId("sidebar-nav-section-footer-info")).toHaveAccessibleName(
"About Footer",
);
await expectFooterSettingsKeys(page, ["usage", syncKey, brokenKey]);
await leaveSettings(page);
await expectFooterOrder(page, ["usage", syncKey]);
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
});
await test.step("moving Sync up and hiding the Usage item changes the rows", async () => {
await openSidebarNavSettings(page);
await moveFooterItemUp(page, syncKey);
await setFooterItemVisible(page, "usage", false);
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
await leaveSettings(page);
await expectFooterItemHidden(page, "usage");
await expect(
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
).toBeVisible();
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
});
await test.step("the rows keep that shape across a reload", async () => {
await page.reload();
await expect(
page.locator(`[data-testid="plugin-sidebar-footer-${SHOWCASE_PLUGIN_ID}-sync"]:visible`),
).toBeVisible({ timeout: 30_000 });
await expectFooterItemHidden(page, "usage");
await expectFooterIconRow(page);
await expectFooterSeparator(page, true);
await openSidebarNavSettings(page);
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
});
});
});
@@ -88,7 +88,6 @@ 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") });
}
@@ -107,7 +106,6 @@ 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,7 +1,6 @@
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 {
@@ -74,75 +73,3 @@ 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,11 +6,6 @@ import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
expectWorkspaceHoverCardClosed,
expectWorkspaceHoverCardOpen,
expectWorkspaceHoverCardStaysOpen,
focusWorkspaceRowWithKeyboard,
movePointerAwayFromWorkspaceHoverCard,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
} from "../support/helpers/sidebar";
@@ -409,52 +404,6 @@ 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-",
@@ -623,21 +572,18 @@ test.describe("Half-screen desktop layout", () => {
await openFilesPanel(page);
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toBeVisible();
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-pane-"]')
.getByTestId("workspace-tabs-row")
.filter({ visible: true }),
).toHaveCount(1);
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
await explorerToggle.click();
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
0,
);
await expect(
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
).toHaveCount(0);
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
} finally {

Some files were not shown because too many files have changed in this diff Show More