mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
249
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49b622bd14 | ||
|
|
36e6ccd6c7 | ||
|
|
fa0f04058d | ||
|
|
d517b3314e | ||
|
|
a8e6c57f15 | ||
|
|
1f8a4559a8 | ||
|
|
2187124642 | ||
|
|
154a088d6e | ||
|
|
71b236d38a | ||
|
|
8a4ce5a482 | ||
|
|
b3ed7363c6 | ||
|
|
c062280429 | ||
|
|
febe9611bf | ||
|
|
642d69b142 | ||
|
|
5aee41952e | ||
|
|
48329facc3 | ||
|
|
97083dd736 | ||
|
|
5c2c85ebc8 | ||
|
|
30fb66c8cb | ||
|
|
05b074764d | ||
|
|
5285b7e502 | ||
|
|
409b8f9312 | ||
|
|
485221b739 | ||
|
|
ed48655a77 | ||
|
|
cc8fe41e28 | ||
|
|
8216e86ecb | ||
|
|
79a3942393 | ||
|
|
4869214bcb | ||
|
|
ed87679c67 | ||
|
|
5eb4c8afad | ||
|
|
c7759713ec | ||
|
|
749fe3e5ae | ||
|
|
0d05584d04 | ||
|
|
42806cdbf9 | ||
|
|
15d8ba16a0 | ||
|
|
526ad1366f | ||
|
|
b5e9747e2a | ||
|
|
5f28b3cf8c | ||
|
|
42e221746c | ||
|
|
5c1be59b68 | ||
|
|
e18f9e8f43 | ||
|
|
70210f1071 | ||
|
|
a983ae0444 | ||
|
|
7dc2a3568c | ||
|
|
5375f43a05 | ||
|
|
8aec0787d2 | ||
|
|
5293ddac3f | ||
|
|
d3c76be9c1 | ||
|
|
d831c7bf33 | ||
|
|
8608525496 | ||
|
|
ac83aa0785 | ||
|
|
087cc71c00 | ||
|
|
3b5bbb139b | ||
|
|
ee0b0ef7c6 | ||
|
|
6a794b6572 | ||
|
|
1d0df5c0b7 | ||
|
|
73d25317a4 | ||
|
|
6c985769be | ||
|
|
bd3986d964 | ||
|
|
6166a7aca5 | ||
|
|
31cd18e748 | ||
|
|
05e94ab52e | ||
|
|
b5b43edd65 | ||
|
|
8498c74169 | ||
|
|
a52bdb106c | ||
|
|
70df09daeb | ||
|
|
e10f6d2e2c | ||
|
|
95774ba71f | ||
|
|
37b66897a9 | ||
|
|
4aa2f2817d | ||
|
|
bdb8e13221 | ||
|
|
06213e49bf | ||
|
|
d30e99c858 | ||
|
|
e939dd4c7a | ||
|
|
15f1431416 | ||
|
|
b701cb2dcd | ||
|
|
4893629ff5 | ||
|
|
e0a0cf990d | ||
|
|
7b0cace991 | ||
|
|
20a887f4cc | ||
|
|
a7c9c05bef | ||
|
|
1f5f5d384e | ||
|
|
695ecb2812 | ||
|
|
7a64eef491 | ||
|
|
9d9d9af993 | ||
|
|
29c198f955 | ||
|
|
318a84a570 | ||
|
|
364e6456e0 | ||
|
|
d070db15b3 | ||
|
|
4417d7c47a | ||
|
|
8332f4d1ff | ||
|
|
7b7685678d | ||
|
|
5715b5ba62 | ||
|
|
9952c55983 | ||
|
|
e9e479c6ba | ||
|
|
2553ab4d82 | ||
|
|
ad56659a89 | ||
|
|
b0c9de0c90 | ||
|
|
8cccc1f6fa | ||
|
|
3a9304607c | ||
|
|
b1a3b0772d | ||
|
|
5599f9e567 | ||
|
|
4e9a4582b7 | ||
|
|
3fea128382 | ||
|
|
53ee9cd993 | ||
|
|
767b0e970b | ||
|
|
d3c902e9fa | ||
|
|
60aff2346c | ||
|
|
5de45e2086 | ||
|
|
5764863f63 | ||
|
|
4b45bc9fc1 | ||
|
|
a8f5d969b8 | ||
|
|
23c4404b95 | ||
|
|
2838922ade | ||
|
+1 |
264f6e1cca | ||
|
|
cc744cc669 | ||
|
|
aff476b01b | ||
|
|
d596861891 | ||
|
|
be2fb1cab9 | ||
|
|
87818812ad | ||
|
|
b9ac1d4982 | ||
|
|
d91171954b | ||
|
|
f232d23e9a | ||
|
|
68e722ec2e | ||
|
|
3b41183606 | ||
|
|
690f5298cb | ||
|
|
68938b668e | ||
|
|
560e478ef5 | ||
|
|
4965af219f | ||
|
|
940dbfd242 | ||
|
|
4340607093 | ||
|
|
849a876bce | ||
|
|
f4ba16a0b3 | ||
|
|
9d010211a2 | ||
|
|
8b5201fedf | ||
|
|
05874e289a | ||
|
|
d0a30ed4da | ||
|
|
cf4509631b | ||
|
|
b85b44aea1 | ||
|
|
7bb7d4ed08 | ||
|
|
dd5df9ec61 | ||
|
|
8de52a3c99 | ||
|
|
dffde6ae8b | ||
|
|
a43c8d888f | ||
|
|
792715e762 | ||
|
|
827178df90 | ||
|
|
c54f20e53e | ||
|
|
c7e7e52b0c | ||
|
|
c481ecf3e1 | ||
|
|
dfc9add774 | ||
|
|
da48803a43 | ||
|
|
7f5d32cdd5 | ||
|
|
dec2d861ec | ||
|
|
30178c4f58 | ||
|
|
52d345db7f | ||
|
|
a50a476009 | ||
|
|
6ca001c3e4 | ||
|
|
78a0f093eb | ||
|
|
d7b7016cc9 | ||
|
|
e1c769c018 | ||
|
|
c081e0350d | ||
|
|
e2de6df2e2 | ||
|
|
c906c2f4a4 | ||
|
|
fffd76e8fb | ||
|
|
d2e2154a8a | ||
|
|
cb9654a65c | ||
|
|
3dc17b7f30 | ||
|
|
76a9781ba5 | ||
|
|
a272a22d7e | ||
|
|
26f227bed9 | ||
|
|
aa3ffaeb6c | ||
|
|
513f2a9ea2 | ||
|
|
8cd989529e | ||
|
|
04c3e003f8 | ||
|
|
e68553f759 | ||
|
|
dc9799f6fc | ||
|
|
43a2a7969c | ||
|
|
90d978ab68 | ||
|
|
a048094da4 | ||
|
|
315803688d | ||
|
|
bf4dc22c2f | ||
|
|
5c9f767d51 | ||
|
|
84304b5532 | ||
|
|
28507224dc | ||
|
|
db4fd33406 | ||
|
|
e3c853df58 | ||
|
|
ea7a741851 | ||
|
|
c67b7158b4 | ||
|
|
91dc927335 | ||
|
|
49f9cec6be | ||
|
|
e07da55f81 | ||
|
|
27d6a71856 | ||
|
|
bbf8cce3f7 | ||
|
|
6016ed7059 | ||
|
|
8e858f0e35 | ||
|
|
fbc83613c0 | ||
|
|
c356394bfa | ||
|
|
e6085c1e98 | ||
|
|
1bf5312297 | ||
|
|
7fa78244fd | ||
|
|
f4efdbded7 | ||
|
|
c976e2e5a5 | ||
|
|
b2bb512b31 | ||
|
|
067937bace | ||
|
|
d615d3d424 | ||
|
|
fbe5005aa1 | ||
|
|
e998e0a082 | ||
|
|
ae42b0afb0 | ||
|
|
829cc5e173 | ||
|
|
48384cafdf | ||
|
|
2c7b38bc7e | ||
|
|
222d45a2fc | ||
|
|
373da70694 | ||
|
|
c3e1e084a0 | ||
|
|
9978988e35 | ||
|
|
8768500fc0 | ||
|
|
89073d4c5c | ||
|
|
ec43e90670 | ||
|
|
897a0abb1a | ||
|
|
c4771ca467 | ||
|
|
05bfffad41 | ||
|
|
faee1cd952 | ||
|
|
ba4595d4e8 | ||
|
|
34c9fd03c9 | ||
|
|
9a3f3a0dc6 | ||
|
|
d6861f81e5 | ||
|
|
3054f80050 | ||
|
|
6d37f7fd9f | ||
|
|
94ab368e4e | ||
|
|
d161b5987d | ||
|
|
90737e1dec | ||
|
|
3eaf0be0b2 | ||
|
|
8269a0ea78 | ||
|
|
5303313632 | ||
|
|
81ad00790f | ||
|
|
290306fd16 | ||
|
|
3a36cc33c6 | ||
|
|
17c5050216 | ||
|
|
5db3bc82fd | ||
|
|
3dbbbb535e | ||
|
|
a3fc59e818 | ||
|
|
bc5bc969c8 | ||
|
|
8186585201 | ||
|
|
eb665a55a8 | ||
|
|
d22737c77a | ||
|
|
aeb98f813a | ||
|
|
c8a7667a03 | ||
|
|
4c051388e1 | ||
|
|
91d9cf1dbd |
@@ -34,6 +34,7 @@ hub:
|
||||
- "packages/server/src/server/hub/**"
|
||||
|
||||
server:
|
||||
- "plugins/**"
|
||||
- "packages/server/**"
|
||||
- "packages/app/e2e/support/fixtures/recording.*"
|
||||
|
||||
|
||||
@@ -117,9 +117,15 @@ 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
|
||||
|
||||
@@ -169,6 +175,10 @@ 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
|
||||
@@ -180,6 +190,37 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps: *server_test_steps
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
||||
# bound runtime.
|
||||
server-tests-macos:
|
||||
name: server-tests (macos-14, file observation)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run file observation tests
|
||||
run: |
|
||||
npm run test:unit --workspace=@getpaseo/server -- \
|
||||
src/server/file-observer \
|
||||
src/server/workspace-git-service.observation.test.ts \
|
||||
src/server/workspace-git-service.observation.integration.test.ts \
|
||||
--bail=1
|
||||
|
||||
desktop-tests-ubuntu:
|
||||
name: desktop-tests (ubuntu-latest)
|
||||
needs: changes
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- "CHANGELOG.md"
|
||||
- "public-docs/**"
|
||||
- "packages/website/**"
|
||||
- "packages/protocol/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "patches/**"
|
||||
@@ -29,7 +30,10 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root
|
||||
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root
|
||||
|
||||
- name: Build protocol
|
||||
run: npm run build:protocol
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/website
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "plugins/**"
|
||||
- "nix/**"
|
||||
- ".github/workflows/desktop-packages.yml"
|
||||
|
||||
|
||||
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
+256
@@ -1,5 +1,261 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.3 - 2026-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
|
||||
## 0.11.0-beta.2 - 2026-10-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
|
||||
## 0.11.0-beta.1 - 2026-10-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
||||
- Added Antigravity as a provider, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m))
|
||||
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
|
||||
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
|
||||
- Added Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
|
||||
- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
|
||||
- Added provider option defaults in `config.json` under `agents.providers.<id>.options`, with per-agent overrides, for every provider ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
||||
- Added sidebar header and footer items and screens to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- Added `activeTurnBehavior` to the SDK's `send()` and `run()` so a caller can steer instead of interrupt an active turn ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||
- Added 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))
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- 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 OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx))
|
||||
- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD))
|
||||
- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil))
|
||||
- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork))
|
||||
- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Fixed 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 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 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 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 opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
|
||||
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
|
||||
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
|
||||
- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
|
||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
|
||||
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
|
||||
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
|
||||
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
|
||||
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602))
|
||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Fixed a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772))
|
||||
- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang))
|
||||
- Fixed iPad chat text keeping its narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
|
||||
- Fixed 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
|
||||
|
||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
|
||||
|
||||
## 0.10.2 - 2026-09-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674))
|
||||
- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679))
|
||||
- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696))
|
||||
- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609))
|
||||
|
||||
## 0.10.1 - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
|
||||
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
|
||||
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
|
||||
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
|
||||
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
|
||||
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
|
||||
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
|
||||
|
||||
## 0.10.0 - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added OpenCode v2 support, selected automatically from the installed `opencode` version ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep))
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added password checks to relay connections, rejecting an incorrect daemon password; a follow-up release will require the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added a password field to Add host, pairing links, and QR pairing for password-protected daemons ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added `relay://` connection strings to `paseo daemon pair` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added OMP Ask option descriptions to question cards on OMP 17.4.2 and newer ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the CLI and desktop app to connect to a password-protected daemon on the same machine without asking for the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Changed a host rejected for its password to show "Password required" or "Incorrect password" on the host and Connections pages ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Reorganized Settings into General, Sidebar, Chat, Terminal, Browser, and Open location pages ([#5459](https://github.com/getpaseo/paseo/pull/5459))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed direct connections failing when the daemon password contains spaces or characters such as `@` or `/` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Fixed existing OpenCode agents failing with `ECONNREFUSED` after the OpenCode server restarts ([#5338](https://github.com/getpaseo/paseo/pull/5338))
|
||||
- Fixed messages OpenCode adds on its own appearing in the chat as if the user typed them ([#5434](https://github.com/getpaseo/paseo/pull/5434))
|
||||
- Fixed the daemon stopping when `daemon.log` cannot be written, such as on a full disk ([#5445](https://github.com/getpaseo/paseo/pull/5445))
|
||||
- Fixed the daemon crashing when opening an archived ACP agent whose worktree was removed ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid))
|
||||
- Fixed existing workspaces missing from the sidebar after the app starts, including after later restarts ([#5394](https://github.com/getpaseo/paseo/pull/5394))
|
||||
- Fixed agents on a custom Claude provider with its own `CLAUDE_CONFIG_DIR` opening with an empty chat after a daemon restart ([#5437](https://github.com/getpaseo/paseo/pull/5437))
|
||||
- Fixed a custom Codex provider's sessions missing from Import session ([#5446](https://github.com/getpaseo/paseo/pull/5446))
|
||||
- Fixed Update daemon and Restart in host settings failing when a provider's version check takes over 1.5s ([#5372](https://github.com/getpaseo/paseo/pull/5372))
|
||||
- Fixed `paseo --host <other daemon> run` failing with "Caller agent not found" from inside an agent session ([#5392](https://github.com/getpaseo/paseo/pull/5392))
|
||||
- Fixed a Pi chat rewind going back further than the picked message after an earlier rewind ([#5383](https://github.com/getpaseo/paseo/pull/5383))
|
||||
- Fixed a Pi chat rewind being undone when the daemon restarts ([#5432](https://github.com/getpaseo/paseo/pull/5432))
|
||||
- Fixed Pi agents started without a model ignoring Pi's configured default model ([#5343](https://github.com/getpaseo/paseo/pull/5343))
|
||||
- Fixed project skills in a new agent's `/` menu staying stale after the checkout switches branches ([#5415](https://github.com/getpaseo/paseo/pull/5415))
|
||||
- Fixed slash commands missing from a new agent's `/` menu on custom ACP providers ([#5411](https://github.com/getpaseo/paseo/pull/5411))
|
||||
- Fixed a parent agent receiving a child's finish notification twice after prompting the running child ([#5407](https://github.com/getpaseo/paseo/pull/5407))
|
||||
- Fixed a blocking `send_agent_prompt` that outlasts its 30s wait never notifying the caller when the child finishes ([#5347](https://github.com/getpaseo/paseo/pull/5347))
|
||||
- Fixed OSC 8 terminal links showing a navigation prompt and opening a blank Paseo window ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the Theme menu not scrolling when plugin themes overflow the window ([#5374](https://github.com/getpaseo/paseo/pull/5374))
|
||||
- Fixed a message from today's weekday last week showing only the weekday instead of its date ([#5341](https://github.com/getpaseo/paseo/pull/5341))
|
||||
- Fixed `paseo daemon set-password` exiting silently when stdin is not a terminal ([#5358](https://github.com/getpaseo/paseo/pull/5358))
|
||||
|
||||
## 0.9.2 - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170))
|
||||
- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172))
|
||||
- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346))
|
||||
- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB))
|
||||
- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai))
|
||||
- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227))
|
||||
- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238))
|
||||
- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229))
|
||||
- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249))
|
||||
- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221))
|
||||
- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322))
|
||||
- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317))
|
||||
- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245))
|
||||
- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281))
|
||||
- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton))
|
||||
- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285))
|
||||
- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289))
|
||||
- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286))
|
||||
- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258))
|
||||
- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243))
|
||||
- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235))
|
||||
- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion))
|
||||
- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS))
|
||||
- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9))
|
||||
- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248))
|
||||
- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253))
|
||||
- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231))
|
||||
- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298))
|
||||
- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302))
|
||||
- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301))
|
||||
- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306))
|
||||
- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277))
|
||||
- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315))
|
||||
- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332))
|
||||
- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337))
|
||||
- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310))
|
||||
- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335))
|
||||
- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305))
|
||||
- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290))
|
||||
- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258))
|
||||
- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224))
|
||||
- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272))
|
||||
- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287))
|
||||
|
||||
## 0.9.1 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
|
||||
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
|
||||
|
||||
## 0.9.0 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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, and Pi.
|
||||
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code.
|
||||
|
||||
## Repository map
|
||||
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## スポンサー
|
||||
|
||||
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
|
||||
|
||||
@@ -161,6 +161,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 스폰서
|
||||
|
||||
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
|
||||
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
@@ -42,7 +42,7 @@
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
- **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, and Pi through the same interface. Pick the right model for each job.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
@@ -68,6 +68,8 @@ 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)
|
||||
|
||||
@@ -193,6 +195,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## Related projects
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
|
||||
|
||||
@@ -151,6 +151,12 @@ npm run build:server
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 赞助
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写
|
||||
|
||||
+6
-2
@@ -42,11 +42,15 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
|
||||
|
||||
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
|
||||
|
||||
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
|
||||
@@ -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.
|
||||
Only the Claude provider does this today; the others still report a death only when a turn happens to
|
||||
be in flight.
|
||||
Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt;
|
||||
the unfinished turn is lost.
|
||||
|
||||
### Cancellation
|
||||
|
||||
|
||||
+39
-10
@@ -31,6 +31,35 @@ 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
|
||||
@@ -391,7 +420,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"
|
||||
},
|
||||
"params": {
|
||||
"options": {
|
||||
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
|
||||
"rpcTimeoutMs": 60000,
|
||||
"smolModel": "openai/gpt-5-mini",
|
||||
@@ -404,7 +433,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -416,7 +445,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"],
|
||||
"params": {
|
||||
"options": {
|
||||
"sessionDir": "~/.my-pi-fork/sessions",
|
||||
"rpcTimeoutMs": 60000
|
||||
}
|
||||
@@ -426,7 +455,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>`. `params.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>`. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline.
|
||||
|
||||
---
|
||||
|
||||
@@ -484,7 +513,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 `params.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 `options.supportsMcpServers: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -494,7 +523,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "acp"],
|
||||
"params": {
|
||||
"options": {
|
||||
"supportsMcpServers": false
|
||||
}
|
||||
}
|
||||
@@ -505,7 +534,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 params:
|
||||
operations Paseo handles, configure client capabilities in provider options:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -515,7 +544,7 @@ operations Paseo handles, configure client capabilities in provider params:
|
||||
"extends": "acp",
|
||||
"label": "Container Agent",
|
||||
"command": ["container-agent", "acp"],
|
||||
"params": {
|
||||
"options": {
|
||||
"clientCapabilities": {
|
||||
"fs": {
|
||||
"readTextFile": false,
|
||||
@@ -684,7 +713,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 |
|
||||
| `params` | `Record<string, unknown>` | No | Provider-specific options such as `supportsMcpServers: false` |
|
||||
| `options` | `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"]`) |
|
||||
@@ -714,7 +743,7 @@ Each entry in the `models` array:
|
||||
|
||||
### Claude settings.json model discovery
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
|
||||
+6
-2
@@ -48,6 +48,7 @@ $PASEO_HOME/
|
||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||
├── paseo.pid # Daemon PID lock file
|
||||
├── local-credential # Per-run local client credential (mode 0600)
|
||||
├── daemon.log # Default log file (path configurable)
|
||||
├── agents/
|
||||
│ └── {sanitized-cwd}/
|
||||
@@ -181,8 +182,10 @@ 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 its three managed
|
||||
skill directories and keeps config plus filesystem convergence behind one serialized owner.
|
||||
`{ 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.
|
||||
|
||||
`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
|
||||
@@ -557,6 +560,7 @@ These small files are not validated as full Zod schemas but are persisted under
|
||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
|
||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||
|
||||
---
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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. Chevron means navigation.
|
||||
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.
|
||||
|
||||
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`).
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@ fixed-target labels and icons from that registration, filter by host, and never
|
||||
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||
a compatible pane.
|
||||
|
||||
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
|
||||
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
|
||||
Files and Changes are the Explorer defaults. Their panel manifests mark them as singletons,
|
||||
so a pane’s + menu omits each while that pane already contains it. Closing one makes its menu
|
||||
item available again. Other compatible tabs, including agents, terminals, files, and diffs,
|
||||
can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
@@ -41,11 +43,13 @@ The persisted layout still contains the Explorer pane so tabs survive reloads. T
|
||||
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
||||
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||
|
||||
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||
toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared
|
||||
launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs
|
||||
can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace
|
||||
focus.
|
||||
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
|
||||
hide their close buttons through the panel manifest; close them from the tab context menu.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
|
||||
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
|
||||
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
|
||||
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
|
||||
does not change workspace focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
|
||||
|
||||
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
|
||||
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
|
||||
+18
-2
@@ -11,7 +11,7 @@ canonical files and are trying to add or change one.
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
|
||||
| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal |
|
||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||
|
||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||
@@ -92,7 +92,7 @@ portal can cover them.
|
||||
Painting and keyboard ownership use the same relative layer model. Register
|
||||
desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
|
||||
highest painted scope alone receives overlay keys, traps focus, and restores
|
||||
focus when it closes. Do not add component-local global Escape listeners: two
|
||||
focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two
|
||||
stacked overlays would both close on one keypress.
|
||||
|
||||
If an overlay is rendered by a global host rather than beneath its opener in
|
||||
@@ -302,6 +302,22 @@ 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
|
||||
|
||||
+5
-1
@@ -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 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" section on the Appearance 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/appearance/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**.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **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,6 +31,10 @@ 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 on the Usage screen, 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 screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
|
||||
+2
-2
@@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
@@ -150,5 +150,5 @@ Before opening a PR that touches hover:
|
||||
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
|
||||
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
|
||||
- [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it.
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel) and the user can point at it, it is a `HoverCard`.
|
||||
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
|
||||
|
||||
+5
-2
@@ -28,12 +28,15 @@ manage their own relationship or permissions.
|
||||
## Session grants and agent operations
|
||||
|
||||
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
|
||||
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
|
||||
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
|
||||
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
|
||||
browser control, and permission management still require their own permissions. See
|
||||
[permissions.md](permissions.md).
|
||||
|
||||
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
|
||||
workspace through `workspace.title.set.request` without `workspace.manage`.
|
||||
|
||||
Clients using this contract check `server_info.features.hubAgentRpc` and
|
||||
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
|
||||
fall back to creating a fresh agent when continuation was requested.
|
||||
|
||||
+4
-2
@@ -191,11 +191,13 @@ 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 `menu-anchor.ts` and are unit-tested. Fix
|
||||
positioning bugs there, not at a call site.
|
||||
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
|
||||
card, and are unit-tested. Fix positioning bugs there, not at a call site.
|
||||
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
|
||||
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
|
||||
|
||||
@@ -32,8 +32,10 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
|
||||
React owns pointer events and accessibility. Native panel hosts and their dependent draggable lists
|
||||
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
release. Native panel hosts and their dependent draggable lists
|
||||
also retain identity across appearance hydration and settings changes. Do not wrap those hosts in
|
||||
appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences).
|
||||
|
||||
|
||||
@@ -65,6 +65,26 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
|
||||
The Android sidebar scroll-retention flow requires a connected host with enough
|
||||
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
|
||||
`SCROLLED_WORKSPACE` as the `serverId:workspaceId` suffixes of their sidebar row
|
||||
IDs. Choose one row near the top and one initially below the viewport. Replace
|
||||
the quoted example values below with those IDs. Start in a workspace with the
|
||||
sidebar closed and keep the list stable during the run. The same flow checks
|
||||
reopening by swipe and button:
|
||||
|
||||
```bash
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-sidebar-scroll-retention.android.yaml \
|
||||
--maestro --platform android --serial emulator-5556 --metro-port 8099 \
|
||||
--env TOP_WORKSPACE='server-id:top-workspace-id' \
|
||||
--env SCROLLED_WORKSPACE='server-id:scrolled-workspace-id'
|
||||
```
|
||||
|
||||
Pass this checkout's Metro port so the debug app uses the intended bundle.
|
||||
For iOS, compare the scrolled row's screen bounds and screenshots across reopening.
|
||||
Agent Device's iOS visibility lookup includes offscreen scroll descendants, so
|
||||
its `assertNotVisible` cannot establish the starting scroll position for this flow.
|
||||
|
||||
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
|
||||
screens launch on their own, take input focus, and the run fails with
|
||||
`IME did not become visible` while the app still looks focused:
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
|
||||
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
|
||||
+180
-29
@@ -36,7 +36,8 @@ The daemon stores directory sources under the root `plugins` object:
|
||||
}
|
||||
```
|
||||
|
||||
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
|
||||
Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active.
|
||||
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.
|
||||
@@ -85,27 +86,71 @@ 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
|
||||
|
||||
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
|
||||
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
|
||||
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
|
||||
|
||||
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
|
||||
artifact, and GitHub shorthand requires `github:`. Registry installs
|
||||
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.
|
||||
|
||||
Use `host/owner/slug` for a registry at `https://host`, or set
|
||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||
Private registry credentials live in daemon config under
|
||||
`pluginRegistries: { "host": { "authorization": "Bearer token" } }`.
|
||||
Restart your daemon after changing these startup settings. Credentials go only to the registry,
|
||||
never artifact hosts or redirects. Git/npm use their own host authentication.
|
||||
|
||||
The [open registry protocol](https://github.com/getpaseo/plugins/blob/main/PROTOCOL.md)
|
||||
owns static hosting, record shapes, pins, and advisory install counts.
|
||||
|
||||
## Install a Git source
|
||||
|
||||
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
|
||||
directory always wins over shorthand resolution.
|
||||
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||
URL. An existing directory still wins over source resolution.
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin add https://gitlab.com/group/repository.git
|
||||
paseo plugin add https://git.example.com/owner/repository.git
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin ls
|
||||
paseo plugin update review
|
||||
paseo plugin update --all
|
||||
paseo plugin install owner/repository
|
||||
paseo plugin install https://gitlab.com/group/repository.git
|
||||
paseo plugin install owner/monorepo:plugins/review
|
||||
paseo plugin install github:owner/repository --ref main
|
||||
```
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
`--ref` chooses the initial branch, tag, or commit once. Ordinary updates resolve the remote's
|
||||
current default HEAD and ask for approval. `ls` reports the installed commit without contacting the remote.
|
||||
Removing a Git source deletes Paseo's managed checkout.
|
||||
Append `:relative/path` for a plugin below the repository root. `--ref` chooses the initial
|
||||
branch, tag, or commit once. Updates of explicit Git sources resolve the remote's default HEAD
|
||||
and ask for approval. `ls` reports the installed commit without contacting the remote.
|
||||
|
||||
## Managed source ownership
|
||||
|
||||
@@ -249,12 +294,23 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||
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.addSurface("main", Greeting);
|
||||
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||
client.addScreen({ id: "main", title: "Greeting", Component: Greeting });
|
||||
client.addSidebarHeaderItem({ id: "main", title: "Greeting", Component: GreetingItem });
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
@@ -267,15 +323,33 @@ 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. 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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -311,7 +385,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#surfaces-and-sidebar-items)
|
||||
See the public [navigation fields](../public-docs/plugins/reference.md#screens-and-sidebar-items)
|
||||
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
|
||||
for the author-facing contract.
|
||||
|
||||
@@ -342,9 +416,38 @@ 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. Return a key covering effective configuration and execution environment, or
|
||||
workspace target plus the resolved `launch` when a command is declared. 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).
|
||||
|
||||
@@ -352,15 +455,22 @@ 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 `providerOptions` config object.
|
||||
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.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
|
||||
`session.open`; there is no provider reload input.
|
||||
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.
|
||||
|
||||
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 direct and ACP examples live in `plugin-examples/provider-direct`
|
||||
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`
|
||||
and `plugin-examples/provider-acp-transformer`.
|
||||
|
||||
Provider-emitted plugin timeline items use the same renderer registration as transformed and
|
||||
@@ -376,6 +486,47 @@ 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 and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
|
||||
## 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
|
||||
@@ -445,7 +596,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`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
the same `paseo`, `rpc`, `openScreen`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
Command Center callback.
|
||||
|
||||
```ts
|
||||
|
||||
@@ -10,6 +10,8 @@ Two contracts follow from it.
|
||||
|
||||
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
|
||||
|
||||
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
|
||||
|
||||
- New fields are `.optional()` with a sensible default.
|
||||
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
|
||||
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
|
||||
|
||||
+48
-57
@@ -1,43 +1,38 @@
|
||||
# Adding a New Provider to Paseo
|
||||
|
||||
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||
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. 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) |
|
||||
|
||||
## Provider-native session options
|
||||
|
||||
`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 cannot be passed here.
|
||||
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.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
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.
|
||||
|
||||
- **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`, `sandbox`, and `settings`.
|
||||
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
|
||||
## Core adapter patterns
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
@@ -60,7 +55,7 @@ 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.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -70,11 +65,15 @@ A provider that can register runtime tools directly should set `supportsNativePa
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions/<extension>/`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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 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.
|
||||
|
||||
@@ -86,9 +85,15 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
@@ -180,26 +185,12 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
---
|
||||
|
||||
## Provider Usage Fetchers
|
||||
## Usage sources
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -287,7 +278,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
|
||||
### 2. Add to the provider manifest
|
||||
|
||||
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||
In `packages/protocol/src/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.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -25,7 +25,13 @@ checkout.forge.set_auto_merge.request;
|
||||
// -> checkout.forge.set_auto_merge.response
|
||||
```
|
||||
|
||||
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
|
||||
A finite streaming request can emit `.update` messages before its terminal `.response`.
|
||||
`usage.list_reports.request` uses this shape: each `.update` carries one settled report, and
|
||||
`.response` carries the request-level error or `null`. All three share `requestId`; no updates
|
||||
follow the response. An empty successful stream is an empty list. Register updates as replies
|
||||
of the originating request so source-owned delivery routes them to the requesting connection.
|
||||
|
||||
Most new RPCs should follow these shapes. If a request does not have a one-to-one terminal response, call that out in the code near the schema.
|
||||
|
||||
## Message Shape
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Claude hook mapping:
|
||||
|
||||
- `UserPromptSubmit` → `running`
|
||||
- `Stop`, `StopFailure`, `SessionEnd` → `idle`
|
||||
- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input`
|
||||
- `Notification` with `notification_type` equal to `idle_prompt` → `needs-input`
|
||||
|
||||
Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input
|
||||
while terminal activity is working clears the activity without finished attention. The same
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,12 @@
|
||||
Added
|
||||
- Added structured Claude Code launch arguments for session configuration and plugins
|
||||
|
||||
Improved
|
||||
- Reduced background Git polling for repositories the watcher cannot observe
|
||||
- Reduced Add Project directory search time on large home directories
|
||||
|
||||
Fixed
|
||||
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
|
||||
- Fixed daemon memory growing after client connections close (9978988)
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"$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}"]
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-6du3a0DzuvF+m2cPTOXDhdZZScQPkqicikdDxWIKZ5o=
|
||||
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
|
||||
|
||||
Generated
+305
-40
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"plugins",
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
@@ -6329,10 +6330,23 @@
|
||||
"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
|
||||
@@ -8271,6 +8285,84 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -8644,6 +8736,48 @@
|
||||
"node": ">=20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/client": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
|
||||
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "2.0.10",
|
||||
"@opencode/schema": "2.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/protocol": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
|
||||
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "2.0.10",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode/schema": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
|
||||
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"effect": "4.0.0-rc.112"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -18622,6 +18756,16 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-rc.112",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
|
||||
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-check": "^4.9.0",
|
||||
"msgpackr": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
@@ -21866,6 +22010,28 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
|
||||
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
@@ -26104,17 +26270,6 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react-native": {
|
||||
"version": "0.546.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
|
||||
"integrity": "sha512-v/qe5JnOHKIhccqfE7Ln78wd0mn5YgmCBvkTX2ca8sWeoym3ZYZcY/mapFsxssarutO4bMI/bRtTqiusGbpOUw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-native": "*",
|
||||
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -28141,6 +28296,37 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
|
||||
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
@@ -28466,6 +28652,21 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -30141,6 +30342,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -36196,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36261,7 +36478,7 @@
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
@@ -37116,6 +37333,23 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/lucide-react-native": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.50.0.tgz",
|
||||
"integrity": "sha512-uMtIW3I6jfHbUp2FnUwxbDJc7XFJDz2g5YVcw6T/De8Sp9f21vTO7HlJNhxpBynDYlqLyWHaXeR0dBSiw6TqOw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-native": "*",
|
||||
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/miniflare": {
|
||||
"version": "4.20260625.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
|
||||
@@ -37262,12 +37496,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/server": "0.9.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/server": "0.11.0-beta.3",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37514,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/relay": "0.9.0",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37528,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37772,7 +38006,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38668,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38697,14 +38931,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38714,8 +38948,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38737,7 +38971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38764,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38982,19 +39216,20 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/highlight": "0.9.0",
|
||||
"@getpaseo/plugin": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/relay": "0.9.0",
|
||||
"@getpaseo/client": "0.11.0-beta.3",
|
||||
"@getpaseo/highlight": "0.11.0-beta.3",
|
||||
"@getpaseo/plugin": "0.11.0-beta.3",
|
||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
||||
"@getpaseo/relay": "0.11.0-beta.3",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
@@ -39023,6 +39258,7 @@
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "2.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39881,6 +40117,21 @@
|
||||
"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",
|
||||
@@ -39895,10 +40146,11 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-start": "^1.168.26",
|
||||
"framer-motion": "^12.35.2",
|
||||
@@ -39913,7 +40165,8 @@
|
||||
"remark-directive": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"wrangler": "^4.75.0"
|
||||
"wrangler": "^4.75.0",
|
||||
"@getpaseo/protocol": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
@@ -41497,6 +41750,18 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0-beta.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0-beta.3",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -21,6 +21,7 @@
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"plugins",
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/plugin",
|
||||
@@ -66,7 +67,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/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/builtin-plugins && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "oxfmt .",
|
||||
"format:files": "oxfmt",
|
||||
|
||||
@@ -1242,6 +1242,7 @@ 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 {
|
||||
|
||||
@@ -6,13 +6,16 @@ import {
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectCreateProfileFromModelRow,
|
||||
expectComposerModel,
|
||||
expectModelRowProfileActionBesideRow,
|
||||
expectModelRowSelected,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
seedAgentProfiles,
|
||||
selectModelRow,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
@@ -115,4 +118,34 @@ test.describe("Agent profiles in the model picker", () => {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-row-action-",
|
||||
title: "Agent profiles row action",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
try {
|
||||
const oneMinute = {
|
||||
provider: "mock",
|
||||
modelId: "one-minute-stream",
|
||||
modelLabel: "One minute stream",
|
||||
};
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expectModelRowProfileActionBesideRow(page, oneMinute);
|
||||
await expectCreateProfileFromModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await openModelPicker(page);
|
||||
await selectModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, oneMinute.modelLabel);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
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);
|
||||
});
|
||||
@@ -84,6 +84,18 @@ 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);
|
||||
@@ -114,6 +126,8 @@ 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("explorer-sidebar-tab-changes_tree")).toBeVisible({
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
|
||||
@@ -428,7 +428,16 @@ async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo
|
||||
}
|
||||
}
|
||||
|
||||
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
|
||||
test("opens and refocuses Find with Control+f from the composer on Linux", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// Chromium retains the host navigator.platform even when its user agent is overridden.
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await session.send("Emulation.setUserAgentOverride", {
|
||||
userAgent:
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
platform: "Linux x86_64",
|
||||
});
|
||||
await findFromComposer(page, "Control+f", testInfo);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
hoverChatOutlinePrompt,
|
||||
chatOutlineRail,
|
||||
movePointerOffChatOutline,
|
||||
observePromptIndexRequests,
|
||||
pointAtChatOutlineRowEdge,
|
||||
pressEnterOnFocusedPrompt,
|
||||
splitCurrentPanelRight,
|
||||
withStreamingMarkdownOutline,
|
||||
expectReadingStreamedMarkdown,
|
||||
} from "../support/helpers/chat-outline";
|
||||
import { createCreationScenario } from "../support/helpers/creation";
|
||||
import {
|
||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||
expectTimelinePromptNotMounted,
|
||||
@@ -266,3 +268,21 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 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 expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
|
||||
await selectSpeed(page, "Ultrafast");
|
||||
await openSpeedSelector(page);
|
||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
|
||||
});
|
||||
await selectSpeed(page, "Normal");
|
||||
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,7 +87,25 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
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> {
|
||||
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" });
|
||||
@@ -96,8 +114,8 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
stdio: "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
|
||||
GIT_AUTHOR_DATE: authorDate,
|
||||
GIT_COMMITTER_DATE: authorDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 = [
|
||||
{
|
||||
@@ -338,6 +339,16 @@ 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) =>
|
||||
@@ -359,6 +370,25 @@ 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,7 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
expectComposerNotFocused,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
@@ -27,3 +28,51 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-open-",
|
||||
title: "Composer focus on open",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForAnimationFrames(page: Page, count: number): Promise<void> {
|
||||
await page.evaluate(async (frames) => {
|
||||
for (let frame = 0; frame < frames; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}, count);
|
||||
}
|
||||
|
||||
test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
|
||||
// composer there raises the on-screen keyboard over the conversation.
|
||||
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("opening an agent leaves the composer unfocused", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-touch-",
|
||||
title: "Composer focus on touch",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
expect(
|
||||
await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches),
|
||||
).toBe(false);
|
||||
// Autofocus makes its first attempt on the next animation frame.
|
||||
await waitForAnimationFrames(page, 4);
|
||||
await expectComposerNotFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
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 replaces the card with the source's new report", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
const card = contextWindowDetails(page);
|
||||
await refreshUsageCard(card, "Claude");
|
||||
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
});
|
||||
|
||||
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(5);
|
||||
});
|
||||
});
|
||||
|
||||
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.refreshedReports()).toEqual([["claude:work"]]);
|
||||
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(2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
@@ -25,3 +28,39 @@ test("settings restart waits for a replacement worker while retaining the superv
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(process.platform === "win32", "The slow provider stub is a POSIX script");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID(), {
|
||||
paseoHome: await createHomeWithSlowProvider(1_800),
|
||||
});
|
||||
try {
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function createHomeWithSlowProvider(versionDelayMs: number): Promise<string> {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-slow-provider-"));
|
||||
const provider = path.join(paseoHome, "slow-provider");
|
||||
await writeFile(
|
||||
provider,
|
||||
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), ${versionDelayMs});\n`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } },
|
||||
})}\n`,
|
||||
);
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe("New tab keyboard launcher", () => {
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
const explorer = await openExplorerWithKeyboard(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -25,13 +25,16 @@ function draftTabChip(page: Page): Locator {
|
||||
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
||||
}
|
||||
|
||||
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
|
||||
/** Scope the shared tab row to the Explorer dock. */
|
||||
function explorerTabRow(page: Page): Locator {
|
||||
return visible(page, "explorer-sidebar-tab-rail");
|
||||
return visible(page, "workspace-explorer-sidebar").getByTestId("workspace-tabs-row");
|
||||
}
|
||||
|
||||
function mainTabRow(page: Page): Locator {
|
||||
return visible(page, "workspace-tabs-row");
|
||||
return page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true });
|
||||
}
|
||||
|
||||
async function selectExplorerChanges(page: Page): Promise<void> {
|
||||
@@ -173,7 +176,7 @@ test.describe("explorer pane tab placement", () => {
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(mainTabRow(page)).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
@@ -233,14 +236,14 @@ async function closeOnlyDraft(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click();
|
||||
await mainTabRow(page).getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Split pane right/ }).click();
|
||||
const target = await emptyPaneBox(page);
|
||||
await dragChipTo(page, draftTabChip(page), {
|
||||
x: target.x + target.width / 2,
|
||||
y: target.y + target.height / 2,
|
||||
});
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(1);
|
||||
await expect(mainTabRow(page)).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function expectNewLauncher(page: Page): Promise<void> {
|
||||
|
||||
@@ -3,20 +3,20 @@ import {
|
||||
withExplorerPanelPlugins,
|
||||
expectWorkspacePanelsInExplorerLauncher,
|
||||
openWorkspacePanelFromExplorerMenu,
|
||||
expectIndependentExplorerPanelToggles,
|
||||
expectIndependentExplorerPanelTabs,
|
||||
expectExplorerPanelWithFocusedAgent,
|
||||
} from "../support/helpers/explorer-plugin-menu";
|
||||
|
||||
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
|
||||
test("Explorer opens and closes workspace panel tabs independently without inheriting agent context", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await withExplorerPanelPlugins(page, async (workspace) => {
|
||||
await test.step("the New Tab launcher offers compatible workspace panels", () =>
|
||||
await test.step("the + menu offers compatible workspace panels", () =>
|
||||
expectWorkspacePanelsInExplorerLauncher(page));
|
||||
await test.step("the rail menu offers the same workspace panels", () =>
|
||||
await test.step("the + menu opens a workspace panel in Explorer", () =>
|
||||
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
|
||||
await test.step("different panels and plugins keep independent selection and closing", () =>
|
||||
expectIndependentExplorerPanelToggles(page, workspace));
|
||||
expectIndependentExplorerPanelTabs(page, workspace));
|
||||
await test.step("focusing an agent does not add agent panels to Explorer", () =>
|
||||
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
pressNewTabShortcut,
|
||||
expectTabTitleFits,
|
||||
} from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
ensureExplorerSidebar,
|
||||
@@ -11,6 +15,25 @@ function explorerSidebar(page: Parameters<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,
|
||||
@@ -26,14 +49,14 @@ test.describe("Explorer sidebar", () => {
|
||||
.count();
|
||||
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
|
||||
await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(1);
|
||||
|
||||
await openFilesPanel(page);
|
||||
await expectExplorerActiveTabForeground(page);
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
|
||||
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
|
||||
await explorer.getByTestId("workspace-tab-changes_tree").click();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
@@ -46,3 +69,181 @@ test.describe("Explorer sidebar", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function launchExplorerPanel(
|
||||
page: Parameters<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,10 +64,8 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(
|
||||
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
|
||||
).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-changes_tree")).toBeVisible();
|
||||
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
@@ -102,7 +100,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
await test.step("open the explorer so both sides are showing", async () => {
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
|
||||
await expect(agentList).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -116,7 +114,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -92,6 +96,70 @@ 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,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
|
||||
const filePath = path.join(workspace.repoPath, "src", "target.ts");
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Absolute file link tooltip",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
initialPrompt: "Show the file link",
|
||||
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await workspaceRow.click();
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
|
||||
|
||||
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.hover();
|
||||
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -241,7 +309,12 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(2);
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import {
|
||||
buildPairingLink,
|
||||
cancelHostConfirmation,
|
||||
connectToConfirmedHost,
|
||||
expectHostConfirmationFor,
|
||||
expectHostInHostPicker,
|
||||
hostConfirmation,
|
||||
openPairingLink,
|
||||
saveLinkedHostBeforeLoad,
|
||||
type LinkedHost,
|
||||
} from "../support/helpers/host-confirmation";
|
||||
|
||||
// An unreachable relay: the link saves the host without connecting first.
|
||||
const linkedHost: LinkedHost = {
|
||||
serverId: "srv_link_confirmation",
|
||||
relayEndpoint: "127.0.0.1:59998",
|
||||
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
|
||||
};
|
||||
|
||||
test("a pairing link for a new host asks before saving it", async ({ page }) => {
|
||||
const link = buildPairingLink(linkedHost);
|
||||
|
||||
await test.step("Cancel saves nothing and stays on the page", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await cancelHostConfirmation(page);
|
||||
await expectAppRoute(page, "/settings/general");
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "not listed");
|
||||
});
|
||||
|
||||
await test.step("Connect saves the host and opens a project", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await connectToConfirmedHost(page);
|
||||
await expectAppRoute(page, "/open-project");
|
||||
await openSettings(page);
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
|
||||
});
|
||||
});
|
||||
|
||||
test("a pairing link for a saved host connects without asking", async ({ page }) => {
|
||||
await saveLinkedHostBeforeLoad(page, linkedHost);
|
||||
|
||||
await openPairingLink(page, buildPairingLink(linkedHost));
|
||||
|
||||
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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 { test, type Page } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { ImportSessionFlow } from "../support/helpers/import-session";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
@@ -49,6 +49,7 @@ interface ImportFlowScenario {
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
importSessionTimestamp: number;
|
||||
repoCleanup(): Promise<void>;
|
||||
unrelatedCleanup(): Promise<void>;
|
||||
}
|
||||
@@ -94,7 +95,7 @@ test.beforeAll(async () => {
|
||||
};
|
||||
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
|
||||
const importSessionId = "fixture-custom-title";
|
||||
await seedClaudeSessions({
|
||||
const importSessionTimestamp = await seedClaudeSessions({
|
||||
projectRoot: repo.path,
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
@@ -108,6 +109,7 @@ test.beforeAll(async () => {
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
importSessionId,
|
||||
importSessionTimestamp,
|
||||
repoCleanup: repo.cleanup,
|
||||
unrelatedCleanup: unrelated.cleanup,
|
||||
};
|
||||
@@ -122,17 +124,29 @@ 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 mobile sidebar exposes import in its footer", async () => {
|
||||
await flow.revealMobileEntryPoint();
|
||||
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
|
||||
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 host-wide sheet is newest first and fits its provider filter", async () => {
|
||||
await flow.openGlobally();
|
||||
await flow.openFromNewWorkspace();
|
||||
await flow.expectScope("Sessions on", false);
|
||||
await flow.expectRows({
|
||||
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
|
||||
@@ -225,7 +239,7 @@ async function seedClaudeSessions(input: {
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
}): Promise<void> {
|
||||
}): Promise<number> {
|
||||
const sessions = [
|
||||
{
|
||||
cwd: input.projectRoot,
|
||||
@@ -290,4 +304,5 @@ async function seedClaudeSessions(input: {
|
||||
const timestamp = new Date(newest - index * 60_000);
|
||||
await utimes(sessionPath, timestamp, timestamp);
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
|
||||
// Where Settings → Keyboard shortcuts persists a rebound shortcut.
|
||||
const OVERRIDE_STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
|
||||
const COMMAND_CENTER_BINDING = "command-center-toggle-ctrl-k-non-mac";
|
||||
|
||||
async function rebindCommandCenter(page: Page, combo: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
([key, bindingId, value]) => {
|
||||
window.localStorage.setItem(key, JSON.stringify({ [bindingId]: value }));
|
||||
},
|
||||
[OVERRIDE_STORAGE_KEY, COMMAND_CENTER_BINDING, combo] as const,
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectCommandCenterOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe("Multi-step shortcut", () => {
|
||||
test("completes a chord whose second step is a bare key", async ({ page }) => {
|
||||
await rebindCommandCenter(page, "Ctrl+K J");
|
||||
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.keyboard.press("j");
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
|
||||
// Releasing Ctrl between the steps makes the browser emit a bare `Control`
|
||||
// keydown before `Ctrl+J`, which used to reset the chord to its first step.
|
||||
test("completes a chord whose second step carries a modifier", async ({ page }) => {
|
||||
await rebindCommandCenter(page, "Ctrl+K Ctrl+J");
|
||||
|
||||
await page.keyboard.press("Control+k");
|
||||
await page.keyboard.press("Control+j");
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
|
||||
// "Toggle command center" recorded as a two-key sequence. Both platform
|
||||
// bindings are overridden so the spec does not depend on the host OS.
|
||||
const COMMAND_CENTER_SEQUENCE = {
|
||||
"command-center-toggle-cmd-k-mac": "Cmd+K J",
|
||||
"command-center-toggle-ctrl-k-non-mac": "Ctrl+K J",
|
||||
};
|
||||
|
||||
const SEQUENCE_PREFIX = process.platform === "darwin" ? "Meta+K" : "Control+K";
|
||||
const SEQUENCE_FINAL_KEY = "j";
|
||||
|
||||
/** Record the sequence in Settings → Keyboard shortcuts, through its storage. */
|
||||
async function recordCommandCenterSequence(page: Page): Promise<void> {
|
||||
await page.addInitScript((overrides) => {
|
||||
localStorage.setItem("@paseo:keyboard-shortcut-overrides", JSON.stringify(overrides));
|
||||
}, COMMAND_CENTER_SEQUENCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the window and let the app render again, so the resize lands between
|
||||
* the two keys of the sequence rather than after them.
|
||||
*/
|
||||
async function resizeWindowAndSettle(page: Page, width: number): Promise<void> {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.waitForFunction((expected) => window.innerWidth === expected, width);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectCommandCenterOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Multi-key shortcut sequences", () => {
|
||||
test("a recorded sequence opens the command center", async ({ page, withWorkspace }) => {
|
||||
await recordCommandCenterSequence(page);
|
||||
const workspace = await withWorkspace({ prefix: "shortcut-sequence-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.keyboard.press(SEQUENCE_PREFIX);
|
||||
await page.keyboard.press(SEQUENCE_FINAL_KEY);
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
|
||||
test("a sequence completes when the window resizes between its keys", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await recordCommandCenterSequence(page);
|
||||
const workspace = await withWorkspace({ prefix: "shortcut-sequence-resize-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await page.keyboard.press(SEQUENCE_PREFIX);
|
||||
await resizeWindowAndSettle(page, 1100);
|
||||
await page.keyboard.press(SEQUENCE_FINAL_KEY);
|
||||
|
||||
await expectCommandCenterOpen(page);
|
||||
});
|
||||
});
|
||||
@@ -250,6 +250,52 @@ test.describe("Tab creation", () => {
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
});
|
||||
|
||||
test("Cursor CLI profile uses the Cursor icon in New tab and host settings", async ({ page }) => {
|
||||
const guessed: TerminalProfile = {
|
||||
id: "e2e-cursor-guessed",
|
||||
name: "Cursor guessed",
|
||||
command: "cursor-agent",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
const explicit: TerminalProfile = {
|
||||
...guessed,
|
||||
id: "e2e-cursor-explicit",
|
||||
name: "Cursor explicit",
|
||||
icon: "cursor",
|
||||
};
|
||||
const profileSeed = await seedTerminalProfiles([guessed, explicit]);
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
const menuIconPath = (name: string) =>
|
||||
menu
|
||||
.getByRole("menuitem", { name })
|
||||
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||
const guessedMenuPath = await menuIconPath(guessed.name);
|
||||
const explicitMenuPath = await menuIconPath(explicit.name);
|
||||
|
||||
await menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles").click();
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
|
||||
const settingsIconPath = (id: string) =>
|
||||
page
|
||||
.getByTestId(`terminal-profile-row-${id}`)
|
||||
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||
const guessedSettingsPath = await settingsIconPath(guessed.id);
|
||||
const explicitSettingsPath = await settingsIconPath(explicit.id);
|
||||
|
||||
expect(explicitMenuPath).not.toBeNull();
|
||||
expect(explicitSettingsPath).not.toBeNull();
|
||||
expect.soft(guessedMenuPath, "New tab Cursor profile icon").toBe(explicitMenuPath);
|
||||
expect.soft(guessedSettingsPath, "Settings Cursor profile icon").toBe(explicitSettingsPath);
|
||||
} finally {
|
||||
await profileSeed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
@@ -291,7 +337,7 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
|
||||
expect(counts.every((count) => count === initialCount)).toBe(true);
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { launchAgent, cleanupRewindFlow, type AgentHandle } from "../support/helpers/rewind-flow";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
const binary = process.env.OPENCODE_TEST_BINARY ?? "opencode";
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: { providers: { opencode: { enabled: true, command: [binary] } } },
|
||||
},
|
||||
});
|
||||
|
||||
async function selectMedium(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select thinking option/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByText("Medium", { exact: true }).filter({ visible: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Select thinking option (Medium)", exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function selectLongcat(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select model \(/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText("LongCat 2.5 Preview Free", { exact: true })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
}
|
||||
async function askForReply(handle: AgentHandle) {
|
||||
await submitMessage(handle.page, "Reply exactly OK. Do not use tools.");
|
||||
return handle.client.waitForFinish(handle.agentId, 120_000);
|
||||
}
|
||||
|
||||
test("switching an existing OpenCode session clears unsupported thinking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000);
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-variant-ui-"));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
cwd,
|
||||
provider: "opencode",
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opencode/space-bunny-free" },
|
||||
});
|
||||
await selectMedium(page);
|
||||
expect((await askForReply(handle)).status).toBe("idle");
|
||||
await selectLongcat(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Select thinking option/ }).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
const finish = await askForReply(handle);
|
||||
const screenshot = testInfo.outputPath("model-switch.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("After switching to Longcat", {
|
||||
path: screenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeFalsy();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ visible: true }).last(),
|
||||
).toContainText("OK");
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
@@ -36,9 +36,18 @@ function ModalBody({ onSaved }) {
|
||||
</View>;
|
||||
}
|
||||
|
||||
function Surface() {
|
||||
function Surface({ playAudio }) {
|
||||
const [audioStatus, setAudioStatus] = useState("Audio ready");
|
||||
async function play(base64) {
|
||||
setAudioStatus("Playing audio");
|
||||
try { await playAudio({ base64, mimeType: "audio/wav" }); setAudioStatus("Audio finished"); }
|
||||
catch { setAudioStatus("Audio rejected"); }
|
||||
}
|
||||
const [open, setOpen] = useState(false);
|
||||
return <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} />
|
||||
@@ -59,7 +68,7 @@ function Surface() {
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSurface("main", () => <Surface playAudio={plugin.playAudio} />);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Host UI",
|
||||
@@ -123,7 +132,11 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
const audio = await readFile(path.resolve(__dirname, "../../assets/audio/thinking-tone.wav"));
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`const AUDIO_BASE64 = ${JSON.stringify(audio.toString("base64"))};\n${PLUGIN_SOURCE}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
@@ -131,6 +144,12 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
await useNonCompactLayout(page);
|
||||
await openHostUiPlugin(page);
|
||||
|
||||
await test.step("plugin audio finishes, rejects corrupt files, and recovers", async () => {
|
||||
await playPluginAudio(page, "Play plugin audio", "Audio finished");
|
||||
await playPluginAudio(page, "Play invalid audio", "Audio rejected");
|
||||
await playPluginAudio(page, "Play plugin audio", "Audio finished");
|
||||
});
|
||||
|
||||
await test.step("non-compact layouts use a centered dialog", async () => {
|
||||
await openPluginModal(page);
|
||||
await expectCenteredPluginDialog(page);
|
||||
@@ -153,3 +172,8 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function playPluginAudio(page: Page, button: string, result: string) {
|
||||
await page.getByRole("button", { name: button, exact: true }).click();
|
||||
await expect(page.getByText(result, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
expectConflictPreservesDraft,
|
||||
discardTitle,
|
||||
returnAndReopenSettings,
|
||||
returnToPluginsPage,
|
||||
expectSettingsUnavailable,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
@@ -43,6 +44,11 @@ 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 } });
|
||||
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
@@ -120,3 +121,75 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const MANY_THEMES_PLUGIN_ID = "plugin-theme-many-e2e";
|
||||
const MANY_THEMES_COUNT = 40;
|
||||
const LAST_THEME_NAME = `Pack theme ${MANY_THEMES_COUNT}`;
|
||||
|
||||
const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
|
||||
for (let index = 1; index <= ${MANY_THEMES_COUNT}; index += 1) {
|
||||
plugin.addTheme({
|
||||
id: "pack-" + index,
|
||||
name: "Pack theme " + index,
|
||||
appearance: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
raised: "#313244",
|
||||
control: "#45475a",
|
||||
border: "#45475a",
|
||||
accent: "#cba6f7",
|
||||
mutedForeground: "#a6adc8",
|
||||
ring: "#6c7086",
|
||||
},
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function scrollThemeMenuToLastTheme(page: Page) {
|
||||
const firstTheme = page.getByText("Pack theme 1", { exact: true });
|
||||
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
|
||||
await firstTheme.hover();
|
||||
await page.mouse.wheel(0, 4000);
|
||||
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: MANY_THEMES_PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), MANY_THEMES_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
await scrollThemeMenuToLastTheme(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -119,6 +119,25 @@ 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,
|
||||
}) => {
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
@@ -18,6 +19,12 @@ import {
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
seedSavedSettingsHosts,
|
||||
selectSettingsHost,
|
||||
expectHostRejectedWithReAddGuidance,
|
||||
expectNoHostPasswordControls,
|
||||
removeHostFromHostPage,
|
||||
addDirectHostFromSettings,
|
||||
expectHostOnlineWithoutError,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -89,6 +96,47 @@ test.describe("Settings host page", () => {
|
||||
await expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("a host rejected for its password reconnects after being removed and added again with it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const password = "e2e host password";
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_password_host", {
|
||||
environment: { ...process.env, PASEO_PASSWORD: password },
|
||||
});
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: TEST_HOST_LABEL,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Password host",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
},
|
||||
]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, daemon.serverId);
|
||||
await openHostSection(page, daemon.serverId, "host");
|
||||
|
||||
await test.step("the host page shows the reason and the re-add guidance, with no password UI", async () => {
|
||||
await expectHostRejectedWithReAddGuidance(page, "Password required");
|
||||
await expectNoHostPasswordControls(page);
|
||||
});
|
||||
|
||||
await test.step("removing the host and adding it again with the password brings it online", async () => {
|
||||
await removeHostFromHostPage(page, daemon.serverId);
|
||||
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: daemon.port, password });
|
||||
await openHostSection(page, daemon.serverId, "host");
|
||||
await expectHostOnlineWithoutError(page);
|
||||
});
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -155,6 +155,18 @@ test("keeps diagnostics available from Settings after globalizing the sheet", as
|
||||
test.describe("compact sidebar help", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("footer buttons provide finger-sized targets and accept taps beyond the glyph", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectFingerSizedFooterTargets(page);
|
||||
await tapHelpNearTargetEdge(page);
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
});
|
||||
|
||||
test("offers diagnostics without advertising disabled keyboard shortcuts", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
@@ -165,3 +177,26 @@ 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();
|
||||
}
|
||||
|
||||
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();
|
||||
expect(bounds?.width).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds?.height).toBeGreaterThanOrEqual(44);
|
||||
// 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,18 +1,83 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
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);
|
||||
@@ -24,8 +89,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-info")).toHaveAccessibleName(
|
||||
"About Sidebar",
|
||||
await expect(page.getByTestId("sidebar-nav-section-header-info")).toHaveAccessibleName(
|
||||
"About Header",
|
||||
);
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
@@ -106,3 +171,69 @@ 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
|
||||
interface SeededProject {
|
||||
projectKey: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a project whose directory name ends in a space, which `seedWorkspace`
|
||||
* cannot produce because `mkdtemp` appends the random suffix. The project is
|
||||
* owned until `cleanup`, which removes the daemon record before the directory.
|
||||
*/
|
||||
async function seedProjectNamedWithTrailingSpace(): Promise<SeededProject> {
|
||||
const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-trailing-space-"));
|
||||
const directoryPath = path.join(parentDirectory, `Reklamation-${randomUUID().slice(0, 8)} `);
|
||||
await mkdir(directoryPath);
|
||||
await writeFile(path.join(directoryPath, "README.md"), "# Trailing space\n");
|
||||
|
||||
const client = await connectSeedClient();
|
||||
const removeParentDirectory = () => rm(parentDirectory, { recursive: true, force: true });
|
||||
try {
|
||||
const added = await client.addProject(directoryPath);
|
||||
expect(added.error).toBeNull();
|
||||
expect(added.project?.projectRootPath).toBe(directoryPath);
|
||||
const project = added.project;
|
||||
if (!project?.projectKey) {
|
||||
throw new Error(`The daemon added ${directoryPath} without a project key`);
|
||||
}
|
||||
return {
|
||||
projectKey: project.projectKey,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(project.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await removeParentDirectory().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
await removeParentDirectory().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectProjectRow(page: Page, projectKey: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function expectAppShellRendered(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Paseo ran into a problem.")).toHaveCount(0);
|
||||
}
|
||||
|
||||
// A project view key carries the project's path, so a directory whose name ends
|
||||
// in a space produces a key that ends in a space. The sidebar order store used
|
||||
// to trim it on write, which left the reconcile effect writing a new order on
|
||||
// every render until React tore the app down with "Maximum update depth
|
||||
// exceeded" on every load (#4880).
|
||||
test("a project whose folder name ends in a space renders in the sidebar", async ({ page }) => {
|
||||
const project = await seedProjectNamedWithTrailingSpace();
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectProjectRow(page, project.projectKey);
|
||||
await expectAppShellRendered(page);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -88,6 +88,7 @@ async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(workspaceRow(page)).toBeVisible();
|
||||
await toggleTrailing(page, "Diff stats", true);
|
||||
await page.mouse.move(0, 0);
|
||||
await page.screenshot({ path: testInfo.outputPath("sidebar.png") });
|
||||
}
|
||||
@@ -106,6 +107,7 @@ async function openDesktopWorkspaceList(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
const row = workspaceRow(page);
|
||||
await expect(row).toBeVisible();
|
||||
await toggleTrailing(page, "Diff stats");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(row.getByText("+12.3k", { exact: true })).toBeVisible();
|
||||
return titleWidth(row);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
@@ -108,7 +110,207 @@ async function withPaseoOwnedWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
async function readCachedRowIds(
|
||||
page: Page,
|
||||
rowKind: "workspace" | "checkpoint",
|
||||
): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
async ({ serverId, kind }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
return await new Promise<string[]>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readonly");
|
||||
const request = transaction.objectStore("rows").getAll();
|
||||
request.onsuccess = () =>
|
||||
resolve(
|
||||
request.result
|
||||
.filter(
|
||||
(row: { serverId: string; kind: string }) =>
|
||||
row.serverId === serverId && row.kind === kind,
|
||||
)
|
||||
.map((row: { id: string }) => row.id)
|
||||
.sort(),
|
||||
);
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), kind: rowKind },
|
||||
);
|
||||
}
|
||||
|
||||
async function simulateDamagedLegacyDirectoryCache(
|
||||
page: Page,
|
||||
workspaceIdToRemove: string,
|
||||
): Promise<void> {
|
||||
// Stop the app before altering storage so no pending app write can repair the fixture.
|
||||
const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href;
|
||||
await page.route(fixtureUrl, (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<html><body>Cache fixture</body></html>" }),
|
||||
);
|
||||
await page.goto(fixtureUrl);
|
||||
await page.evaluate(
|
||||
async ({ serverId, missingWorkspaceId }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readwrite");
|
||||
const store = transaction.objectStore("rows");
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const rows = request.result as Array<{
|
||||
serverId: string;
|
||||
kind: string;
|
||||
id: string;
|
||||
payload: string;
|
||||
}>;
|
||||
const checkpoint = rows.find(
|
||||
(row) => row.serverId === serverId && row.kind === "checkpoint",
|
||||
);
|
||||
if (!checkpoint) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
const stored = JSON.parse(checkpoint.payload);
|
||||
// Accept either writer so this fixture also reproduces on the unfixed client.
|
||||
const cursors = stored.version === 1 ? stored.cursors : stored;
|
||||
if (!cursors.workspaces?.generation) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
store.put({ ...checkpoint, payload: JSON.stringify(cursors) });
|
||||
for (const row of rows) {
|
||||
if (
|
||||
row.serverId === serverId &&
|
||||
row.kind === "workspace" &&
|
||||
row.id === missingWorkspaceId
|
||||
)
|
||||
store.delete([serverId, row.kind, row.id]);
|
||||
}
|
||||
};
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("abort", () =>
|
||||
reject(transaction.error ?? new Error("Missing current directory checkpoint")),
|
||||
);
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove },
|
||||
);
|
||||
expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove);
|
||||
}
|
||||
|
||||
async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
server.onMessage(async (message) => {
|
||||
await ready;
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
return release;
|
||||
}
|
||||
|
||||
async function createPinnedSiblingWorkspace(
|
||||
project: Awaited<ReturnType<typeof seedWorkspace>>,
|
||||
): Promise<string> {
|
||||
const sibling = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
|
||||
title: "Pinned sibling",
|
||||
});
|
||||
if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created");
|
||||
await project.client.setWorkspacePinned(sibling.workspace.id, true);
|
||||
return sibling.workspace.id;
|
||||
}
|
||||
|
||||
async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await waitForSidebarWorkspace(page, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
}
|
||||
|
||||
async function reopenDamagedCacheBeforeHostResponds(
|
||||
page: Page,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<() => void> {
|
||||
await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId);
|
||||
const releaseHost = await holdHostResponsesUntilCacheIsVisible(page);
|
||||
await gotoAppShell(page);
|
||||
return releaseHost;
|
||||
}
|
||||
|
||||
async function expectDamagedCacheHydrated(
|
||||
page: Page,
|
||||
retainedWorkspaceId: string,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<void> {
|
||||
await waitForSidebarWorkspace(page, retainedWorkspaceId);
|
||||
await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0);
|
||||
await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectRecoveredWorkspacesAfterReload(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
testInfo: TestInfo,
|
||||
): Promise<void> {
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
await page.reload();
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") });
|
||||
await testInfo.attach("recovered-workspaces", {
|
||||
path: testInfo.outputPath("recovered-workspaces.png"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("recovers missing workspaces from a damaged legacy cache after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" });
|
||||
try {
|
||||
const siblingId = await createPinnedSiblingWorkspace(project);
|
||||
const ids = [project.workspaceId, siblingId].sort();
|
||||
await test.step("cache both workspaces", () => cacheWorkspaces(page, ids));
|
||||
|
||||
const releaseHost = await test.step("reopen damaged cache before host responds", () =>
|
||||
reopenDamagedCacheBeforeHostResponds(page, project.workspaceId));
|
||||
await test.step("keep cached workspace and discard stale checkpoint", () =>
|
||||
expectDamagedCacheHydrated(page, siblingId, project.workspaceId));
|
||||
|
||||
releaseHost();
|
||||
await test.step("recover missing workspace and retain both after reload", () =>
|
||||
expectRecoveredWorkspacesAfterReload(page, ids, testInfo));
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
@@ -370,18 +572,21 @@ test.describe("Half-screen desktop layout", () => {
|
||||
|
||||
await openFilesPanel(page);
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await explorerToggle.click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||
|
||||
const SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId, agentId: parentId });
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${parentId}`)).toBeVisible();
|
||||
await page.getByTestId("workspace-pane-main").getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-agent").click();
|
||||
await page.getByTestId(`workspace-tab-agent_${parentId}`).click();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${parentId}`)
|
||||
.click();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
|
||||
|
||||
const left = page.getByTestId("workspace-pane-main");
|
||||
const right = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${parentId}`) });
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a subagent opened from a right-pane parent stays with its parent when left has focus", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-origin-pane-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the side preference opens a right-pane parent's subagent in the side pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((key) => {
|
||||
localStorage.setItem(key, JSON.stringify({ openInSidePane: { subagents: true } }));
|
||||
}, SETTINGS_KEY);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-side-preference-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
const childPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${pair.child.id}`) });
|
||||
await expect(childPane).toBeVisible();
|
||||
expect(await childPane.getAttribute("data-testid")).not.toBe(
|
||||
await right.getAttribute("data-testid"),
|
||||
);
|
||||
await expect(childPane).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an already-tabbed subagent reveals its left tab without a duplicate", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-existing-tab-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowLeft");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await right.getByTestId(`workspace-tab-agent_${pair.parent.id}`).click();
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-file-origin-pane-",
|
||||
title: "File link parent",
|
||||
initialPrompt: [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
|
||||
const { left, right } = await openParentInRightPane(
|
||||
page,
|
||||
workspace.agentId,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
const fileLink = right.getByText(target, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
await expect(right.getByTestId("workspace-tab-file_linked.ts")).toBeVisible();
|
||||
await expect(left.getByTestId("workspace-tab-file_linked.ts")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -199,6 +199,49 @@ async function quitVim(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
interface TerminalChord {
|
||||
key: string;
|
||||
bytes: string;
|
||||
}
|
||||
|
||||
async function openKeyboardCapture(page: Page) {
|
||||
await test.step("Open a real terminal process that reports received bytes", async () => {
|
||||
const terminal = await harness.createTerminal({
|
||||
name: "Keyboard bytes",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`
|
||||
process.stdin.setRawMode(true);
|
||||
let received = Buffer.alloc(0);
|
||||
process.stdin.on('data', chunk => {
|
||||
received = Buffer.concat([received, chunk]);
|
||||
process.stdout.write('\\r\\nBYTES:' + received.toString('hex') + ':END\\r\\n');
|
||||
});
|
||||
console.log('KEYBOARD_READY');
|
||||
`,
|
||||
],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("KEYBOARD_READY");
|
||||
});
|
||||
}
|
||||
|
||||
async function expectTerminalChords(page: Page, chords: TerminalChord[]) {
|
||||
let received = "";
|
||||
for (const chord of chords) {
|
||||
await test.step(`${chord.key} sends ${JSON.stringify(chord.bytes)}`, async () => {
|
||||
await pressTerminalShortcut(page, chord.key);
|
||||
// A printable sentinel proves delivery even for chords expected to send nothing.
|
||||
await input(page).pressSequentially("x");
|
||||
received += Buffer.from(chord.bytes + "x").toString("hex");
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain(`BYTES:${received}:END`);
|
||||
});
|
||||
}
|
||||
await recordTerminalEvidence("keyboard-bytes", await getTerminalBufferText(page));
|
||||
await page.screenshot({ path: test.info().outputPath("keyboard-chords.png") });
|
||||
}
|
||||
|
||||
let harness: TerminalE2EHarness;
|
||||
test.beforeEach(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" });
|
||||
@@ -213,6 +256,28 @@ test.describe("macOS terminal shortcuts", () => {
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("encodes editing chords without confusing Control and Command", async ({ page }) => {
|
||||
await openKeyboardCapture(page);
|
||||
await expectTerminalChords(page, [
|
||||
{ key: "Meta+ArrowLeft", bytes: "\x01" },
|
||||
{ key: "Meta+ArrowRight", bytes: "\x05" },
|
||||
{ key: "Alt+ArrowLeft", bytes: "\x1bb" },
|
||||
{ key: "Alt+ArrowRight", bytes: "\x1bf" },
|
||||
{ key: "Meta+Backspace", bytes: "\x15" },
|
||||
{ key: "Control+c", bytes: "\x03" },
|
||||
{ key: "Control+a", bytes: "\x01" },
|
||||
{ key: "Control+e", bytes: "\x05" },
|
||||
{ key: "Control+v", bytes: "\x16" },
|
||||
{ key: "Control+f", bytes: "\x06" },
|
||||
{ key: "ArrowLeft", bytes: "\x1b[D" },
|
||||
{ key: "ArrowRight", bytes: "\x1b[C" },
|
||||
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
|
||||
{ key: "Control+Alt+ArrowLeft", bytes: "\x1b[1;7D" },
|
||||
]);
|
||||
await pressTerminalShortcut(page, "Meta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test("sends Control+f to cat and opens Find with Meta+f", async ({ page }) => {
|
||||
await openControlCharacterTerminal(page);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
@@ -232,7 +297,40 @@ test.describe("macOS terminal shortcuts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function useLinuxKeyboardPlatform(page: Page) {
|
||||
// A user agent alone leaves navigator.platform as MacIntel on macOS hosts.
|
||||
// Emulate both browser identity fields before the app reads its keyboard policy.
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await session.send("Emulation.setUserAgentOverride", {
|
||||
userAgent:
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
platform: "Linux x86_64",
|
||||
});
|
||||
}
|
||||
|
||||
test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }) => {
|
||||
await useLinuxKeyboardPlatform(page);
|
||||
await openKeyboardCapture(page);
|
||||
await expectTerminalChords(page, [
|
||||
{ key: "Meta+ArrowLeft", bytes: "" },
|
||||
{ key: "Meta+ArrowRight", bytes: "" },
|
||||
{ key: "Alt+ArrowLeft", bytes: "\x1b[1;3D" },
|
||||
{ key: "Alt+ArrowRight", bytes: "\x1b[1;3C" },
|
||||
{ key: "Meta+Backspace", bytes: "\x7f" },
|
||||
{ key: "Control+c", bytes: "\x03" },
|
||||
{ key: "Control+a", bytes: "\x01" },
|
||||
{ key: "Control+e", bytes: "\x05" },
|
||||
{ key: "Control+ArrowLeft", bytes: "\x1b[1;5D" },
|
||||
{ key: "Control+ArrowRight", bytes: "\x1b[1;5C" },
|
||||
{ key: "Control+Backspace", bytes: "\x08" },
|
||||
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
|
||||
]);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => {
|
||||
await useLinuxKeyboardPlatform(page);
|
||||
await openControlCharacterTerminal(page);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
installUsageReportsFixture,
|
||||
type UsageListRequest,
|
||||
type UsageReportsFixture,
|
||||
} from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installLoginUsage,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
openUsage,
|
||||
refreshLoginUsage,
|
||||
hoverUsageWindow,
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectPinnedUsage,
|
||||
pinRow,
|
||||
openUsageOptions,
|
||||
closeUsageOptions,
|
||||
expectUsageOptionsFitContent,
|
||||
showUsageAs,
|
||||
refreshAllUsage,
|
||||
togglePin,
|
||||
usageItem,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const emptyHome = path.join(tmpdir(), `paseo-usage-empty-${randomUUID()}`);
|
||||
test.use({
|
||||
e2eDaemonEnvironment: {
|
||||
HOME: emptyHome,
|
||||
USERPROFILE: emptyHome,
|
||||
CODEX_HOME: emptyHome,
|
||||
CLAUDE_CONFIG_DIR: emptyHome,
|
||||
XDG_DATA_HOME: emptyHome,
|
||||
PI_CODING_AGENT_DIR: emptyHome,
|
||||
COPILOT_TOKEN: "",
|
||||
GITHUB_TOKEN: "",
|
||||
GITHUB_PAT: "",
|
||||
CURSOR_ACCESS_TOKEN: "",
|
||||
CURSOR_TOKEN: "",
|
||||
GROK_API_KEY: "",
|
||||
GROK_TOKEN: "",
|
||||
KIMI_TOKEN: "",
|
||||
KIMI_API_KEY: "",
|
||||
KIMI_CODE_HOME: emptyHome,
|
||||
MINIMAX_API_KEY: "",
|
||||
ZAI_API_KEY: "",
|
||||
GLM_API_KEY: "",
|
||||
},
|
||||
});
|
||||
|
||||
// These tests read the sidebar summary and pin windows, which both need the Usage item on.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
});
|
||||
|
||||
function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] {
|
||||
return usage.listRequests().filter((request) => request.forceRefresh);
|
||||
}
|
||||
|
||||
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
|
||||
function twoHoursAgo(): string {
|
||||
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
|
||||
function hostFilter(page: Page) {
|
||||
return page.locator('[data-testid="usage-host-filter-trigger"]:visible');
|
||||
}
|
||||
|
||||
function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
||||
return {
|
||||
id: `${sourceId}:a`,
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId,
|
||||
sourceLabel: `${sourceId} plan`,
|
||||
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct }] },
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("usage screen", () => {
|
||||
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
{
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "beta:b",
|
||||
account: {},
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active coding plan" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(hostFilter(page)).toHaveCount(0);
|
||||
await qaScreenshot(page, "phase7-usage-screen-one-host");
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").first().hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("refreshes one report from its card", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const beta: UsageReportEntry = {
|
||||
id: "beta:b",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 12 }],
|
||||
},
|
||||
};
|
||||
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt,
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct }],
|
||||
},
|
||||
});
|
||||
// The sidebar summary and the screen each load reports; only a card's Refresh forces one.
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
(request) =>
|
||||
request.forceRefresh
|
||||
? [alpha(58, new Date().toISOString())]
|
||||
: [alpha(31, twoHoursAgo()), beta],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await expect
|
||||
.poll(() => forcedRefreshes(usage))
|
||||
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").nth(1).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await alphaRefresh.hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
||||
});
|
||||
|
||||
test("shows the host once it connects after a cold load on a phone", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
{
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { status: "available", windows: [] },
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/usage");
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
||||
});
|
||||
|
||||
test("tells the user to update a host without usage support", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
// Without reports the footer has no Usage item; its Usage icon opens the screen.
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await qaScreenshot(page, "phase7-usage-update-host");
|
||||
await expect(page.getByTestId("usage-options-menu")).toHaveCount(0);
|
||||
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(420_000);
|
||||
const primaryServerId = getServerId();
|
||||
const secondary = await startIsolatedHostDaemon(
|
||||
`srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
|
||||
);
|
||||
try {
|
||||
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
|
||||
await installUsageReportsFixture(page, {
|
||||
port: secondary.port,
|
||||
lists: [[weeklyReport("beta", 12)]],
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondary.serverId,
|
||||
label: "Secondary box",
|
||||
port: secondary.port,
|
||||
});
|
||||
|
||||
// Nothing picked and no workspace open: the first host.
|
||||
await expectPinnedUsage(page, ["31% Weekly"]);
|
||||
await usageItem(page).click();
|
||||
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
||||
|
||||
await hostFilter(page).click();
|
||||
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-screen-picked-host");
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
|
||||
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondary.serverId,
|
||||
label: "Secondary box",
|
||||
port: secondary.port,
|
||||
});
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await secondary.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const loginWindows: UsageReportEntry["report"] = {
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct: 31 },
|
||||
{ id: "weekly", label: "Weekly", shortLabel: "wk", summary: true, usedPct: 54 },
|
||||
],
|
||||
};
|
||||
|
||||
test("expired login refreshes to windows with visible pin toggles", async ({ page }) => {
|
||||
const fixture = await installLoginUsage({
|
||||
status: "unavailable",
|
||||
problem: {
|
||||
kind: "expired",
|
||||
expiresAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
refreshedBy: "claude",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-expired-login");
|
||||
await fixture.setReport(loginWindows);
|
||||
await refreshLoginUsage(page);
|
||||
await expect(
|
||||
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
|
||||
await expect(row).toBeChecked();
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
||||
await hoverUsageWindow(page, "Weekly");
|
||||
await expect(page.getByText("Unpin", { exact: true })).toBeVisible();
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned").locator("svg")).not.toHaveAttribute(
|
||||
"fill",
|
||||
"none",
|
||||
);
|
||||
await qaScreenshot(page, "usage-pin-hover");
|
||||
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1");
|
||||
await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute(
|
||||
"fill",
|
||||
"none",
|
||||
);
|
||||
await hoverUsageWindow(page, "Weekly");
|
||||
await expect(page.getByText("Pin", { exact: true })).toBeVisible();
|
||||
await expectPinnedUsage(page, ["31% 5h"]);
|
||||
await qaScreenshot(page, "usage-pin-unselected");
|
||||
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
|
||||
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("compact usage rows always show the pin glyph", async ({ page }) => {
|
||||
const fixture = await installLoginUsage(loginWindows);
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await gotoAppShell(page);
|
||||
await openUsage(page);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
|
||||
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(1)).toHaveCSS("opacity", "1");
|
||||
await qaScreenshot(page, "usage-pin-compact");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const shape of ["seven-day-only", "Spark"] as const) {
|
||||
test(`Codex ${shape} windows use provider durations on the card and sidebar`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const reset = Math.floor(Date.now() / 1000) + 5 * 86400;
|
||||
const source = await installCodexWindowUsage({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 11, limit_window_seconds: 604800, reset_at: reset },
|
||||
secondary_window: null,
|
||||
},
|
||||
additional_rate_limits:
|
||||
shape === "Spark"
|
||||
? [
|
||||
{
|
||||
limit_name: "GPT-5.3-Codex-Spark",
|
||||
metered_feature: "codex_bengalfox",
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 0,
|
||||
limit_window_seconds: 18000,
|
||||
reset_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 0,
|
||||
limit_window_seconds: 604800,
|
||||
reset_at: reset,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
try {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await expect(usageItem(page)).toBeVisible();
|
||||
await usageItem(page).click();
|
||||
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
|
||||
await expectCodexReportedWindows(page, shape);
|
||||
await qaScreenshot(page, `codex-${shape}-pins`);
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const [size, viewport] of Object.entries({
|
||||
desktop: { width: 1440, height: 900 },
|
||||
compact: { width: 390, height: 844 },
|
||||
})) {
|
||||
test(`Usage Settings cog ${size} ${theme}`, async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript((value) => {
|
||||
const key = "@paseo:app-settings";
|
||||
const current = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
localStorage.setItem(key, JSON.stringify({ ...current, theme: value }));
|
||||
}, theme);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/usage");
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
const settings = page.getByTestId("usage-options-menu");
|
||||
await expect(settings).toHaveAccessibleName("Settings");
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-cog`);
|
||||
|
||||
await openUsageOptions(page);
|
||||
await expectUsageOptionsFitContent(page);
|
||||
await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveAttribute("aria-selected", "true");
|
||||
await captureSettingsState(page, testInfo, `${size}-${theme}-settings`);
|
||||
await showUsageAs(page, "remaining");
|
||||
await expect(screen.getByText("69% left")).toBeVisible();
|
||||
await showUsageAs(page, "used");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, 31% · resets /,
|
||||
);
|
||||
await openUsageOptions(page);
|
||||
await closeUsageOptions(page);
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function captureSettingsState(
|
||||
page: Page,
|
||||
testInfo: import("@playwright/test").TestInfo,
|
||||
name: string,
|
||||
) {
|
||||
await qaScreenshot(page, name);
|
||||
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
installUsageReportsFixture,
|
||||
type UsageReportsFixture,
|
||||
} from "../support/helpers/usage-reports";
|
||||
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
||||
|
||||
function forcedRefreshCount(usage: UsageReportsFixture): number {
|
||||
return usage.listRequests().filter((request) => request.forceRefresh).length;
|
||||
}
|
||||
|
||||
function report(input: {
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
report:
|
||||
| Partial<Extract<UsageReportEntry["report"], { status: "available" }>>
|
||||
| Exclude<UsageReportEntry["report"], { status: "available" }>;
|
||||
}): UsageReportEntry {
|
||||
return {
|
||||
id: `${input.sourceId}:account`,
|
||||
account: { label: input.sourceId === "alpha" ? "dev@example.com" : undefined },
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: input.sourceId,
|
||||
sourceLabel: input.sourceLabel,
|
||||
icon: ICON,
|
||||
report:
|
||||
input.report.status === "error" || input.report.status === "unavailable"
|
||||
? input.report
|
||||
: {
|
||||
status: "available",
|
||||
windows: [],
|
||||
...input.report,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("usage settings", () => {
|
||||
test("renders every report returned by usage.list_reports", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
planLabel: "Max",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7 }],
|
||||
},
|
||||
}),
|
||||
report({
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
planLabel: "Coding plan",
|
||||
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" }],
|
||||
},
|
||||
}),
|
||||
report({
|
||||
sourceId: "gamma",
|
||||
sourceLabel: "Gamma plan",
|
||||
report: { status: "error", error: "Gamma auth expired" },
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
|
||||
// The shared percentages setting applies to the host section.
|
||||
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
|
||||
const hostUsageUrl = page.url();
|
||||
await page.goto("/usage");
|
||||
await showUsageAs(page, "remaining");
|
||||
await page.goto(hostUsageUrl);
|
||||
await expect(card.getByText("30% left")).toBeVisible();
|
||||
await expect(card.getByText("93% left")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh forces a fresh report", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }];
|
||||
// The sidebar summary and the section each load reports; only Refresh forces one.
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
(request) => [
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { windows: windows(request.forceRefresh ? 64 : 23) },
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
|
||||
await expect(card.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks to update a host without usage support and never calls it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
await expect(
|
||||
// Names the host: "Update Laptop to see usage".
|
||||
page.getByTestId("usage-card").getByText(/^Update (?!the host ).+ to see usage$/),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
expectFooterItemSetting,
|
||||
expectFooterSeparator,
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsageSheet,
|
||||
closeUsageOptions,
|
||||
openUsageSheetFromIcon,
|
||||
expectNoUsageItem,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
expectSummaryInSidebar,
|
||||
expectUnpinnableRows,
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
openUsageScreenFromIcon,
|
||||
pinRow,
|
||||
setSummaryInSidebar,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
usageItem,
|
||||
usageSheet,
|
||||
installTallUsageSource,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
type ScreenshotArea = { kind: "page" } | { kind: "footer" } | { kind: "element"; locator: Locator };
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep QA screenshots of each state. */
|
||||
async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { kind: "page" }) {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let 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.kind === "element") {
|
||||
await area.locator.screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
const clip = area.kind === "footer" ? await footerClip(page) : undefined;
|
||||
await page.screenshot({ path: file, clip });
|
||||
}
|
||||
|
||||
/** Includes summary rows above the fixed icon row. */
|
||||
async function footerClip(page: Page) {
|
||||
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: false }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
const startingUrl = page.url();
|
||||
await expectNoUsageItem(page);
|
||||
|
||||
await test.step("the icon opens a sheet without leaving the current screen", async () => {
|
||||
await openUsageSheetFromIcon(page);
|
||||
await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-icon-sheet");
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await closeUsageSheet(page);
|
||||
});
|
||||
|
||||
await test.step("the summary opens the same sheet", async () => {
|
||||
await usageItem(page).click();
|
||||
await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
||||
await closeUsageSheet(page);
|
||||
});
|
||||
});
|
||||
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const source = await installTallUsageSource();
|
||||
try {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await usageItem(page).click();
|
||||
const content = page.getByTestId("sidebar-usage-sheet-content");
|
||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
||||
await waitForSettledPosition(content);
|
||||
const bounds = (await content.boundingBox())!;
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
||||
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
||||
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
|
||||
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const serverId = getServerId();
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
|
||||
await test.step("a fresh device shows default windows once the Usage item is on", async () => {
|
||||
// The Usage item starts off on every layout; turning it on stores the choice.
|
||||
await expectNoUsageItem(page);
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterItemSetting(page, "usage", false);
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await qaScreenshot(page, "desktop-settings-sidebar-footer");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expectFooterItemSetting(page, "usage", true);
|
||||
await qaScreenshot(page, "compact-settings-sidebar-footer");
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "compact-footer-defaults");
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await qaScreenshot(page, "default-pins-card");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked();
|
||||
});
|
||||
|
||||
await test.step("pinning Claude 5-hour and Codex weekly shows both in the Usage item", async () => {
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
// One host: no host filter, as on History.
|
||||
await expect(page.locator('[data-testid="usage-host-filter-trigger"]:visible')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Weekly")).toBeChecked();
|
||||
await qaScreenshot(page, "default-pins-after-one-toggle");
|
||||
await togglePin(screen, "Codex", "Session");
|
||||
await expectPinnedUsage(page, ["31% 5h", "12% wk"]);
|
||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, "usage-screen-header", {
|
||||
kind: "element",
|
||||
locator: page.getByTestId("page-title").locator(".."),
|
||||
});
|
||||
await qaScreenshot(page, "usage-card-refresh", {
|
||||
kind: "element",
|
||||
locator: screen.getByTestId("usage-report-claude:default"),
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude:default").getByText("69% left"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-codex:default").getByText("88% left"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("on desktop the Usage item opens the Usage screen", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
|
||||
await expect(sheet.getByText("88% left")).toBeVisible();
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(sheet, "Claude", "Weekly")).not.toBeChecked();
|
||||
// The row reads its window, percent and reset; the checkbox state says it is pinned.
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, \d+% left( · .+)?$/,
|
||||
);
|
||||
// Settings opens a second sheet; closing it restores the Usage sheet underneath.
|
||||
await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible();
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "compact-settings-expanded");
|
||||
await closeUsageOptions(page);
|
||||
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/usage$/);
|
||||
await waitForSettledPosition(sheet);
|
||||
const sheetBox = (await sheet.boundingBox())!;
|
||||
// The Usage sheet reserves the top 20% as its backdrop.
|
||||
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
||||
await qaScreenshot(page, "compact-sheet");
|
||||
await qaScreenshot(page, "phase7-compact-sheet");
|
||||
// Tap the backdrop above the sheet.
|
||||
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await page.setViewportSize(WIDE);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await qaScreenshot(page, "usage-settings-expanded");
|
||||
await closeUsageOptions(page);
|
||||
});
|
||||
|
||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
||||
const usageUrl = page.url();
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const section = page.getByTestId("usage-card");
|
||||
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
|
||||
await page.goto(usageUrl);
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
await test.step("unpinning everything stays empty after a reload", async () => {
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectNoUsageItem(page);
|
||||
await page.reload();
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoUsageItem(page);
|
||||
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked();
|
||||
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
|
||||
await qaScreenshot(page, "explicit-empty-pins-after-reload");
|
||||
});
|
||||
|
||||
await test.step("Settings > Sidebar lists the Usage item", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
const footer = page.getByTestId("sidebar-nav-section-footer");
|
||||
await expect(
|
||||
footer.getByTestId("sidebar-nav-item-usage").getByText("Usage", { exact: true }),
|
||||
).toBeVisible();
|
||||
await qaScreenshot(page, "settings-sidebar-footer", { kind: "element", locator: footer });
|
||||
await leaveSettings(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("without summary data the footer drops the Usage item and keeps the Usage icon", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[
|
||||
{
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt: new Date().toISOString(),
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active coding plan" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectNoUsageItem(page);
|
||||
await expectFooterSeparator(page, false);
|
||||
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
|
||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
const claude = screen.getByTestId("usage-report-claude:default");
|
||||
|
||||
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
|
||||
await openUsageScreenFromIcon(page);
|
||||
await expectNoUsageItem(page);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await qaScreenshot(page, "usage-screen-summary-off");
|
||||
});
|
||||
|
||||
await test.step("on: the Usage item and the pins appear", async () => {
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "usage-screen-summary-on");
|
||||
});
|
||||
|
||||
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
|
||||
await setSummaryInSidebar(page, false);
|
||||
await expectNoUsageItem(page);
|
||||
await expectUnpinnableRows(claude);
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test("released hosts supply source logos through the client conversion", async ({ page }) => {
|
||||
await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
providerUsageListOnly: true,
|
||||
});
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
// Source logos are decorative SVGs with no accessible role. Their path data distinguishes
|
||||
// the source artwork from the fallback gauge.
|
||||
const claudePath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[0]!.icon!)![1]!;
|
||||
const codexPath = /<path[^>]* d="([^"]+)"/.exec(claudeAndCodexReports()[1]!.icon!)![1]!;
|
||||
const summary = usageItem(page).getByTestId("sidebar-usage-source");
|
||||
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
|
||||
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-claude").locator("svg path").first(),
|
||||
).toHaveAttribute("d", claudePath);
|
||||
await expect(
|
||||
screen.getByTestId("usage-report-codex").locator("svg path").first(),
|
||||
).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-usage-screen");
|
||||
});
|
||||
@@ -203,6 +203,41 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a workspace usable when its setup config cannot be parsed", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-invalid-config-", {
|
||||
files: [{ path: "paseo.json", content: "{ invalid json\n" }],
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed",
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-invalid-config",
|
||||
});
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.error).toContain("Failed to parse paseo.json");
|
||||
expect(failedPayload.detail.commands).toEqual([]);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||
await expect(page.getByText(/Failed to parse paseo\.json at/)).toBeVisible();
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "paseo.json");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user