mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85fdc875f6 | ||
|
|
7ae1ffc913 | ||
|
|
9da676d709 | ||
|
|
dfb8a1a2bc | ||
|
|
8ced059b4d | ||
|
|
da9fae172d | ||
|
|
f24bb523e8 | ||
|
|
cf2b3f4bb3 | ||
|
|
a50e14b69e | ||
|
|
7b43f21940 | ||
|
|
2f9a786061 | ||
|
|
6b00310f7b | ||
|
|
981bb4c409 | ||
|
|
3ee4c49e3c | ||
|
|
fbd1bc47e0 | ||
|
|
36e6ccd6c7 | ||
|
|
fa0f04058d | ||
|
|
d517b3314e | ||
|
|
a8e6c57f15 | ||
|
|
1f8a4559a8 | ||
|
|
2187124642 | ||
|
|
154a088d6e | ||
|
|
71b236d38a | ||
|
|
8a4ce5a482 | ||
|
|
b3ed7363c6 | ||
|
|
c062280429 | ||
|
|
febe9611bf | ||
|
|
642d69b142 | ||
|
|
5aee41952e | ||
|
|
48329facc3 | ||
|
|
97083dd736 | ||
|
|
5c2c85ebc8 | ||
|
|
30fb66c8cb | ||
|
|
05b074764d | ||
|
|
5285b7e502 | ||
|
|
409b8f9312 | ||
|
|
485221b739 | ||
|
|
ed48655a77 | ||
|
|
cc8fe41e28 | ||
|
|
8216e86ecb | ||
|
|
79a3942393 | ||
|
|
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 |
@@ -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"
|
||||
|
||||
|
||||
+119
@@ -1,5 +1,124 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.4 - 2026-10-05
|
||||
|
||||
### Added
|
||||
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
||||
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m))
|
||||
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
|
||||
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
|
||||
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
||||
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
|
||||
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
|
||||
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
|
||||
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
|
||||
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
|
||||
- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090))
|
||||
- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805))
|
||||
- Changed the sidebar workspace row to show last activity by default on new installs ([#6087](https://github.com/getpaseo/paseo/pull/6087))
|
||||
- Changed compact sidebar footer buttons to 44×44 tap targets ([#6044](https://github.com/getpaseo/paseo/pull/6044))
|
||||
- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds))
|
||||
- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750))
|
||||
- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Changed relative timestamps in agent, schedule, and Import session rows to keep advancing while on screen ([#5340](https://github.com/getpaseo/paseo/pull/5340))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running ([#6067](https://github.com/getpaseo/paseo/pull/6067))
|
||||
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
|
||||
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
|
||||
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
|
||||
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
|
||||
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
|
||||
- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx))
|
||||
- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD))
|
||||
- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil))
|
||||
- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork))
|
||||
- Fixed OMP tool rows showing raw objects or generic labels for failed tools, Task, Wait, web search, and fetch ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||
- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi))
|
||||
- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff))
|
||||
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
|
||||
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
|
||||
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
|
||||
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
|
||||
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
|
||||
- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
|
||||
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed Codex custom prompt arguments containing `$` sequences reaching Codex altered ([#5669](https://github.com/getpaseo/paseo/pull/5669) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed Codex structured output failing when the schema has a field named `properties` ([#5684](https://github.com/getpaseo/paseo/pull/5684))
|
||||
- Fixed a Codex agent created without a thinking option losing its default effort after a daemon restart ([#5686](https://github.com/getpaseo/paseo/pull/5686))
|
||||
- Fixed only one permission showing when Codex asks to approve two commands from one item ([#5758](https://github.com/getpaseo/paseo/pull/5758))
|
||||
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
|
||||
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
|
||||
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
|
||||
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
|
||||
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
|
||||
- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone))
|
||||
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
|
||||
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
|
||||
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
|
||||
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
|
||||
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
|
||||
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
|
||||
- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
|
||||
- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058))
|
||||
- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang))
|
||||
- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
|
||||
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
|
||||
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
|
||||
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
|
||||
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed `paseo agent delete --all` and `--cwd` leaving archived agents behind ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed `paseo agent` commands reporting a generic failure instead of `AGENT_NOT_FOUND` for an unknown agent ID ([#6053](https://github.com/getpaseo/paseo/pull/6053))
|
||||
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
|
||||
- Fixed `paseo restart` exiting with `RESTART_NOT_CONFIRMED` when provider checks take more than a second ([#6009](https://github.com/getpaseo/paseo/pull/6009))
|
||||
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||
- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815))
|
||||
|
||||
## 0.10.3 - 2026-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]`) |
|
||||
|
||||
+4
-2
@@ -182,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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+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
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Import session, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Sidebar items** — The 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).
|
||||
@@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, or a plain "Usage" row while no pinned window has data; pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card 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.
|
||||
|
||||
+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:
|
||||
|
||||
+63
-24
@@ -88,34 +88,69 @@ never deletes it. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
|
||||
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
|
||||
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
|
||||
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
|
||||
list. 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.
|
||||
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
|
||||
|
||||
@@ -420,7 +455,11 @@ 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. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
@@ -449,7 +488,7 @@ 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`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery 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.
|
||||
|
||||
@@ -463,7 +502,7 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
|
||||
- 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, Import session,
|
||||
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
|
||||
|
||||
+21
-54
@@ -6,7 +6,7 @@ server integrations.
|
||||
## Plugin providers
|
||||
|
||||
Keep a bundled provider in `plugins/<id>/` and register it through
|
||||
`@getpaseo/plugin/server/provider`. Antigravity follows this pattern. Built-in loading and SDK
|
||||
`@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.
|
||||
|
||||
@@ -15,52 +15,22 @@ executable resolution and applies `agents.providers.<provider-id>.command` and `
|
||||
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.
|
||||
|
||||
For Antigravity installation, modes, and limitations, see
|
||||
[Supported providers](../public-docs/supported-providers.md#antigravity).
|
||||
| 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 are not accepted as
|
||||
top-level provider options.
|
||||
The provider owns validation and application of the opaque record in
|
||||
`AgentSessionConfig.providerOptions`. The registry supplies the effective options
|
||||
at session startup. See [provider configuration](custom-providers.md#provider-options)
|
||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||
for native keys and examples.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
- **Codex:** `approval_policy`, `sandbox_mode`,
|
||||
`sandbox_workspace_write.{writable_roots,network_access,exclude_slash_tmp,exclude_tmpdir_env_var}`,
|
||||
`web_search`, `features.multi_agent_v2`, and `features.network_proxy`. A network proxy object may
|
||||
contain `enabled`, `proxy_url`, `socks_url`, `enable_socks5`, `enable_socks5_udp`,
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
|
||||
`settings`. `providerOptions.extraArgs` passes the SDK's documented
|
||||
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
|
||||
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
|
||||
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
|
||||
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
|
||||
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
|
||||
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
|
||||
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
|
||||
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
|
||||
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
|
||||
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
- **OpenCode:** `permission`, either one `ask`/`allow`/`deny` action or the native per-tool rule
|
||||
object. Supported entries are `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`,
|
||||
`external_directory`, `todowrite`, `question`, `webfetch`, `websearch`, `codesearch`,
|
||||
`repo_clone`, `repo_overview`, `lsp`, `doom_loop`, and `skill`. See the
|
||||
[OpenCode permissions reference](https://opencode.ai/docs/permissions/). OpenCode permissions are
|
||||
application policy, not an OS sandbox.
|
||||
|
||||
Each provider definition owns its option schema and exact MCP preapproval mapping. A new provider
|
||||
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
|
||||
and tool identity without approving native tools.
|
||||
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.
|
||||
|
||||
## Core adapter patterns
|
||||
|
||||
@@ -85,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/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.
|
||||
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.
|
||||
|
||||
@@ -101,7 +71,9 @@ Paseo's per-agent and daemon-wide system prompts are appended by its generated P
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
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.
|
||||
|
||||
@@ -215,15 +187,10 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
## Usage sources
|
||||
|
||||
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and required `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details.
|
||||
|
||||
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
|
||||
|
||||
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
|
||||
|
||||
### Credentials are read only
|
||||
|
||||
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-17
@@ -95,7 +95,7 @@ release push as the changelog and version commit.
|
||||
There are two supported release paths:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||
|
||||
Paseo has one linear release track even though npm dist-tags are independent
|
||||
pointers. The npm invariant is:
|
||||
@@ -194,7 +194,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
|
||||
- **A beta series has one changelog entry.** Each beta folds into it and renames its heading to the new `## X.Y.Z-beta.N`, which `Release Notes Sync` mirrors into that prerelease body. Promotion renames it to the stable heading. See the Changelog policy section.
|
||||
|
||||
Use the beta path when you need to:
|
||||
|
||||
@@ -414,7 +414,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
|
||||
|
||||
## Fixing a failed release build
|
||||
@@ -521,17 +521,20 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
- `CHANGELOG.md` holds stable releases and at most one entry for the current beta series.
|
||||
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag.
|
||||
- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry.
|
||||
- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series.
|
||||
- Each subsequent beta rewrites that entry in place: the heading becomes the new beta number and date, and the body describes the full change from the previous stable release to this beta. Never add a second beta entry.
|
||||
- The entry is a list of changes, not of PRs. When a later beta reverts, reworks, or follows up on something an earlier beta added, rewrite the bullet to the current behavior or delete it. A stable user never saw the earlier beta behavior, so fixes to it are not "Fixed" bullets.
|
||||
- Anything that already shipped to stable through a patch release on an older line stays out of the beta entry.
|
||||
- Stable promotion renames the entry to `## 0.1.60 - YYYY-MM-DD` after a final review.
|
||||
|
||||
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
|
||||
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
- Every entry, beta or stable, is drafted from the previous stable release to the release source. For a later beta, read the PRs since the previous beta tag, then rework the existing entry so each bullet still describes current behavior. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
|
||||
## Changelog wording
|
||||
|
||||
@@ -563,7 +566,7 @@ that changed, the failure that was fixed, or the capability that was added? If t
|
||||
claims a general improvement, rewrite it with the concrete change.
|
||||
|
||||
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
|
||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
@@ -631,12 +634,7 @@ Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. T
|
||||
|
||||
Changelog scope follows the release being described:
|
||||
|
||||
- **First beta**: `previous stable tag → release source`
|
||||
- **Later beta**: `previous beta tag → release source`
|
||||
- **Direct stable release**: `previous stable tag → release source`
|
||||
- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source`
|
||||
|
||||
Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next.
|
||||
Every entry covers `previous stable release → release source`: first beta, later beta, direct stable release, and promotion alike. The beta entry always reads as the stable entry would if the beta were promoted today.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
@@ -644,7 +642,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
|
||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
@@ -667,7 +665,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
|
||||
sha256-aAt1FdwSeAqg8u14oHHHf0B0/eN97qIqApcbxv2y3s0=
|
||||
|
||||
Generated
+57
-40
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -6330,6 +6330,15 @@
|
||||
"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
|
||||
@@ -26261,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",
|
||||
@@ -36415,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36480,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",
|
||||
@@ -37335,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",
|
||||
@@ -37481,12 +37496,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/server": "0.10.0",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/server": "0.11.0-beta.4",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37733,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37747,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37991,7 +38006,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38887,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38916,14 +38931,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38933,8 +38948,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38956,7 +38971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38983,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39201,16 +39216,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.10.0",
|
||||
"@getpaseo/highlight": "0.10.0",
|
||||
"@getpaseo/plugin": "0.10.0",
|
||||
"@getpaseo/protocol": "0.10.0",
|
||||
"@getpaseo/relay": "0.10.0",
|
||||
"@getpaseo/client": "0.11.0-beta.4",
|
||||
"@getpaseo/highlight": "0.11.0-beta.4",
|
||||
"@getpaseo/plugin": "0.11.0-beta.4",
|
||||
"@getpaseo/protocol": "0.11.0-beta.4",
|
||||
"@getpaseo/relay": "0.11.0-beta.4",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40131,10 +40146,12 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@getpaseo/protocol": "*",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-start": "^1.168.26",
|
||||
"framer-motion": "^12.35.2",
|
||||
@@ -41736,7 +41753,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.0.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
|
||||
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
|
||||
|
||||
test("agent list age advances and command center says just now for a fresh agent", async ({
|
||||
page,
|
||||
@@ -41,9 +40,6 @@ test("agent list age advances and command center says just now for a fresh agent
|
||||
"3m ago",
|
||||
);
|
||||
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expectComposerDraft,
|
||||
expectComposerFocused,
|
||||
expectComposerNotFocused,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
@@ -27,3 +28,51 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-open-",
|
||||
title: "Composer focus on open",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForAnimationFrames(page: Page, count: number): Promise<void> {
|
||||
await page.evaluate(async (frames) => {
|
||||
for (let frame = 0; frame < frames; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}, count);
|
||||
}
|
||||
|
||||
test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
|
||||
// composer there raises the on-screen keyboard over the conversation.
|
||||
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("opening an agent leaves the composer unfocused", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-touch-",
|
||||
title: "Composer focus on touch",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
expect(
|
||||
await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches),
|
||||
).toBe(false);
|
||||
// Autofocus makes its first attempt on the next animation frame.
|
||||
await waitForAnimationFrames(page, 4);
|
||||
await expectComposerNotFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,46 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
submitMessageWithButton,
|
||||
} from "../support/helpers/composer";
|
||||
import {
|
||||
type AgentUsageScript,
|
||||
closeContextWindowSheet,
|
||||
contextWindowDetails,
|
||||
expectRefreshButtons,
|
||||
expiredLogin,
|
||||
gate,
|
||||
hoverContextWindowMeter,
|
||||
leaveContextWindowMeter,
|
||||
type MockAgentSession,
|
||||
moveOntoContextWindowDetails,
|
||||
onPersonalLogin,
|
||||
onWorkLogin,
|
||||
openAgent,
|
||||
pressContextWindowMeter,
|
||||
qaScreenshot,
|
||||
refreshUsageCard,
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
seedAgentWithContextWindow,
|
||||
usageCard,
|
||||
} from "../support/helpers/context-window";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item";
|
||||
import { installLoginUsage } from "../support/helpers/usage-login";
|
||||
|
||||
const test = base.extend<{ agent: MockAgentSession }>({
|
||||
agent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedAgentWithContextWindow();
|
||||
try {
|
||||
await provide(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
||||
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
||||
@@ -69,3 +108,342 @@ test.describe("context window meter", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const DESKTOP = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/;
|
||||
|
||||
for (const layout of [
|
||||
{
|
||||
name: "desktop",
|
||||
viewport: DESKTOP,
|
||||
open: hoverContextWindowMeter,
|
||||
close: leaveContextWindowMeter,
|
||||
},
|
||||
{
|
||||
name: "compact",
|
||||
viewport: COMPACT,
|
||||
open: pressContextWindowMeter,
|
||||
close: closeContextWindowSheet,
|
||||
},
|
||||
]) {
|
||||
test(`shows account usage before the first turn (${layout.name})`, async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const source = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 31 }],
|
||||
});
|
||||
try {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-empty-",
|
||||
title: "Agent before its first turn",
|
||||
});
|
||||
try {
|
||||
await page.setViewportSize(layout.viewport);
|
||||
await openAgent(page, agent);
|
||||
|
||||
await test.step("the empty meter opens context details and account usage", async () => {
|
||||
const details = await layout.open(page, "Context window: No context data");
|
||||
await expect(details.getByText("No context data", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("0% used", { exact: true })).toHaveCount(0);
|
||||
await expect(details.getByText(/tokens$/)).toHaveCount(0);
|
||||
const account = usageCard(details, "login-journey:account");
|
||||
await expect(account.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31%", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, `context-without-data-${layout.name}`);
|
||||
await layout.close(page);
|
||||
});
|
||||
|
||||
await test.step("the first context reading replaces the empty state", async () => {
|
||||
await submitMessageWithButton(page, "emit 32000 byte file agent stream payload");
|
||||
const details = await layout.open(page);
|
||||
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await source.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type OpenDetails = (page: Page) => Promise<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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -96,6 +96,36 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("jumps to each relative reference to the same file", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "relative-reference-qa-",
|
||||
title: "Relative line references",
|
||||
initialPrompt: "Show both references",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First `src/router.go:46`. Then `src/router.go:403`.",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "src"));
|
||||
await writeFile(
|
||||
path.join(session.cwd, "src/router.go"),
|
||||
Array.from({ length: 450 }, (_, n) => `// line ${n + 1}`).join("\n"),
|
||||
);
|
||||
await openAgentRoute(page, session);
|
||||
for (const line of [46, 403, 46]) {
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByText(`src/router.go:${line}`, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Line ${line}, column 1`)).toBeVisible();
|
||||
}
|
||||
await page.screenshot({ path: test.info().outputPath("relative-line-target.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -279,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);
|
||||
});
|
||||
@@ -128,7 +128,6 @@ 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.revealMobileEntryPoint();
|
||||
await flow.openGlobally();
|
||||
|
||||
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
|
||||
@@ -141,13 +140,13 @@ 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"],
|
||||
|
||||
@@ -337,7 +337,7 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
|
||||
expect(counts.every((count) => count === initialCount)).toBe(true);
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 });
|
||||
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -20,10 +20,13 @@ import {
|
||||
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";
|
||||
|
||||
@@ -40,22 +43,21 @@ async function qaScreenshot(page: Page, name: string, area?: Locator) {
|
||||
else await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
/** Footer rows sit between the fixed Add project row and the fixed icon row, full width. */
|
||||
/** 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 hosts = (await visibleTestId(page, "sidebar-hosts-trigger").boundingBox())!;
|
||||
const settings = (await visibleTestId(page, "sidebar-settings").boundingBox())!;
|
||||
const box = (await row.boundingBox())!;
|
||||
return {
|
||||
belowAddProject: box.y >= addProject.y + addProject.height,
|
||||
aboveIconRow: box.y + box.height <= hosts.y,
|
||||
aboveBottomLine: box.y + box.height <= addProject.y,
|
||||
alignedLeft: Math.abs(box.x - addProject.x) < 1,
|
||||
fullWidth: Math.abs(box.width - addProject.width) < 1,
|
||||
alignedRight: Math.abs(box.x + box.width - (settings.x + settings.width)) < 1,
|
||||
};
|
||||
})
|
||||
.toEqual({ belowAddProject: true, aboveIconRow: true, alignedLeft: true, fullWidth: true });
|
||||
.toEqual({ aboveBottomLine: true, alignedLeft: true, alignedRight: true });
|
||||
}
|
||||
|
||||
function botRow(page: Page, rowId: string): Locator {
|
||||
@@ -153,6 +155,9 @@ test.describe("Plugin sidebar items", () => {
|
||||
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");
|
||||
@@ -188,7 +193,7 @@ test.describe("Plugin sidebar items", () => {
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the footer row renders as a row between Add project and the icon row", async () => {
|
||||
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();
|
||||
|
||||
@@ -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,8 +1,18 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items";
|
||||
import {
|
||||
expectFooterIconRow,
|
||||
expectFooterSeparator,
|
||||
hoverFooterAddProject,
|
||||
footerScreenshot,
|
||||
expectFooterItemHidden,
|
||||
expectFooterOrder,
|
||||
expectFooterSettingsKeys,
|
||||
@@ -21,6 +31,53 @@ import {
|
||||
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);
|
||||
@@ -130,8 +187,11 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
|
||||
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 },
|
||||
@@ -147,6 +207,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
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 () => {
|
||||
@@ -160,6 +221,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
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 () => {
|
||||
@@ -169,6 +231,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expectFooterItemHidden(page, "usage");
|
||||
await expectFooterIconRow(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterSettingsKeys(page, [syncKey, "usage", brokenKey]);
|
||||
});
|
||||
|
||||
@@ -88,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);
|
||||
|
||||
@@ -572,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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,10 +1,73 @@
|
||||
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 { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
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 {
|
||||
@@ -20,15 +83,25 @@ async function qaScreenshot(page: Page, name: string) {
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
|
||||
/** The host selector names the host shown, even when it is the only one. */
|
||||
async function shownHostLabel(page: Page): Promise<string> {
|
||||
const selector = page.locator('[data-testid="usage-host-switcher"]:visible');
|
||||
await expect(selector).toHaveAccessibleName(/^Usage host: .+/, { timeout: 10_000 });
|
||||
return (await selector.innerText()).trim();
|
||||
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, naming the one host", async ({ page }) => {
|
||||
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, {
|
||||
@@ -51,7 +124,10 @@ test.describe("usage screen", () => {
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: { status: "unavailable", windows: [] },
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active coding plan" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -64,10 +140,7 @@ test.describe("usage screen", () => {
|
||||
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
const hostLabel = await shownHostLabel(page);
|
||||
await expect(page.locator('[data-testid="usage-host-switcher"]:visible')).toHaveAccessibleName(
|
||||
`Usage host: ${hostLabel}`,
|
||||
);
|
||||
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();
|
||||
@@ -103,13 +176,13 @@ test.describe("usage screen", () => {
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct }],
|
||||
},
|
||||
});
|
||||
// The sidebar summary and the screen each load reports; only a card's Refresh forces one.
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[alpha(31, twoHoursAgo()), beta],
|
||||
() => [
|
||||
alpha(58, new Date().toISOString()),
|
||||
{ ...beta, fetchedAt: new Date().toISOString() },
|
||||
],
|
||||
(request) =>
|
||||
request.forceRefresh
|
||||
? [alpha(58, new Date().toISOString())]
|
||||
: [alpha(31, twoHoursAgo()), beta],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -120,11 +193,9 @@ test.describe("usage screen", () => {
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await usage.waitForListRequests(2);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
{ forceRefresh: false, reportIds: undefined },
|
||||
{ forceRefresh: true, reportIds: ["alpha:a"] },
|
||||
]);
|
||||
await expect
|
||||
.poll(() => forcedRefreshes(usage))
|
||||
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
|
||||
@@ -167,15 +238,239 @@ test.describe("usage screen", () => {
|
||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
// 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();
|
||||
|
||||
const hostLabel = await shownHostLabel(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${serverId}`).getByText(`Update ${hostLabel} to see usage`, {
|
||||
exact: true,
|
||||
}),
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -3,14 +3,24 @@ 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 } from "../support/helpers/usage-reports";
|
||||
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<UsageReportEntry["report"]>;
|
||||
report:
|
||||
| Partial<Extract<UsageReportEntry["report"], { status: "available" }>>
|
||||
| Exclude<UsageReportEntry["report"], { status: "available" }>;
|
||||
}): UsageReportEntry {
|
||||
return {
|
||||
id: `${input.sourceId}:account`,
|
||||
@@ -19,11 +29,14 @@ function report(input: {
|
||||
sourceId: input.sourceId,
|
||||
sourceLabel: input.sourceLabel,
|
||||
icon: ICON,
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [],
|
||||
...input.report,
|
||||
},
|
||||
report:
|
||||
input.report.status === "error" || input.report.status === "unavailable"
|
||||
? input.report
|
||||
: {
|
||||
status: "available",
|
||||
windows: [],
|
||||
...input.report,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +82,6 @@ test.describe("usage settings", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usage.waitForListRequests(1);
|
||||
|
||||
@@ -83,7 +95,12 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
|
||||
|
||||
await card.getByTestId("usage-display-remaining").click();
|
||||
// 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();
|
||||
});
|
||||
@@ -92,20 +109,14 @@ test.describe("usage settings", () => {
|
||||
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(23) },
|
||||
}),
|
||||
],
|
||||
[
|
||||
report({
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: { windows: windows(64) },
|
||||
report: { windows: windows(request.forceRefresh ? 64 : 23) },
|
||||
}),
|
||||
],
|
||||
],
|
||||
@@ -114,13 +125,12 @@ test.describe("usage settings", () => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await usage.waitForListRequests(2);
|
||||
|
||||
expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true });
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
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 }) => {
|
||||
|
||||
@@ -4,21 +4,38 @@ 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 { leaveSettings, openSidebarNavSettings } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
expectFooterItemSetting,
|
||||
expectFooterSeparator,
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
expectNoPinnedUsage,
|
||||
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 };
|
||||
@@ -33,7 +50,7 @@ async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { k
|
||||
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, `phase4-${name}.png`);
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (area.kind === "element") {
|
||||
await area.locator.screenshot({ path: file });
|
||||
return;
|
||||
@@ -42,17 +59,65 @@ async function qaScreenshot(page: Page, name: string, area: ScreenshotArea = { k
|
||||
await page.screenshot({ path: file, clip });
|
||||
}
|
||||
|
||||
/** The sidebar footer, from Add project down to the icon row, with some margin. */
|
||||
/** Includes summary rows above the fixed icon row. */
|
||||
async function footerClip(page: Page) {
|
||||
const top = (await page.locator('[data-testid="sidebar-add-project"]:visible').boundingBox())!;
|
||||
const bottom = (await page.locator('[data-testid="sidebar-settings"]:visible').boundingBox())!;
|
||||
const margin = 16;
|
||||
const x = Math.max(0, top.x - margin);
|
||||
const y = Math.max(0, top.y - margin);
|
||||
return { x, y, width: 300, height: bottom.y + bottom.height + margin - y };
|
||||
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,
|
||||
}) => {
|
||||
@@ -63,26 +128,50 @@ test.describe("Usage item", () => {
|
||||
await gotoAppShell(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
|
||||
await test.step("a fresh device shows the plain Usage row, which opens the Usage screen", async () => {
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoPinnedUsage(page);
|
||||
await qaScreenshot(page, "desktop-footer-empty", { kind: "footer" });
|
||||
await test.step("a fresh device shows default windows once the Usage item is on", async () => {
|
||||
// The Usage item starts off on every layout; turning it on stores the choice.
|
||||
await expectNoUsageItem(page);
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterItemSetting(page, "usage", false);
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await qaScreenshot(page, "desktop-settings-sidebar-footer");
|
||||
await page.setViewportSize(COMPACT);
|
||||
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: the selector still names it.
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-host-switcher"]:visible'),
|
||||
).toHaveAccessibleName(/^Usage host: .+/);
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectPinnedUsage(page, ["31%", "12%"]);
|
||||
// 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", exact: true })).toBeVisible();
|
||||
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(".."),
|
||||
@@ -95,7 +184,8 @@ test.describe("Usage item", () => {
|
||||
|
||||
await test.step("remaining flips the Usage item and the Usage screen", async () => {
|
||||
await showUsageAs(page, "remaining");
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
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();
|
||||
@@ -117,7 +207,7 @@ test.describe("Usage item", () => {
|
||||
await leaveUsageScreen(page);
|
||||
await openCompactSidebar(page);
|
||||
await expect(usageItem(page)).toBeInViewport();
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||
await qaScreenshot(page, "compact-footer");
|
||||
await usageItem(page).click();
|
||||
const sheet = usageSheet(page);
|
||||
@@ -129,14 +219,23 @@ test.describe("Usage item", () => {
|
||||
await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, \d+% left( · .+)?$/,
|
||||
);
|
||||
// The sheet carries the Usage screen's controls, host selector included.
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-host-switcher"]:visible'),
|
||||
).toHaveAccessibleName(/^Usage host: .+/);
|
||||
// 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())!;
|
||||
expect(sheetBox.y).toBeGreaterThan(COMPACT.height / 3);
|
||||
// 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");
|
||||
@@ -150,10 +249,14 @@ test.describe("Usage item", () => {
|
||||
|
||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||
await page.reload();
|
||||
await expectPinnedUsage(page, ["69% left", "88% left"]);
|
||||
await expect(
|
||||
page.locator('[data-testid="usage-display-remaining"]:visible').first(),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
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 () => {
|
||||
@@ -167,16 +270,18 @@ test.describe("Usage item", () => {
|
||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
await test.step("unpinning both brings back the Usage row, and that survives a reload", async () => {
|
||||
await test.step("unpinning everything stays empty after a reload", async () => {
|
||||
await togglePin(screen, "Claude", "Session");
|
||||
await togglePin(screen, "Codex", "Weekly");
|
||||
await expectNoPinnedUsage(page);
|
||||
await expectNoUsageItem(page);
|
||||
await page.reload();
|
||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||
await expectNoPinnedUsage(page);
|
||||
await gotoAppShell(page);
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
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 () => {
|
||||
@@ -190,3 +295,110 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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-");
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
- launchApp
|
||||
- runFlow: ../../../maestro/sidebar-scroll-retention.yaml
|
||||
@@ -90,3 +90,52 @@ a failed provider tap leaves its modal open, so dismiss it before the next run.
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
Keep generated screenshots, recordings, and run logs outside the repository.
|
||||
|
||||
## Stacked adaptive sheets
|
||||
|
||||
`stacked-pairing.ad` opens the welcome screen's pairing sheet and then the root-mounted host
|
||||
confirmation. It first types without tapping the input to verify autofocus, then cancels the
|
||||
confirmation, closes and reopens the underlying sheet, confirms again,
|
||||
and completes the password retry. `stacked-settings.ad` checks the same return and retry through
|
||||
Settings → add host → Direct connection → Advanced. Run both on phones and tablets so both the
|
||||
bottom sheet and centered dialog are exercised.
|
||||
|
||||
These integration journeys require the host-confirmation flow in the test app. Include both the
|
||||
sheet fix and that flow in the QA checkout when verifying them; the production sheet component
|
||||
has no dependency on the host-confirmation feature.
|
||||
|
||||
Use a fresh English app with notification permission granted and first-use keyboard prompts
|
||||
already dismissed. Load this checkout from an isolated Metro. Start an isolated password-protected
|
||||
daemon and a local Wrangler relay with `--var 'PASEO_RELAY_UPSTREAM:'`; the checked-in upstream
|
||||
setting otherwise forwards traffic to the hosted relay, which drops password rejection replies.
|
||||
Set `PAIRING_LINK` to the daemon's `relay://` URI with an intentionally incorrect `password` query
|
||||
parameter, and `PASSWORD` to its correct password. This exercises password retry even while the
|
||||
daemon accepts relay connections with no credential.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/modal-sheet/stacked-pairing.ad \
|
||||
--state-dir "$SHEET_QA_DEVICE_STATE" --session stacked-ipad --platform ios \
|
||||
--udid "$SHEET_QA_IOS_UDID" \
|
||||
--env PAIRING_LINK="$SHEET_QA_LINK" --env PASSWORD="$SHEET_QA_PASSWORD" \
|
||||
--env OUT="$SHEET_QA_OUTPUT"
|
||||
```
|
||||
|
||||
For Settings, first open Settings with Add host visible. With a saved host, open Switch host
|
||||
first. Use a second loopback spelling
|
||||
(`localhost` instead of `127.0.0.1`) for the same local relay so the saved host asks for confirmation
|
||||
of its changed connection. Pass that link to `stacked-settings.ad` with the same arguments.
|
||||
For an app with no saved hosts, use the original link; Add host is visible directly in Settings.
|
||||
|
||||
Android uses the same journeys: copy each script outside the repository, change its context's
|
||||
`platform=ios` to `platform=android`, and pass `--serial` instead of `--udid`. Reverse both Metro
|
||||
(including the port advertised by its manifest) and the local relay ports. Keep driver state,
|
||||
artifacts, and sessions separate from other runs. These host-dependent journeys remain outside the
|
||||
default mobile suite, alongside the existing modal-sheet scripts.
|
||||
|
||||
On iPhone, bulk replacement or bulk keyboard entry of the advanced URI field triggers a
|
||||
repeated-update error with both the parent and stacked-sheet implementations. Entering the complete
|
||||
URI one character at a time passed with both implementations; faster typing remains unverified.
|
||||
On iPad, replacing the field a second time appends text. For this Settings journey, use the
|
||||
native clipboard Paste menu, then continue the assertions manually. Dismiss the password keyboard
|
||||
with Done to submit when the Connect button is below the keyboard. The welcome pairing regression
|
||||
runs end to end without these adjustments.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Fresh English app at Welcome, local relay host with PASSWORD, see README.md.
|
||||
# Run on phones and tablets: the confirmation lives at the root, outside the pairing sheet.
|
||||
context platform=ios timeout=180000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"welcome-paste-pairing-link\"" 20000
|
||||
screenshot "${OUT}/01-welcome.png"
|
||||
press "id=\"welcome-paste-pairing-link\""
|
||||
wait stable
|
||||
# Type without tapping the input: opening the sheet must retain its autofocus.
|
||||
wait "id=\"pair-link-input\"" 10000
|
||||
type "relay://"
|
||||
wait "id=\"pair-link-input\" value=\"relay://\"" 5000
|
||||
screenshot "${OUT}/20-pairing-autofocus-without-tap.png"
|
||||
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
screenshot "${OUT}/02-pairing-input.png"
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/03-confirmation-on-top.png"
|
||||
press "id=\"host-confirmation-cancel\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-submit\" enabled=true" 10000
|
||||
screenshot "${OUT}/04-cancel-returns-to-pairing.png"
|
||||
# Close and reopen the underlying sheet, then edit it and submit again.
|
||||
press "id=\"pair-link-cancel\""
|
||||
wait stable
|
||||
wait "id=\"welcome-paste-pairing-link\"" 10000
|
||||
screenshot "${OUT}/05-underlying-sheet-closes.png"
|
||||
press "id=\"welcome-paste-pairing-link\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-input\"" 10000
|
||||
fill "id=\"pair-link-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/06-confirmation-reopens.png"
|
||||
press "id=\"host-confirmation-connect\""
|
||||
wait stable
|
||||
wait "id=\"pair-link-password-input\"" 20000
|
||||
screenshot "${OUT}/07-password-after-connect.png"
|
||||
fill "id=\"pair-link-password-input\"" "${PASSWORD}"
|
||||
wait stable
|
||||
press "id=\"pair-link-submit\""
|
||||
wait stable
|
||||
wait text "Open a folder on your machine" 30000
|
||||
screenshot "${OUT}/08-connected.png"
|
||||
@@ -0,0 +1,46 @@
|
||||
# Settings with Add host visible: no saved hosts, or the host picker already open.
|
||||
context platform=ios timeout=180000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
open "${APP_ID}"
|
||||
wait "id=\"settings-add-host\"" 20000
|
||||
screenshot "${OUT}/09-settings.png"
|
||||
press "id=\"settings-add-host\""
|
||||
wait stable
|
||||
wait "id=\"add-host-method-direct\"" 10000
|
||||
screenshot "${OUT}/10-add-host-methods.png"
|
||||
press "id=\"add-host-method-direct\""
|
||||
wait stable
|
||||
press "id=\"direct-host-advanced-toggle\""
|
||||
wait stable
|
||||
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
screenshot "${OUT}/11-add-host-relay-input.png"
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/12-add-host-confirmation-on-top.png"
|
||||
press "id=\"host-confirmation-cancel\""
|
||||
wait stable
|
||||
wait "id=\"direct-host-submit\" enabled=true" 10000
|
||||
screenshot "${OUT}/13-cancel-returns-to-add-host.png"
|
||||
# Editing the underlying sheet proves it still accepts input after closing the top sheet.
|
||||
fill "id=\"direct-host-uri-input\"" "${PAIRING_LINK}"
|
||||
wait stable
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
wait "id=\"host-confirmation-connect\"" 10000
|
||||
screenshot "${OUT}/14-add-host-confirmation-reopens.png"
|
||||
press "id=\"host-confirmation-connect\""
|
||||
wait stable
|
||||
wait "id=\"direct-password-input\"" 20000
|
||||
screenshot "${OUT}/15-add-host-password.png"
|
||||
fill "id=\"direct-password-input\"" "${PASSWORD}"
|
||||
wait stable
|
||||
press "id=\"direct-host-submit\""
|
||||
wait stable
|
||||
open "${APP_ID}" "paseo://settings"
|
||||
wait "label=\"Connections\"" 10000
|
||||
press "label=\"Connections\""
|
||||
wait stable
|
||||
wait "id=\"host-page-connections-card\"" 30000
|
||||
screenshot "${OUT}/16-settings-connected.png"
|
||||
@@ -47,7 +47,7 @@ async function openAddProjectFlowSurface(
|
||||
page: Page,
|
||||
expectedPage: "host" | "method",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
await page.locator('[data-testid="sidebar-add-project"]:visible').click();
|
||||
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectAddProjectPage(page, expectedPage);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ type AgentSkillsClient = Pick<
|
||||
"close" | "connect" | "getAgentSkillsStatus" | "saveAgentSkillsSelection"
|
||||
>;
|
||||
|
||||
export type SkillTargetName = "agents" | "claude" | "codex";
|
||||
export type SkillTargetName = "agents" | "claude";
|
||||
|
||||
export interface AgentSkillsSandbox {
|
||||
available: string[];
|
||||
@@ -30,6 +30,7 @@ export interface AgentSkillsSandbox {
|
||||
daemon: IsolatedHostDaemon;
|
||||
home: string;
|
||||
targets: Record<SkillTargetName, string>;
|
||||
codexSkillsDir: string;
|
||||
blockAgentsDirectory(): Promise<void>;
|
||||
unblockAgentsDirectory(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -63,8 +64,8 @@ export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
|
||||
const targets = {
|
||||
agents: path.join(home, ".agents", "skills"),
|
||||
claude: path.join(home, ".claude", "skills"),
|
||||
codex: path.join(home, ".codex", "skills"),
|
||||
};
|
||||
const codexSkillsDir = path.join(home, ".codex", "skills");
|
||||
|
||||
return {
|
||||
available,
|
||||
@@ -72,6 +73,7 @@ export async function startAgentSkillsSandbox(): Promise<AgentSkillsSandbox> {
|
||||
daemon,
|
||||
home,
|
||||
targets,
|
||||
codexSkillsDir,
|
||||
blockAgentsDirectory: async () => {
|
||||
await mkdir(path.dirname(targets.agents), { recursive: true });
|
||||
await writeFile(targets.agents, "not a directory", "utf8");
|
||||
@@ -196,6 +198,7 @@ export async function expectInstalledSkills(
|
||||
for (const directory of Object.values(sandbox.targets)) {
|
||||
await expect.poll(() => readInstalledSkills(directory)).toEqual([...expected].sort());
|
||||
}
|
||||
await expect.poll(() => readInstalledSkills(sandbox.codexSkillsDir)).toEqual([]);
|
||||
}
|
||||
|
||||
export async function expectSavedSelection(
|
||||
|
||||
@@ -52,6 +52,10 @@ export async function expectComposerFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeFocused();
|
||||
}
|
||||
|
||||
export async function expectComposerNotFocused(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).not.toBeFocused();
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
@@ -59,6 +63,12 @@ export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
/** The Send button works on compact screens, where Enter inserts a newline. */
|
||||
export async function submitMessageWithButton(page: Page, text: string): Promise<void> {
|
||||
await fillComposerDraft(page, text);
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expectComposerVisible } from "./composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
|
||||
import { installUsageReportsFixture, type UsageListResponse } from "./usage-reports";
|
||||
import { claudeAndCodexReports } from "./usage-sidebar-item";
|
||||
|
||||
// 32,000 of the mock's 128,000-token window.
|
||||
const METER_NAME = "Context window 25% used";
|
||||
|
||||
export type MockAgentSession = Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
|
||||
|
||||
/** An agent a quarter into its context window. */
|
||||
export function seedAgentWithContextWindow(): Promise<MockAgentSession> {
|
||||
return seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-usage-",
|
||||
title: "Context window usage e2e",
|
||||
initialPrompt: "emit 32000 byte file agent stream payload",
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAgent(page: Page, session: MockAgentSession): Promise<void> {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
export async function reloadAgent(page: Page): Promise<void> {
|
||||
// Reload must not reopen the card under a stationary pointer.
|
||||
await page.mouse.move(0, 0);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
/** Wide screens show the meter's details in a hover card while the pointer is on it. */
|
||||
export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 });
|
||||
const card = contextWindowDetails(page);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toBeInViewport();
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
|
||||
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
|
||||
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
|
||||
const box = await card.boundingBox();
|
||||
if (!meter || !box) throw new Error("The meter and its hover card must be on screen.");
|
||||
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
|
||||
}
|
||||
|
||||
export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Compact screens open the meter's details in a sheet. */
|
||||
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowDetails(page);
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||
const sheet = contextWindowDetails(page);
|
||||
await sheet.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The hover card on wide screens and the sheet on compact ones are both this dialog. */
|
||||
export function contextWindowDetails(page: Page): Locator {
|
||||
return page.getByRole("dialog", { name: "Context window" });
|
||||
}
|
||||
|
||||
/** One report card; cards have no role of their own. */
|
||||
export function usageCard(surface: Locator, reportId: string): Locator {
|
||||
return surface.getByTestId(`usage-report-${reportId}`);
|
||||
}
|
||||
|
||||
export async function refreshUsageCard(surface: Locator, source: string): Promise<void> {
|
||||
await surface.getByRole("button", { name: `Refresh ${source}`, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectRefreshButtons(surface: Locator, count: number): Promise<void> {
|
||||
await expect(surface.getByRole("button", { name: /^Refresh / })).toHaveCount(count);
|
||||
}
|
||||
|
||||
/** A report on the agent's own login, which is not the host's default one. */
|
||||
export function onWorkLogin(report: UsageReportEntry): UsageReportEntry {
|
||||
return onLogin(report, "work");
|
||||
}
|
||||
|
||||
/** A report on the login an agent resumed under. */
|
||||
export function onPersonalLogin(report: UsageReportEntry): UsageReportEntry {
|
||||
return onLogin(report, "personal");
|
||||
}
|
||||
|
||||
function onLogin(report: UsageReportEntry, login: string): UsageReportEntry {
|
||||
return {
|
||||
...report,
|
||||
id: `${report.sourceId}:${login}`,
|
||||
account: { label: `${login}@example.com` },
|
||||
};
|
||||
}
|
||||
|
||||
export function expiredLogin(report: UsageReportEntry): UsageReportEntry {
|
||||
return {
|
||||
...report,
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: {
|
||||
kind: "expired",
|
||||
expiresAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(),
|
||||
refreshedBy: "claude",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Holds a stream at a point until the test opens it, like a slow source. */
|
||||
export function gate(): { promise: Promise<void>; open: () => void } {
|
||||
let open!: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
export interface AgentUsageScript {
|
||||
/** The answer to the next agent usage request or report Refresh. */
|
||||
answerNext(response: UsageListResponse): void;
|
||||
/** From the next reload the host no longer reports usage. */
|
||||
stopReportingUsage(): void;
|
||||
/** The agent of each agent usage request, in order. */
|
||||
agentRequests(): string[];
|
||||
/** Each report Refresh, by report ID. */
|
||||
refreshedReports(): string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scripts the host's usage: the host's own list is Claude and Codex on its default login; each
|
||||
* agent request and each Refresh takes the next scripted answer.
|
||||
*/
|
||||
export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
let supported = true;
|
||||
const answers: UsageListResponse[] = [];
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
usageSupported: () => supported,
|
||||
lists: [
|
||||
(request) => {
|
||||
if (!request.agentId && !request.reportIds) return claudeAndCodexReports();
|
||||
const next = answers.shift();
|
||||
if (!next) throw new Error("The test scripts every agent usage request.");
|
||||
return next;
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
answerNext: (response) => answers.push(response),
|
||||
stopReportingUsage: () => {
|
||||
supported = false;
|
||||
},
|
||||
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
|
||||
refreshedReports: () =>
|
||||
usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
export async function qaScreenshot(page: Page, name: string): Promise<void> {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let the sheet and hover card animations settle.
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
@@ -35,18 +35,14 @@ export default function contribute(client) {
|
||||
|
||||
async function openExplorerMenu(page: Page) {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
// The rail's empty background has no accessible role; tab menus are separate.
|
||||
await explorer.getByTestId("explorer-sidebar-tab-rail").click({
|
||||
button: "right",
|
||||
position: { x: 20, y: 2 },
|
||||
});
|
||||
const menu = page.getByTestId("explorer-sidebar-tab-configuration");
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu).toHaveCSS("opacity", "1");
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function toggleExplorerView(page: Page, name: string) {
|
||||
async function launchExplorerView(page: Page, name: string | RegExp) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name, exact: true }).click();
|
||||
await expect(menu).not.toBeVisible();
|
||||
@@ -84,13 +80,12 @@ export async function withExplorerPanelPlugins(
|
||||
|
||||
export async function expectWorkspacePanelsInExplorerLauncher(page: Page) {
|
||||
const menu = await openExplorerMenu(page);
|
||||
await menu.getByRole("menuitem", { name: "New tab", exact: true }).click();
|
||||
const launcher = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount(
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review main only", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function openWorkspacePanelFromExplorerMenu(
|
||||
@@ -103,67 +98,51 @@ export async function openWorkspacePanelFromExplorerMenu(
|
||||
body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"New tab",
|
||||
"Changes",
|
||||
"Files",
|
||||
"Other review",
|
||||
"Other review summary",
|
||||
"Review",
|
||||
"Review summary",
|
||||
]);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole("menuitem", { name: "Other review summary", exact: true }),
|
||||
).toBeVisible();
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectIndependentExplorerPanelToggles(
|
||||
export async function expectIndependentExplorerPanelTabs(
|
||||
page: Page,
|
||||
workspace: ExplorerPanelWorkspace,
|
||||
) {
|
||||
await toggleExplorerView(page, "Other review");
|
||||
await launchExplorerView(page, "Other review");
|
||||
await expect(
|
||||
page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await toggleExplorerView(page, "Review summary");
|
||||
await launchExplorerView(page, "Review summary");
|
||||
const tabs = page.getByTestId("workspace-explorer-sidebar").getByTestId("workspace-tabs-row");
|
||||
await expect(tabs.getByRole("button", { name: "Review", exact: true })).toBeVisible();
|
||||
await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible();
|
||||
await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible();
|
||||
await tabs
|
||||
.getByRole("button", { name: "Review", exact: true })
|
||||
.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect(tabs.getByRole("button", { name: "Review", exact: true })).toHaveCount(0);
|
||||
await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible();
|
||||
await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible();
|
||||
const files = tabs.getByRole("button", { name: "Browse workspace files", exact: true });
|
||||
await expect(files).toHaveCount(1);
|
||||
const menu = await openExplorerMenu(page);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
|
||||
const updated = await openExplorerMenu(page);
|
||||
await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Other review", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await expect(
|
||||
updated.getByRole("menuitem", { name: "Review summary", exact: true }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await files.click({ button: "right", position: { x: 12, y: 13 } });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect(files).toHaveCount(0);
|
||||
await launchExplorerView(page, /^Files/);
|
||||
await expect(files).toHaveCount(1);
|
||||
const restoredMenu = await openExplorerMenu(page);
|
||||
await expect(restoredMenu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await toggleExplorerView(page, "Files");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("explorer-sidebar-tab-rail")
|
||||
.getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectExplorerPanelWithFocusedAgent(
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
|
||||
export interface LinkedHost {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
daemonPublicKeyB64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pairing link that opens on the settings page. `/` redirects to
|
||||
* `/open-project` on its own, so starting in settings leaves the pairing link
|
||||
* as the only thing that can move the app to `/open-project`.
|
||||
*/
|
||||
export function buildPairingLink(host: LinkedHost): string {
|
||||
const offer = {
|
||||
v: 2,
|
||||
serverId: host.serverId,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
relay: { endpoint: host.relayEndpoint, useTls: false },
|
||||
};
|
||||
const encoded = Buffer.from(JSON.stringify(offer), "utf8").toString("base64url");
|
||||
return `/settings/general#offer=${encoded}`;
|
||||
}
|
||||
|
||||
/** Loads the app from a pairing link, the way opening the link in a browser does. */
|
||||
export async function openPairingLink(page: Page, link: string): Promise<void> {
|
||||
// Leave the link's page first so the link always loads the app from scratch.
|
||||
await page.goto("/");
|
||||
await page.goto(link);
|
||||
}
|
||||
|
||||
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
|
||||
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
|
||||
await page.goto("/");
|
||||
const nowIso = new Date().toISOString();
|
||||
const connectionId = `relay:${host.relayEndpoint}`;
|
||||
const savedHost = {
|
||||
serverId: host.serverId,
|
||||
label: host.serverId,
|
||||
connections: [
|
||||
{
|
||||
id: connectionId,
|
||||
type: "relay",
|
||||
relayEndpoint: host.relayEndpoint,
|
||||
useTls: false,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: connectionId,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
|
||||
key: EXTRA_HOSTS_KEY,
|
||||
hosts: [savedHost],
|
||||
});
|
||||
}
|
||||
|
||||
export function hostConfirmation(page: Page): Locator {
|
||||
return page.getByTestId("host-confirmation");
|
||||
}
|
||||
|
||||
export async function expectHostConfirmationFor(page: Page, host: LinkedHost): Promise<void> {
|
||||
const sheet = hostConfirmation(page);
|
||||
await expect(sheet.getByText("Connect to this host?", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sheet.getByTestId("host-confirmation-server-id")).toHaveText(host.serverId);
|
||||
await expect(sheet.getByTestId("host-confirmation-relay")).toHaveText(host.relayEndpoint);
|
||||
}
|
||||
|
||||
export async function cancelHostConfirmation(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function connectToConfirmedHost(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Checks the host picker on the settings page the app is already showing. */
|
||||
export async function expectHostInHostPicker(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
expected: "listed" | "not listed",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("settings-host-picker").click();
|
||||
await expect(page.getByTestId(`settings-host-picker-item-${serverId}`)).toHaveCount(
|
||||
expected === "listed" ? 1 : 0,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { openGlobalNewWorkspaceComposer } from "./new-workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar";
|
||||
|
||||
@@ -18,16 +19,24 @@ export class ImportSessionFlow {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
async revealMobileEntryPoint() {
|
||||
await openMobileAgentSidebar(this.page);
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
const button = this.page.getByTestId("sidebar-import-session");
|
||||
/** The new workspace screen's Import session button, reached from the sidebar. */
|
||||
async revealNewWorkspaceEntryPoint() {
|
||||
const compact = (this.page.viewportSize()?.width ?? 0) < 768;
|
||||
if (compact) {
|
||||
await openMobileAgentSidebar(this.page);
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
}
|
||||
await openGlobalNewWorkspaceComposer(this.page);
|
||||
const button = this.page.getByTestId("new-workspace-import-session");
|
||||
await expect(button).toHaveAccessibleName("Import session");
|
||||
await expect(button).toBeInViewport();
|
||||
}
|
||||
async openGlobally() {
|
||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
||||
await this.page.getByTestId("sidebar-import-session").click();
|
||||
await this.revealNewWorkspaceEntryPoint();
|
||||
await this.openFromNewWorkspace();
|
||||
}
|
||||
async openFromNewWorkspace() {
|
||||
await this.page.getByTestId("new-workspace-import-session").click();
|
||||
await this.expectSheetReady();
|
||||
}
|
||||
async openFromWorkspaceHeader() {
|
||||
@@ -108,8 +117,12 @@ export class ImportSessionFlow {
|
||||
});
|
||||
}
|
||||
async expectTranscript(userText: string, assistantText: string) {
|
||||
await expect(this.page.getByTestId("user-message")).toContainText(userText);
|
||||
await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText);
|
||||
await expect(this.page.getByTestId("user-message").filter({ visible: true })).toContainText(
|
||||
userText,
|
||||
);
|
||||
await expect(
|
||||
this.page.getByTestId("assistant-message").filter({ visible: true }),
|
||||
).toContainText(assistantText);
|
||||
}
|
||||
async showAll() {
|
||||
await this.page.getByTestId("import-session-show-all").click();
|
||||
@@ -120,7 +133,9 @@ export class ImportSessionFlow {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`);
|
||||
await expect(workspace.getByTestId("user-message")).toContainText(userText);
|
||||
await expect(workspace.getByTestId("user-message").filter({ visible: true })).toContainText(
|
||||
userText,
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
await this.page.keyboard.press("Escape");
|
||||
|
||||
@@ -198,7 +198,8 @@ export async function openNewWorkspaceComposer(
|
||||
}
|
||||
|
||||
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").click();
|
||||
// The shell keeps a hidden copy of the other layout's sidebar mounted.
|
||||
await page.locator('[data-testid="sidebar-global-new-workspace"]:visible').first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
|
||||
@@ -162,7 +163,6 @@ async function expectVerticalOrder<Key extends string>(
|
||||
|
||||
/** Persisted footer row key -> the testID the app shell renders that row with. */
|
||||
function shellFooterTestID(key: string): string {
|
||||
// With nothing pinned the Usage item is the plain Usage row.
|
||||
if (key === "usage") return "sidebar-usage";
|
||||
const [, pluginId, itemId] = key.split(":");
|
||||
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
|
||||
@@ -197,18 +197,29 @@ export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
|
||||
.click();
|
||||
}
|
||||
|
||||
function footerItemSwitch(page: Page, key: string): Locator {
|
||||
return page.getByTestId("sidebar-nav-section-footer").getByTestId(`sidebar-nav-toggle-${key}`);
|
||||
}
|
||||
|
||||
export async function setFooterItemVisible(
|
||||
page: Page,
|
||||
key: string,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-toggle-${key}`);
|
||||
const toggle = footerItemSwitch(page, key);
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
/** Whether Settings > Sidebar shows a footer item as on. */
|
||||
export async function expectFooterItemSetting(
|
||||
page: Page,
|
||||
key: string,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
await expect(footerItemSwitch(page, key)).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
export async function expectFooterOrder(page: Page, keys: string[]): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows");
|
||||
}
|
||||
@@ -218,24 +229,69 @@ export async function expectFooterItemHidden(page: Page, key: string): Promise<v
|
||||
}
|
||||
|
||||
const FOOTER_ICON_TEST_IDS = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-import-session",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
/** The fixed icon row: Hosts, Import session, Help and support, Settings, left to right. */
|
||||
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
|
||||
const FOOTER_GEOMETRY_TOLERANCE = 0.01;
|
||||
|
||||
/**
|
||||
* One line of same-size icons: Add project, Usage and Hosts together on the left, Help and
|
||||
* Settings together at the end.
|
||||
*/
|
||||
export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||
const boxes = await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
);
|
||||
const [first, ...rest] = boxes.map((box) => box!);
|
||||
let previous = first;
|
||||
for (const box of rest) {
|
||||
expect(Math.abs(box.y - first.y)).toBeLessThan(2);
|
||||
expect(box.x).toBeGreaterThan(previous.x);
|
||||
previous = box;
|
||||
const boxes = (
|
||||
await Promise.all(
|
||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
||||
),
|
||||
)
|
||||
).map((box) => box!);
|
||||
const [first] = boxes;
|
||||
for (const box of boxes) {
|
||||
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
|
||||
expect(Math.abs(box.width - first!.width)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
|
||||
}
|
||||
const gaps = boxes.slice(1).map((box, index) => box.x - (boxes[index]!.x + boxes[index]!.width));
|
||||
for (const index of [0, 1, 3]) {
|
||||
expect(Math.abs(gaps[index]!)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
|
||||
}
|
||||
expect(gaps[2]).toBeGreaterThan(first!.width);
|
||||
}
|
||||
|
||||
export async function expectFooterSeparator(page: Page, shown: boolean): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-footer"]:visible')).toHaveCSS(
|
||||
"border-top-width",
|
||||
"1px",
|
||||
);
|
||||
await expect(page.locator('[data-testid="sidebar-footer-bottom-line"]:visible')).toHaveCSS(
|
||||
"border-top-width",
|
||||
"0px",
|
||||
);
|
||||
const separator = page.locator('[data-testid="sidebar-footer-separator"]:visible');
|
||||
await expect(separator).toHaveCount(shown ? 1 : 0);
|
||||
expect(
|
||||
await separator.evaluateAll((elements) =>
|
||||
elements.map((element) => getComputedStyle(element).borderBottomWidth),
|
||||
),
|
||||
).toEqual(shown ? ["1px"] : []);
|
||||
}
|
||||
|
||||
export async function hoverFooterAddProject(page: Page): Promise<void> {
|
||||
await page.locator('[data-testid="sidebar-add-project"]:visible').hover();
|
||||
const tooltip = page.getByTestId("sidebar-add-project-tooltip");
|
||||
await expect(tooltip.getByText("Add project", { exact: true })).toBeVisible();
|
||||
await expect(tooltip.getByText("Ctrl+O", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function footerScreenshot(page: Page, name: string): Promise<void> {
|
||||
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`) });
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ class StartupAssertions {
|
||||
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(hostRow).toContainText(input.label);
|
||||
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||
return this;
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination";
|
||||
|
||||
export const scrollCadences = [
|
||||
{ name: "slow", delta: 160, intervalMs: 50, steps: 1000 },
|
||||
{ name: "medium", delta: 480, intervalMs: 32, steps: 400 },
|
||||
{ name: "fast", delta: 1200, intervalMs: 16, steps: 180 },
|
||||
] as const;
|
||||
|
||||
type Cadence = (typeof scrollCadences)[number];
|
||||
interface RowFrame {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
export interface ScrollFrame {
|
||||
at: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
viewportHeight: number;
|
||||
virtualized: boolean;
|
||||
loading: boolean;
|
||||
rows: RowFrame[];
|
||||
anchor: string | null;
|
||||
wheelTotal: number;
|
||||
lastWheelAt: number;
|
||||
inputFinishedAt: number | null;
|
||||
imageLoads: number;
|
||||
mounted: number;
|
||||
unmounted: number;
|
||||
}
|
||||
interface Recording {
|
||||
frames: ScrollFrame[];
|
||||
finishInput(): void;
|
||||
stop(): void;
|
||||
}
|
||||
interface TimelinePage {
|
||||
direction: string;
|
||||
count: number;
|
||||
hasOlder: boolean;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */
|
||||
export async function withVariedTimeline(
|
||||
run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
const server = createServer((request, response) => {
|
||||
const portrait = request.url?.includes("portrait");
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
timers.delete(timer);
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Cache-Control": "no-store",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
response.end(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${portrait ? 320 : 960}" height="${portrait ? 640 : 240}"><rect width="100%" height="100%" fill="${portrait ? "#936" : "#369"}"/><text x="20" y="50" fill="white" font-size="24">Delayed ${portrait ? "portrait" : "landscape"}</text></svg>`,
|
||||
);
|
||||
},
|
||||
portrait ? 900 : 350,
|
||||
);
|
||||
timers.add(timer);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Image server did not listen");
|
||||
const imageUrl = `http://127.0.0.1:${address.port}`;
|
||||
let agent: MockAgentWorkspace | undefined;
|
||||
try {
|
||||
agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-scroll-smoothness-",
|
||||
title: "Varied timeline scroll reproduction",
|
||||
featureValues: {
|
||||
mockAssistantResponses: Array.from({ length: 70 }, (_, turn) =>
|
||||
[
|
||||
`## Review of turn ${turn}`,
|
||||
"A short response followed by blocks with different heights. Read the comparison, inspect the screenshot, and continue to the next turn.",
|
||||
"| Component | Observation | Next step |\n| --- | --- | --- |\n| Pagination | Older turns arrive together | Keep the visible text anchored |\n| Images | Intrinsic sizes arrive later | Preserve the reading position |\n| Virtualization | Estimated heights become measured heights | Keep wheel input moving upward |",
|
||||
``,
|
||||
"```typescript\nconst viewport = { top: 0, height: 800 };\nfor (const row of history) {\n inspect(row, viewport);\n}\n```",
|
||||
"> Keep reading while content above the viewport changes.",
|
||||
"- Short item\n- A longer item that wraps when the available width is smaller and adds another height variation\n- Final item",
|
||||
``,
|
||||
"Review complete. The next prompt starts a separate turn.",
|
||||
].join("\n\n"),
|
||||
),
|
||||
},
|
||||
});
|
||||
for (let turn = 0; turn < 70; turn += 1) {
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
`Review turn ${turn}. ${"Compare the image and table with the earlier result. ".repeat(1 + (turn % 4))}`,
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await run(
|
||||
agent,
|
||||
`Review turn 69. ${"Compare the image and table with the earlier result. ".repeat(2)}`,
|
||||
);
|
||||
} finally {
|
||||
await agent?.cleanup();
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
export function observeTimelinePages(page: Page, agentId: string): TimelinePage[] {
|
||||
const pages: TimelinePage[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
const envelope = JSON.parse(String(payload));
|
||||
const message = envelope.message;
|
||||
if (message?.type !== "fetch_agent_timeline_response" || message.payload.agentId !== agentId)
|
||||
return;
|
||||
pages.push({
|
||||
direction: message.payload.direction,
|
||||
count: message.payload.entries.length,
|
||||
hasOlder: message.payload.hasOlder,
|
||||
at: Date.now(),
|
||||
});
|
||||
});
|
||||
});
|
||||
return pages;
|
||||
}
|
||||
|
||||
export async function openOnlyTimelineTail(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
newestPrompt: string,
|
||||
pages: TimelinePage[],
|
||||
): Promise<void> {
|
||||
await page.addInitScript(() => Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true));
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, newestPrompt.trim());
|
||||
await expect
|
||||
.poll(() => pages.filter((entry) => entry.direction === "tail").length)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
|
||||
expect(pages[0]).toMatchObject({ direction: "tail", hasOlder: true });
|
||||
expect(pages[0].count).toBe(40);
|
||||
expect(pages.filter((entry) => entry.direction === "tail")).toHaveLength(1);
|
||||
expect(
|
||||
pages.filter((entry) => entry.direction === "after").every((entry) => entry.count === 0),
|
||||
).toBe(true);
|
||||
await expect(page.getByText(/^Review turn 0\./)).toHaveCount(0);
|
||||
// Start each cadence after the tail's initial image loads and bottom anchoring.
|
||||
await page.waitForTimeout(1500);
|
||||
expect(pages.filter((entry) => entry.direction === "before")).toHaveLength(0);
|
||||
}
|
||||
|
||||
export async function recordUpwardTraversal(
|
||||
page: Page,
|
||||
cadence: Cadence,
|
||||
testInfo: TestInfo,
|
||||
): Promise<ScrollFrame[]> {
|
||||
const stopTrace = await traceScrollIfRequested(page, testInfo, cadence);
|
||||
const timeline = page.getByTestId("agent-chat-scroll");
|
||||
await timeline.hover();
|
||||
await timeline.evaluate((viewport) => {
|
||||
const scroll = viewport as HTMLElement;
|
||||
const frames: ScrollFrame[] = [];
|
||||
let frame = 0;
|
||||
let wheelTotal = 0;
|
||||
let lastWheelAt = -Infinity;
|
||||
let inputFinishedAt: number | null = null;
|
||||
let imageLoads = 0;
|
||||
let mounted = scroll.querySelectorAll("[data-history-row-id]").length;
|
||||
let unmounted = 0;
|
||||
const collectRows = (node: Node, rows: Set<Element>) => {
|
||||
if (!(node instanceof Element)) return;
|
||||
if (node.matches("[data-history-row-id]")) rows.add(node);
|
||||
for (const row of node.querySelectorAll("[data-history-row-id]")) rows.add(row);
|
||||
};
|
||||
const recordMutations = (records: MutationRecord[]) => {
|
||||
// A newly inserted container and its children can both appear in one
|
||||
// delivery. Count each DOM row once, including rows removed before paint.
|
||||
const added = new Set<Element>();
|
||||
const removed = new Set<Element>();
|
||||
for (const record of records) {
|
||||
for (const node of record.addedNodes) collectRows(node, added);
|
||||
for (const node of record.removedNodes) collectRows(node, removed);
|
||||
}
|
||||
mounted += added.size;
|
||||
unmounted += removed.size;
|
||||
};
|
||||
const mutations = new MutationObserver(recordMutations);
|
||||
mutations.observe(scroll, { childList: true, subtree: true });
|
||||
const wheel = (event: WheelEvent) => {
|
||||
wheelTotal += -event.deltaY;
|
||||
lastWheelAt = performance.now();
|
||||
};
|
||||
const imageLoad = (event: Event) => {
|
||||
if (event.target instanceof HTMLImageElement) imageLoads += 1;
|
||||
};
|
||||
scroll.addEventListener("wheel", wheel, { passive: true });
|
||||
scroll.addEventListener("load", imageLoad, true);
|
||||
const observed = new Set<Element>();
|
||||
const capture = (replaceLast: boolean) => {
|
||||
const rect = scroll.getBoundingClientRect();
|
||||
const elements = Array.from(scroll.querySelectorAll<HTMLElement>("[data-history-row-id]"));
|
||||
const current = new Set<Element>([scroll, ...elements]);
|
||||
if (scroll.firstElementChild) current.add(scroll.firstElementChild);
|
||||
for (const element of observed) {
|
||||
if (!current.has(element)) {
|
||||
resizeObserver.unobserve(element);
|
||||
observed.delete(element);
|
||||
}
|
||||
}
|
||||
for (const element of current) {
|
||||
if (!observed.has(element)) {
|
||||
resizeObserver.observe(element);
|
||||
observed.add(element);
|
||||
}
|
||||
}
|
||||
const rows = elements.map((row) => {
|
||||
const box = row.getBoundingClientRect();
|
||||
return { id: row.dataset.historyRowId!, top: box.top - rect.top, height: box.height };
|
||||
});
|
||||
// Include rows mounted and removed in the same commit, before sampling.
|
||||
recordMutations(mutations.takeRecords());
|
||||
// Preserve the row crossing the reading line. Rows below an expanding
|
||||
// visible image legitimately move even when the reading position is stable.
|
||||
const anchor = rows.find((row) => row.top < rect.height && row.top + row.height > 8);
|
||||
const snapshot: ScrollFrame = {
|
||||
at: performance.now(),
|
||||
scrollTop: scroll.scrollTop,
|
||||
scrollHeight: scroll.scrollHeight,
|
||||
viewportHeight: rect.height,
|
||||
rows,
|
||||
anchor: anchor?.id ?? null,
|
||||
virtualized: scroll.id.includes("virtualized"),
|
||||
loading: !!scroll.querySelector('[data-testid="load-older-history-spinner"]'),
|
||||
wheelTotal,
|
||||
lastWheelAt,
|
||||
inputFinishedAt,
|
||||
imageLoads,
|
||||
mounted,
|
||||
unmounted,
|
||||
};
|
||||
if (replaceLast && frames.length > 0) frames[frames.length - 1] = snapshot;
|
||||
else frames.push(snapshot);
|
||||
};
|
||||
// rAF precedes ResizeObserver. Refresh that frame after resize delivery so
|
||||
// it represents the final layout, not geometry repaired before the paint.
|
||||
const resizeObserver = new ResizeObserver(() => capture(true));
|
||||
const sample = () => {
|
||||
capture(false);
|
||||
frame = requestAnimationFrame(sample);
|
||||
};
|
||||
const recording: Recording = {
|
||||
frames,
|
||||
finishInput() {
|
||||
inputFinishedAt = performance.now();
|
||||
},
|
||||
stop() {
|
||||
cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
mutations.disconnect();
|
||||
scroll.removeEventListener("wheel", wheel);
|
||||
scroll.removeEventListener("load", imageLoad, true);
|
||||
},
|
||||
};
|
||||
Reflect.set(window, "__timelineScrollRecording", recording);
|
||||
const reset = Reflect.get(window, "__PASEO_RESET_RENDER_PROFILE__");
|
||||
if (typeof reset === "function") reset();
|
||||
sample();
|
||||
});
|
||||
// No settling between inputs: exercise wheel input overlapping prepend and measurement.
|
||||
for (let step = 0; step < cadence.steps; step += 1) {
|
||||
await page.mouse.wheel(0, -cadence.delta);
|
||||
await page.waitForTimeout(cadence.intervalMs);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
(Reflect.get(window, "__timelineScrollRecording") as Recording).finishInput();
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
await stopTrace();
|
||||
return page.evaluate(() => {
|
||||
const recording = Reflect.get(window, "__timelineScrollRecording") as Recording;
|
||||
recording.stop();
|
||||
return recording.frames;
|
||||
});
|
||||
}
|
||||
|
||||
export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
return frames.flatMap((current, index) => {
|
||||
const previous = frames[index - 1];
|
||||
if (!previous?.anchor) return [];
|
||||
// Wheel input can move the reading line onto an image before it expands.
|
||||
// Follow that intended row, not text now below the image.
|
||||
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
|
||||
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
|
||||
const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop);
|
||||
const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop);
|
||||
const readingLine = 8 - availableScroll;
|
||||
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine);
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
: previous.rows
|
||||
.filter((row) => currentRows.has(row.id))
|
||||
.sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0];
|
||||
if (!before) {
|
||||
throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`);
|
||||
}
|
||||
const after = currentRows.get(before.id)!;
|
||||
const movement = after.top - before.top;
|
||||
// A busy main thread can deliver wheel movement hundreds of milliseconds
|
||||
// after its event. Only assert idle stability after the driver stops input.
|
||||
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
|
||||
// Wheel events can precede their scroll update, or include input already
|
||||
// applied in earlier frames. Locate the reader with this frame's scroll,
|
||||
// bounded by recent input. Growth below it can move the old anchor without
|
||||
// moving the reading line.
|
||||
const readerFollowsInput =
|
||||
intendedRow &&
|
||||
currentRows.has(intendedRow.id) &&
|
||||
Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32;
|
||||
const enteredRowGrowth = readerFollowsInput
|
||||
? previous.rows.reduce((growth, row) => {
|
||||
if (row.top < intendedRow.top || row.top >= before.top) return growth;
|
||||
const height = currentRows.get(row.id)?.height ?? row.height;
|
||||
return growth + Math.max(0, height - row.height);
|
||||
}, 0)
|
||||
: 0;
|
||||
const excessForward = movement > wheelBudget + enteredRowGrowth + 32;
|
||||
// Upward wheel input moves the same text DOWN the viewport. A negative move
|
||||
// is a reversal, independent of legitimate scrollTop compensation on prepend.
|
||||
if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
at: current.at,
|
||||
row: before.id,
|
||||
movement,
|
||||
idle,
|
||||
excessForward,
|
||||
resizedRows: current.rows.flatMap((row) => {
|
||||
const old = previous.rows.find((candidate) => candidate.id === row.id);
|
||||
return old && old.height !== row.height
|
||||
? [{ id: row.id, before: old.height, after: row.height }]
|
||||
: [];
|
||||
}),
|
||||
loading: current.loading || previous.loading,
|
||||
virtualized: current.virtualized,
|
||||
heightChange: current.scrollHeight - previous.scrollHeight,
|
||||
imageLoads: current.imageLoads - previous.imageLoads,
|
||||
frameGapMs: current.at - previous.at,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export async function reportScrollJumps(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
frames: ScrollFrame[],
|
||||
pages: TimelinePage[],
|
||||
): Promise<void> {
|
||||
const jumps = findScrollJumps(frames);
|
||||
const react = await page.evaluate(() => Reflect.get(window, "__PASEO_RENDER_PROFILE__") ?? []);
|
||||
const gaps = frames
|
||||
.slice(1)
|
||||
.map((frame, index) => frame.at - frames[index].at)
|
||||
.sort((a, b) => a - b);
|
||||
const summary = {
|
||||
browserTimeOrigin: await page.evaluate(() => performance.timeOrigin),
|
||||
frames: frames.length,
|
||||
pages,
|
||||
jumpCount: jumps.length,
|
||||
worstReversePx: Math.max(0, ...jumps.map((jump) => -jump.movement)),
|
||||
frameGapP95Ms: gaps[Math.floor(gaps.length * 0.95)],
|
||||
frameGapMaxMs: gaps.at(-1),
|
||||
imageLoads: frames[frames.length - 1].imageLoads,
|
||||
mounted: frames[frames.length - 1].mounted,
|
||||
unmounted: frames[frames.length - 1].unmounted,
|
||||
jumps,
|
||||
};
|
||||
await writeFile(testInfo.outputPath("scroll-summary.json"), JSON.stringify(summary, null, 2));
|
||||
await writeFile(testInfo.outputPath("scroll-frames.json"), JSON.stringify(frames));
|
||||
await writeFile(testInfo.outputPath("scroll-react-profile.json"), JSON.stringify(react));
|
||||
await testInfo.attach("scroll-summary", {
|
||||
body: JSON.stringify(summary, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await testInfo.attach("scroll-frames", {
|
||||
body: JSON.stringify(frames),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await testInfo.attach("scroll-react-profile", {
|
||||
body: JSON.stringify(react),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await attachTimelineScreenshot(page, testInfo, "scroll-end");
|
||||
console.log(
|
||||
`[scroll] ${testInfo.title}: ${JSON.stringify({ ...summary, jumps: jumps.slice(0, 5) })}`,
|
||||
);
|
||||
expect(
|
||||
pages.filter((entry) => entry.direction === "before").length,
|
||||
"must exercise multiple older pages",
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
expect(
|
||||
frames.some((frame) => frame.virtualized),
|
||||
"must exercise virtualization",
|
||||
).toBe(true);
|
||||
expect(summary.imageLoads, "must exercise image completion during traversal").toBeGreaterThan(0);
|
||||
expect(
|
||||
summary.mounted - summary.unmounted,
|
||||
"row counters must balance against the final DOM",
|
||||
).toBe(frames[frames.length - 1].rows.length);
|
||||
const uniqueRows = new Set(frames.flatMap((frame) => frame.rows.map((row) => row.id))).size;
|
||||
expect(
|
||||
summary.mounted,
|
||||
"prepending must not mount a discarded range before correcting the viewport",
|
||||
).toBeLessThanOrEqual(uniqueRows * 1.25);
|
||||
expect(
|
||||
jumps,
|
||||
"visible text must not reverse direction while scrolling upward or move after input stops",
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
async function traceScrollIfRequested(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
cadence: Cadence,
|
||||
): Promise<() => Promise<void>> {
|
||||
if (process.env.PASEO_TIMELINE_SCROLL_TRACE !== cadence.name) return async () => {};
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Tracing.start", {
|
||||
categories:
|
||||
"devtools.timeline,v8.execute,blink.user_timing,disabled-by-default-v8.cpu_profiler",
|
||||
transferMode: "ReturnAsStream",
|
||||
});
|
||||
return async () => {
|
||||
const completed = new Promise<string>((resolve) => {
|
||||
cdp.once("Tracing.tracingComplete", ({ stream }) => resolve(stream!));
|
||||
});
|
||||
await cdp.send("Tracing.end");
|
||||
const stream = await completed;
|
||||
const chunks: string[] = [];
|
||||
for (;;) {
|
||||
const chunk = await cdp.send("IO.read", { handle: stream });
|
||||
chunks.push(chunk.base64Encoded ? Buffer.from(chunk.data, "base64").toString() : chunk.data);
|
||||
if (chunk.eof) break;
|
||||
}
|
||||
await cdp.send("IO.close", { handle: stream });
|
||||
await cdp.detach();
|
||||
const path = testInfo.outputPath("scroll-chromium-trace.json");
|
||||
await writeFile(path, chunks.join(""));
|
||||
await testInfo.attach("scroll-chromium-trace", { path, contentType: "application/json" });
|
||||
};
|
||||
}
|
||||
|
||||
/** Hold real image bytes until the reader has reached its placeholder. */
|
||||
export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo): Promise<void> {
|
||||
let released = false;
|
||||
const pending = new Set<() => void>();
|
||||
const server = createServer((_request, response) => {
|
||||
const send = () => {
|
||||
response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "no-store" });
|
||||
response.end(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="240" height="160" fill="#369"/></svg>',
|
||||
);
|
||||
};
|
||||
if (released) send();
|
||||
else pending.add(send);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Image server did not listen");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "reserved-timeline-image-",
|
||||
title: "Reserved image geometry",
|
||||
featureValues: {
|
||||
mockAssistantResponses: [
|
||||
`Before the image.\n\n\n\nText below the image.`,
|
||||
],
|
||||
},
|
||||
});
|
||||
try {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the delayed image");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, "Show the delayed image");
|
||||
const image = page.getByRole("img", { name: "Reserved image" }).first();
|
||||
await expect(image).toBeVisible();
|
||||
const before = await image.boundingBox();
|
||||
await attachTimelineScreenshot(page, testInfo, "image-placeholder");
|
||||
released = true;
|
||||
for (const send of pending) send();
|
||||
await expect
|
||||
.poll(() =>
|
||||
image.evaluate((element) => {
|
||||
const img =
|
||||
element instanceof HTMLImageElement ? element : element.querySelector("img");
|
||||
return img?.naturalWidth;
|
||||
}),
|
||||
)
|
||||
.toBe(240);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await image.boundingBox();
|
||||
return box ? Math.round((box.width / box.height) * 100) : 0;
|
||||
})
|
||||
.toBe(150);
|
||||
const after = await image.boundingBox();
|
||||
await attachTimelineScreenshot(page, testInfo, "image-loaded");
|
||||
expect(before?.height, "reserve the final image height before the HTTP response").toBe(
|
||||
after?.height,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
async function attachTimelineScreenshot(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.getByTestId("agent-chat-scroll").screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { cp, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { UsageReport } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expectPinnedUsage, pinRow, usageItem } from "./usage-sidebar-item";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
/** A real store-backed plugin on the worker's isolated daemon. */
|
||||
export async function installLoginUsage(initialReport: UsageReport) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const store = path.join(directory, "report.json");
|
||||
const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report));
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await client.removePlugin("login-journey");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
try {
|
||||
await setReport(initialReport);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "login-journey", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {z} from "zod";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}),
|
||||
discover: async () => [{key: "account", input: {}}],
|
||||
fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")),
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: directory });
|
||||
return { setReport, cleanup };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openUsage(page: Page) {
|
||||
await page.goto("/usage");
|
||||
}
|
||||
|
||||
export async function refreshLoginUsage(page: Page) {
|
||||
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function hoverUsageWindow(page: Page, window: string) {
|
||||
await page
|
||||
.getByRole("checkbox", { name: new RegExp(`^Pin Claude ${window}, `) })
|
||||
.getByTestId(/usage-pin-glyph-/)
|
||||
.hover();
|
||||
}
|
||||
|
||||
/** Exercises the built-in Codex adapter through the real plugin loader and daemon transport. */
|
||||
export async function installCodexWindowUsage(payload: unknown) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "usage-codex-windows-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await client.removePlugin("codex-window-qa");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
try {
|
||||
const source = path.resolve(__dirname, "../../../../../plugins/codex-usage-source");
|
||||
await cp(path.join(source, "server"), path.join(directory, "server"), { recursive: true });
|
||||
await cp(path.join(source, "shared"), path.join(directory, "shared"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(directory, "auth.json"),
|
||||
JSON.stringify({ tokens: { access_token: "fixture-codex" } }),
|
||||
);
|
||||
await writeFile(path.join(directory, "payload.json"), JSON.stringify(payload));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "codex-window-qa", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import {readFile} from "node:fs/promises";
|
||||
import {fetchUsage, discover} from "./server/usage.js";
|
||||
import {inputSchema} from "./shared/input.js";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({id: "codex-window-qa", label: "Codex", input: inputSchema,
|
||||
discover: (scope) => discover(scope, {home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}),
|
||||
fetch: (input) => fetchUsage(input, async () => Response.json(JSON.parse(await readFile(${JSON.stringify(path.join(directory, "payload.json"))}, "utf8")))),
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: directory });
|
||||
return { cleanup };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectCodexReportedWindows(page: Page, shape: "seven-day-only" | "Spark") {
|
||||
await expectPinnedUsage(page, ["11% wk"]);
|
||||
await expect(pinRow(page.locator("body"), "Codex", "Weekly")).toBeChecked();
|
||||
await expect(page.getByText("Session", { exact: true })).toHaveCount(0);
|
||||
if (shape === "Spark") {
|
||||
const fiveHour = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · 5-hour");
|
||||
const weekly = pinRow(page.locator("body"), "Codex", "GPT-5.3-Codex-Spark · Weekly");
|
||||
await expect(fiveHour).not.toBeChecked();
|
||||
await expect(weekly).not.toBeChecked();
|
||||
await fiveHour.click();
|
||||
await expect(fiveHour).toBeChecked();
|
||||
await expect(usageItem(page)).toHaveAccessibleName(
|
||||
/Codex Weekly 11% used, Codex GPT-5.3-Codex-Spark · 5-hour 0% used/,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,44 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
|
||||
|
||||
export interface UsageListRequest {
|
||||
forceRefresh: boolean;
|
||||
reportIds?: string[];
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface UsageReportsFixture {
|
||||
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
|
||||
listRequests(): UsageListRequest[];
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface UsageReportsFixtureOptions {
|
||||
/**
|
||||
* Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that
|
||||
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
|
||||
* request; a function builds the response from the request when it arrives (e.g. a fresh
|
||||
* `fetchedAt`, or a different answer to a forced refresh).
|
||||
*/
|
||||
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
|
||||
/** False simulates a host with no usage reporting capability. */
|
||||
usageSupported?: boolean;
|
||||
lists?: Array<UsageListResponse | ((request: UsageListRequest) => UsageListResponse)>;
|
||||
/**
|
||||
* False simulates a host with no usage reporting capability. A function is read each time the
|
||||
* app connects, so a reload can switch it.
|
||||
*/
|
||||
usageSupported?: boolean | (() => boolean);
|
||||
/** Released hosts expose provider.usage.list with no source icons. */
|
||||
providerUsageListOnly?: boolean;
|
||||
/** The host daemon's port; defaults to the E2E daemon. */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
type UsageListResponse = UsageReportEntry[] | { error: string };
|
||||
/**
|
||||
* A list of reports, a request error, or a stream: reports sent in order, where a promise holds
|
||||
* the rest of the stream and the response until it settles, like a slow source.
|
||||
*/
|
||||
export type UsageListResponse =
|
||||
| UsageReportEntry[]
|
||||
| { error: string }
|
||||
| { stream: Array<UsageReportEntry | Promise<unknown>> };
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
@@ -38,7 +59,11 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
return envelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): string | null {
|
||||
function withUsageSupportFeature(
|
||||
message: WebSocketMessage,
|
||||
enabled: boolean,
|
||||
providerUsageListOnly: boolean,
|
||||
): string | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown; payload?: Record<string, unknown> };
|
||||
@@ -59,7 +84,11 @@ function withUsageSupportFeature(message: WebSocketMessage, enabled: boolean): s
|
||||
...envelope.message,
|
||||
payload: {
|
||||
...payload,
|
||||
features: { ...features, usageSources: enabled, providerUsageList: enabled },
|
||||
features: {
|
||||
...features,
|
||||
usageSources: enabled && !providerUsageListOnly,
|
||||
providerUsageList: enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -93,49 +122,104 @@ export async function installUsageReportsFixture(
|
||||
page: Page,
|
||||
options: UsageReportsFixtureOptions,
|
||||
): Promise<UsageReportsFixture> {
|
||||
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
|
||||
const listRequests: UsageListRequest[] = [];
|
||||
const listCounter = createCounter();
|
||||
const usageSupported = options.usageSupported ?? true;
|
||||
const isUsageSupported = () =>
|
||||
typeof usageSupported === "function" ? usageSupported() : usageSupported;
|
||||
const providerUsageListOnly = options.providerUsageListOnly ?? false;
|
||||
const requestType = providerUsageListOnly
|
||||
? "provider.usage.list.request"
|
||||
: "usage.list_reports.request";
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const route =
|
||||
options.port === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(`${options.port}`);
|
||||
await page.routeWebSocket(route, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const request = getSessionMessage(message);
|
||||
const requestId = request?.requestId;
|
||||
if (request?.type === "usage.list_reports.request" && typeof requestId === "string") {
|
||||
listRequests.push({
|
||||
if (request?.type === requestType && typeof requestId === "string") {
|
||||
const listRequest: UsageListRequest = {
|
||||
forceRefresh: request.forceRefresh === true,
|
||||
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
|
||||
});
|
||||
agentId: typeof request.agentId === "string" ? request.agentId : undefined,
|
||||
};
|
||||
listRequests.push(listRequest);
|
||||
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
|
||||
const response = typeof scripted === "function" ? scripted() : scripted;
|
||||
const response = typeof scripted === "function" ? scripted(listRequest) : scripted;
|
||||
if ("error" in response) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "usage.list_reports.request",
|
||||
error: response.error,
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
message: providerUsageListOnly
|
||||
? {
|
||||
type: "rpc_error",
|
||||
payload: { requestId, requestType, error: response.error, code: "transport" },
|
||||
}
|
||||
: {
|
||||
type: "usage.list_reports.response",
|
||||
payload: { requestId, error: response.error },
|
||||
},
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
const sendUpdate = (report: UsageReportEntry) =>
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: { type: "usage.list_reports.update", payload: { requestId, report } },
|
||||
}),
|
||||
);
|
||||
if ("stream" in response) {
|
||||
void (async () => {
|
||||
for (const item of response.stream) {
|
||||
if (item instanceof Promise) await item;
|
||||
else sendUpdate(item);
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "usage.list_reports.response",
|
||||
payload: { requestId, error: null },
|
||||
},
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
})();
|
||||
return;
|
||||
}
|
||||
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
|
||||
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: { type: "usage.list_reports.response", payload: { requestId, reports } },
|
||||
}),
|
||||
);
|
||||
if (!providerUsageListOnly) {
|
||||
for (const report of reports) sendUpdate(report);
|
||||
}
|
||||
const reply = providerUsageListOnly
|
||||
? {
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
providers: reports.map((entry) => ({
|
||||
providerId: entry.sourceId,
|
||||
displayName: entry.sourceLabel,
|
||||
fetchedAt: entry.fetchedAt,
|
||||
status: entry.report.status,
|
||||
windows: entry.report.status === "available" ? entry.report.windows : [],
|
||||
balances: entry.report.status === "available" ? entry.report.balances : undefined,
|
||||
details: entry.report.status === "available" ? entry.report.details : undefined,
|
||||
planLabel:
|
||||
entry.report.status === "available" ? (entry.report.planLabel ?? null) : null,
|
||||
error: entry.report.status === "error" ? entry.report.error : null,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: { type: "usage.list_reports.response", payload: { requestId, error: null } };
|
||||
ws.send(JSON.stringify({ type: "session", message: reply }));
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +228,9 @@ export async function installUsageReportsFixture(
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string" ? withUsageSupportFeature(message, usageSupported) : null;
|
||||
typeof message === "string"
|
||||
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
|
||||
: null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
|
||||
export async function installTallUsageSource() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await client.removePlugin("tall-usage");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "tall-usage",
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import { z } from "zod";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({
|
||||
id: "tall-usage", label: "Scrolling account", input: z.object({}),
|
||||
discover: async () => [{key: "scrolling-account", input: {}}],
|
||||
fetch: async () => ({ status: "available", windows: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: String(i), label: "Window " + (i + 1), usedPct: 25,
|
||||
})) }),
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: directory });
|
||||
return { cleanup };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const PLUGINS_DIR = path.resolve(__dirname, "../../../../../plugins");
|
||||
|
||||
@@ -28,8 +80,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] {
|
||||
status: "available",
|
||||
planLabel: "Max",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", usedPct: 31, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 54, resetsAt: inOneDay() },
|
||||
{
|
||||
id: "five_hour",
|
||||
label: "Session",
|
||||
shortLabel: "5h",
|
||||
summary: true,
|
||||
usedPct: 31,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
shortLabel: "wk",
|
||||
summary: true,
|
||||
usedPct: 54,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -44,8 +110,22 @@ export function claudeAndCodexReports(): UsageReportEntry[] {
|
||||
status: "available",
|
||||
planLabel: "Pro",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 7, resetsAt: inOneDay() },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 12, resetsAt: inOneDay() },
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
shortLabel: "5h",
|
||||
summary: true,
|
||||
usedPct: 7,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
label: "Weekly",
|
||||
shortLabel: "wk",
|
||||
summary: true,
|
||||
usedPct: 12,
|
||||
resetsAt: inOneDay(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -57,7 +137,7 @@ function visible(page: Page, testID: string): Locator {
|
||||
return page.locator(`[data-testid="${testID}"]:visible`).first();
|
||||
}
|
||||
|
||||
/** The sidebar footer's Usage item: pinned windows, or a plain "Usage" row without any. */
|
||||
/** The sidebar footer's Usage item: each summary window with data. */
|
||||
export function usageItem(page: Page): Locator {
|
||||
return visible(page, "sidebar-usage");
|
||||
}
|
||||
@@ -71,15 +151,16 @@ export async function expectOnUsageScreen(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/usage$/);
|
||||
}
|
||||
|
||||
export async function expectPinnedUsage(page: Page, percents: string[]): Promise<void> {
|
||||
const windows = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
await expect(windows).toHaveText(percents);
|
||||
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
|
||||
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
|
||||
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
|
||||
await expect(pinned).toHaveText(windows);
|
||||
}
|
||||
|
||||
/** Without pinned windows the Usage item is a plain row that reads "Usage". */
|
||||
export async function expectNoPinnedUsage(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-usage-pinned-window"]:visible')).toHaveCount(0);
|
||||
await expect(usageItem(page)).toHaveText("Usage");
|
||||
/** Without a summary window with data the footer has no Usage item, only the Usage icon. */
|
||||
export async function expectNoUsageItem(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="sidebar-usage-icon"]:visible')).toBeVisible();
|
||||
}
|
||||
|
||||
/** A window row, which is itself the pin toggle: "Claude", "Session". */
|
||||
@@ -88,6 +169,19 @@ export function pinRow(scope: Locator, source: string, window: string): Locator
|
||||
return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) });
|
||||
}
|
||||
|
||||
/**
|
||||
* A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable
|
||||
* but buttons, so no row presses or highlights on hover.
|
||||
*/
|
||||
export async function expectUnpinnableRows(card: Locator): Promise<void> {
|
||||
await expect(card.getByRole("checkbox")).toHaveCount(0);
|
||||
await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0);
|
||||
const focusable = await card
|
||||
.locator("[tabindex]")
|
||||
.evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length);
|
||||
expect(focusable).toBe(0);
|
||||
}
|
||||
|
||||
export async function togglePin(scope: Locator, source: string, window: string) {
|
||||
const row = pinRow(scope, source, window);
|
||||
const pinned = await row.isChecked();
|
||||
@@ -95,8 +189,86 @@ export async function togglePin(scope: Locator, source: string, window: string)
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
export async function showUsageAs(scope: Locator | Page, displayAs: "used" | "remaining") {
|
||||
await scope.locator(`[data-testid="usage-display-${displayAs}"]:visible`).first().click();
|
||||
/** The footer's Usage icon, which is there whether or not the Usage item is on. */
|
||||
export async function openUsageScreenFromIcon(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
|
||||
await expectOnUsageScreen(page);
|
||||
}
|
||||
|
||||
/** Both footer entry points open Usage over the current screen on a phone. */
|
||||
export async function openUsageSheetFromIcon(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Usage", exact: true }).click();
|
||||
await expect(usageSheet(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeUsageSheet(page: Page): Promise<void> {
|
||||
const sheet = usageSheet(page);
|
||||
await waitForSettledPosition(sheet);
|
||||
const bounds = await sheet.boundingBox();
|
||||
if (!bounds) throw new Error("Usage sheet must be visible before closing it.");
|
||||
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
function summaryInSidebarSwitch(page: Page): Locator {
|
||||
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
|
||||
}
|
||||
|
||||
/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */
|
||||
export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await summaryInSidebarSwitch(page).click();
|
||||
await expectSummaryInSidebar(page, on);
|
||||
}
|
||||
|
||||
export async function expectSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on });
|
||||
await closeUsageOptions(page);
|
||||
}
|
||||
|
||||
/** Open the cog's settings popover or compact sheet. */
|
||||
export async function openUsageOptions(page: Page): Promise<void> {
|
||||
if (!(await visible(page, "usage-display-used").isVisible())) {
|
||||
await visible(page, "usage-options-menu").click();
|
||||
}
|
||||
await expect(visible(page, "usage-display-used")).toBeVisible();
|
||||
}
|
||||
|
||||
export function usageOptionsSurface(page: Page): Locator {
|
||||
return page
|
||||
.locator(
|
||||
'[data-testid="usage-options-surface"]:visible, [data-testid="usage-options-surface-content"]:visible',
|
||||
)
|
||||
.first();
|
||||
}
|
||||
|
||||
export async function expectUsageOptionsFitContent(page: Page): Promise<void> {
|
||||
await waitForSettledPosition(usageOptionsSurface(page));
|
||||
const surface = await usageOptionsSurface(page).boundingBox();
|
||||
const fields = await page.getByTestId("usage-options-fields").boundingBox();
|
||||
if (!surface || !fields) throw new Error("Settings must be visible before measuring its fit.");
|
||||
expect(surface.y + surface.height - (fields.y + fields.height)).toBeLessThanOrEqual(10);
|
||||
}
|
||||
|
||||
export async function closeUsageOptions(page: Page): Promise<void> {
|
||||
await waitForSettledPosition(usageOptionsSurface(page));
|
||||
await page.mouse.click(0, 0);
|
||||
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function showUsageAs(page: Page, displayAs: "used" | "remaining") {
|
||||
await openUsageOptions(page);
|
||||
await visible(page, `usage-display-${displayAs}`).click();
|
||||
await expect(visible(page, `usage-display-${displayAs}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await closeUsageOptions(page);
|
||||
}
|
||||
|
||||
export async function refreshAllUsage(page: Page): Promise<void> {
|
||||
await visible(page, "usage-refresh-all").click();
|
||||
}
|
||||
|
||||
/** Opens the compact sidebar drawer. */
|
||||
|
||||
@@ -89,7 +89,7 @@ async function openExplorerView(
|
||||
): Promise<void> {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
const tab = explorer.getByTestId(view.tabTestId);
|
||||
await tab.click();
|
||||
await selectWorkspaceTab(tab);
|
||||
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||
timeout: view.timeout ?? 30_000,
|
||||
});
|
||||
@@ -97,7 +97,7 @@ async function openExplorerView(
|
||||
|
||||
export async function openChangesTreePanel(page: Page): Promise<void> {
|
||||
await openExplorerView(page, {
|
||||
tabTestId: "explorer-sidebar-tab-changes_tree",
|
||||
tabTestId: "workspace-tab-changes_tree",
|
||||
contentTestId: "changes-tree-panel",
|
||||
});
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export async function openChangesPanel(page: Page, timeout = 30_000): Promise<vo
|
||||
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
await openExplorerView(page, {
|
||||
tabTestId: "explorer-sidebar-tab-files",
|
||||
tabTestId: "workspace-tab-files",
|
||||
contentTestId: "file-explorer-tree-scroll",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
appId: sh.paseo.debug
|
||||
---
|
||||
# Start in a workspace with the sidebar closed. Both workspace IDs come from
|
||||
# the connected host: TOP_WORKSPACE is near the top; SCROLLED_WORKSPACE starts
|
||||
# below the viewport. The list must remain unchanged throughout the flow.
|
||||
- tapOn:
|
||||
id: menu-button
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
direction: UP
|
||||
timeout: 60000
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
direction: DOWN
|
||||
timeout: 60000
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
- tapOn:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- waitForAnimationToEnd
|
||||
- assertNotVisible:
|
||||
id: sidebar-project-workspace-list-scroll
|
||||
- swipe:
|
||||
start: "5%,50%"
|
||||
end: "90%,50%"
|
||||
duration: 300
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
|
||||
# Selecting the current workspace removes workspace switching as a cause.
|
||||
# Reopening by button removes horizontal movement as a cause.
|
||||
- tapOn:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- waitForAnimationToEnd
|
||||
- tapOn:
|
||||
id: menu-button
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible:
|
||||
id: sidebar-workspace-row-${SCROLLED_WORKSPACE}
|
||||
- assertNotVisible:
|
||||
id: sidebar-workspace-row-${TOP_WORKSPACE}
|
||||
- tapOn:
|
||||
id: sidebar-close
|
||||
@@ -1,12 +1,10 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { resolve } = require("metro-resolver");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const fdroidModuleOverrides = {
|
||||
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
|
||||
@@ -81,15 +79,6 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
|
||||
}
|
||||
|
||||
const origin = context.originModulePath;
|
||||
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
|
||||
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
|
||||
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
return resolveWithCustomWebOverlay(context, tsModuleName, platform);
|
||||
}
|
||||
}
|
||||
|
||||
return resolveWithCustomWebOverlay(context, moduleName, platform);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0-beta.4",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -102,7 +102,7 @@
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"lucide-react-native": "^1.50.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
describe("reading anchor", () => {
|
||||
const rows = [
|
||||
{ id: "above", top: 0, height: 500 },
|
||||
{ id: "reading", top: 500, height: 500 },
|
||||
{ id: "below", top: 1000, height: 500 },
|
||||
];
|
||||
|
||||
it("preserves wheel movement during an asynchronous prepend", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(2440);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
2280,
|
||||
rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(2280);
|
||||
});
|
||||
|
||||
it("ignores growth below the reader and compensates growth above", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(anchor.reconcile(600, [rows[0]!, rows[1]!, { ...rows[2]!, height: 2000 }])).toBe(600);
|
||||
expect(
|
||||
anchor.reconcile(600, [
|
||||
{ ...rows[0]!, height: 1000 },
|
||||
{ ...rows[1]!, top: 1000 },
|
||||
{ ...rows[2]!, top: 1500 },
|
||||
]),
|
||||
).toBe(1100);
|
||||
});
|
||||
|
||||
it("leaves expansion of the row being read in place", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(
|
||||
anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 1000 }, { ...rows[2]!, top: 1500 }]),
|
||||
).toBe(600);
|
||||
});
|
||||
|
||||
it("keeps the existing reader through prepend measurements until the user scrolls", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(0, [{ id: "reading", top: 48, height: 100 }]);
|
||||
const prepended = [
|
||||
{ id: "new", top: 2000, height: 57 },
|
||||
{ id: "reading", top: 2057, height: 100 },
|
||||
];
|
||||
expect(anchor.reconcile(0, prepended)).toBe(2009);
|
||||
expect(anchor.getRowId()).toBe("reading");
|
||||
const measured = [
|
||||
{ id: "new", top: 2000, height: 100 },
|
||||
{ id: "reading", top: 2100, height: 100 },
|
||||
];
|
||||
expect(anchor.reconcile(2009, measured)).toBe(2052);
|
||||
expect(anchor.getRowId()).toBe("reading");
|
||||
expect(anchor.reconcile(2000, measured, true)).toBe(2000);
|
||||
expect(anchor.getRowId()).toBe("new");
|
||||
});
|
||||
|
||||
it("retains the reader while the virtualizer initializes its mounted range", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.reconcile(600, [rows[2]!]);
|
||||
expect(anchor.getRowId()).toBe("reading");
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
600,
|
||||
rows.map((row) => ({ ...row, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(2600);
|
||||
});
|
||||
|
||||
it("projects a prepend range without consuming the correction before layout commits", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
rows.map((row) => ({ ...row, top: row.top + 2000 })),
|
||||
true,
|
||||
),
|
||||
).toBe(2440);
|
||||
expect(anchor.getRowId()).toBe("above");
|
||||
});
|
||||
|
||||
it("repicks from user movement before applying a simultaneous layout correction", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.scroll(440);
|
||||
expect(anchor.getRowId()).toBe("above");
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
rows.map((row) => ({ ...row, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(2440);
|
||||
expect(anchor.getRowId()).toBe("above");
|
||||
});
|
||||
|
||||
it("releases the old reading position for explicit navigation", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.reset();
|
||||
expect(anchor.getRowId()).toBeNull();
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
0,
|
||||
rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 2000 })),
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
interface RowGeometry {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
export function createReadingAnchor() {
|
||||
let anchor: { id: string; top: number } | null = null;
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
|
||||
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
|
||||
return {
|
||||
getRowId: () => anchor?.id ?? null,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
|
||||
project,
|
||||
reset() {
|
||||
anchor = null;
|
||||
},
|
||||
scroll(scrollTop: number) {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
},
|
||||
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
|
||||
geometry = rows;
|
||||
const previous = anchor && rows.find((row) => row.id === anchor?.id);
|
||||
// The first virtualized commit can precede its mounted range. Keep the
|
||||
// pinned reader until it mounts, rather than adopting an unrelated row.
|
||||
if (anchor && !previous && !userScrolled) return scrollTop;
|
||||
const correctedTop = project(scrollTop, previous ?? undefined);
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
return correctedTop;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -491,10 +491,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
value: 4959.1708984375,
|
||||
});
|
||||
scrollContainer.scrollTop = 4959.1708984375;
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
@@ -504,10 +501,7 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
|
||||
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
value: 4960.5,
|
||||
});
|
||||
scrollContainer.scrollTop = 4960.5;
|
||||
|
||||
act(() => {
|
||||
viewportRef.current?.scrollToBottom("message-sent");
|
||||
@@ -516,10 +510,7 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" });
|
||||
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
value: 4967,
|
||||
});
|
||||
scrollContainer.scrollTop = 4967;
|
||||
|
||||
act(() => {
|
||||
viewportRef.current?.scrollToBottom("message-sent");
|
||||
@@ -535,7 +526,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
this.scrollTop = top;
|
||||
});
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
@@ -585,7 +576,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
@@ -621,10 +612,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
@@ -682,7 +670,7 @@ describe("createWebStreamStrategy", () => {
|
||||
const scrollElement = scrollContainer;
|
||||
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollElement.scrollTop = 0;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
@@ -724,10 +712,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
@@ -786,7 +771,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1400 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
@@ -798,7 +783,7 @@ describe("createWebStreamStrategy", () => {
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
@@ -831,10 +816,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
@@ -892,7 +874,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
@@ -901,17 +883,17 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
|
||||
scrollContainer.scrollTop = 991;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
|
||||
scrollContainer.scrollTop = 91;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
@@ -937,10 +919,7 @@ describe("createWebStreamStrategy", () => {
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
@@ -992,7 +971,7 @@ describe("createWebStreamStrategy", () => {
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
scrollContainer.scrollTop = 50;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
@@ -1004,13 +983,13 @@ describe("createWebStreamStrategy", () => {
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
|
||||
scrollContainer.scrollTop = 20;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
scrollContainer.scrollTop = 50;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
@@ -1038,7 +1017,7 @@ describe("createWebStreamStrategy", () => {
|
||||
it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => {
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
@@ -1084,7 +1063,7 @@ describe("createWebStreamStrategy", () => {
|
||||
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
@@ -1124,7 +1103,7 @@ describe("createWebStreamStrategy", () => {
|
||||
window.dispatchEvent(jitterPointerMove);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 });
|
||||
scrollContainer.scrollTop = 800;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
@@ -1132,11 +1111,11 @@ describe("createWebStreamStrategy", () => {
|
||||
nestedScroller.style.overflowY = "auto";
|
||||
Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 });
|
||||
Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 });
|
||||
Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 });
|
||||
nestedScroller.scrollTop = 100;
|
||||
scrollContainer.append(nestedScroller);
|
||||
act(() => {
|
||||
nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
|
||||
scrollContainer.scrollTop = 700;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }),
|
||||
@@ -1159,7 +1138,7 @@ describe("createWebStreamStrategy", () => {
|
||||
it("stops following output after scrollbar and middle-button upward scrolling", () => {
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
@@ -1204,7 +1183,7 @@ describe("createWebStreamStrategy", () => {
|
||||
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
@@ -1232,7 +1211,7 @@ describe("createWebStreamStrategy", () => {
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
act(() => scrollContainer.dispatchEvent(scrollbarPointerDown));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
|
||||
scrollContainer.scrollTop = 700;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
@@ -1248,7 +1227,7 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 });
|
||||
scrollContainer.scrollTop = 1300;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
@@ -1276,7 +1255,7 @@ describe("createWebStreamStrategy", () => {
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 });
|
||||
scrollContainer.scrollTop = 900;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
@@ -1295,7 +1274,7 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 });
|
||||
scrollContainer.scrollTop = 1400;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const autoscrollPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
@@ -1329,7 +1308,7 @@ describe("createWebStreamStrategy", () => {
|
||||
window.dispatchEvent(autoscrollPointerUp);
|
||||
window.dispatchEvent(autoscrollPointerMove);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
@@ -1390,7 +1369,7 @@ describe("createWebStreamStrategy", () => {
|
||||
};
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
this.scrollTop = top;
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
@@ -1447,12 +1426,18 @@ describe("createWebStreamStrategy", () => {
|
||||
throw new Error("Expected retained history rows");
|
||||
}
|
||||
let rowsShifted = false;
|
||||
scrollContainer.getBoundingClientRect = vi.fn(() => ({ top: 0 }) as DOMRect);
|
||||
firstRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 4 : 120 }) as DOMRect);
|
||||
secondRow.getBoundingClientRect = vi.fn(() => ({ bottom: rowsShifted ? 120 : 160 }) as DOMRect);
|
||||
scrollContainer.getBoundingClientRect = () => ({ top: 0, height: 500, bottom: 500 }) as DOMRect;
|
||||
firstRow.getBoundingClientRect = () =>
|
||||
({ top: rowsShifted ? -116 : 0, height: 120, bottom: rowsShifted ? 4 : 120 }) as DOMRect;
|
||||
secondRow.getBoundingClientRect = () =>
|
||||
({
|
||||
top: rowsShifted ? 4 : 120,
|
||||
height: rowsShifted ? 116 : 40,
|
||||
bottom: rowsShifted ? 120 : 160,
|
||||
}) as DOMRect;
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.scrollTop = 1000;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const viewportObservation = observed.get(scrollContainer);
|
||||
expect(viewportObservation).toBeDefined();
|
||||
@@ -1483,7 +1468,7 @@ describe("createWebStreamStrategy", () => {
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
scrollContainer.scrollTop = 0;
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
|
||||
@@ -9,10 +9,13 @@ import React, {
|
||||
} from "react";
|
||||
import {
|
||||
defaultRangeExtractor,
|
||||
observeElementOffset,
|
||||
measureElement as measureVirtualElement,
|
||||
useVirtualizer,
|
||||
type Range as VirtualRange,
|
||||
type Virtualizer,
|
||||
} from "@tanstack/react-virtual";
|
||||
import { flushSync } from "react-dom";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -20,10 +23,8 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
import {
|
||||
estimateStreamItemHeight,
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
} from "./web-virtualization";
|
||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
@@ -49,12 +50,6 @@ interface CreateWebStreamStrategyInput {
|
||||
isMobileBreakpoint: boolean;
|
||||
}
|
||||
|
||||
interface HistoryStartPrependAnchor {
|
||||
progressKey: string;
|
||||
rowId: string;
|
||||
viewportOffset: number;
|
||||
}
|
||||
|
||||
type ScrollBehaviorLike = "auto" | "smooth";
|
||||
|
||||
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
|
||||
@@ -67,24 +62,12 @@ const HISTORY_START_SLOT_HEIGHT_PX = 32;
|
||||
const CONTENT_PADDING_TOP_PX = 16;
|
||||
const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100;
|
||||
const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX;
|
||||
// A row has to clear this much of the viewport top before the next one takes over as the
|
||||
// reading position, so a row resting exactly on the edge does not flip back and forth.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null {
|
||||
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
|
||||
if (element.dataset.historyRowId === rowId) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -304,6 +287,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
olderHistoryProgressKey,
|
||||
scrollEnabled,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const historyVirtualized = useRevisedHistoryRows(
|
||||
@@ -348,14 +332,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const lastTouchClientYRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
|
||||
const historyStartPrependAnchorActiveRef = useRef(false);
|
||||
const readingAnchor = useMemo(createReadingAnchor, []);
|
||||
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
const lastActiveFollowOutputLayoutRef = useRef<ActiveFollowOutputLayout | null>(null);
|
||||
const lastObservedViewportGeometryRef = useRef<ObservedViewportGeometry | null>(null);
|
||||
@@ -389,14 +372,56 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
return indexes.length > 0 ? indexes : null;
|
||||
}, [chatFindMessageId, segments.historyVirtualized]);
|
||||
const rowVirtualizerRef = useRef<Virtualizer<HTMLElement, Element> | null>(null);
|
||||
const rangeExtractor = useCallback(
|
||||
(range: VirtualRange) => {
|
||||
const visible = defaultRangeExtractor(range);
|
||||
if (!chatFindRowIndexes) return visible;
|
||||
return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right);
|
||||
(range: VirtualRange): number[] => {
|
||||
const anchorIndex = segments.historyVirtualized.findIndex(
|
||||
(row) => row.id === readingAnchor.getRowId(),
|
||||
);
|
||||
const container = scrollContainerRef.current;
|
||||
// TanStack has computed the new measurements before calling the extractor.
|
||||
// Select against the pending anchor correction, before mounting any rows at
|
||||
// the stale pre-prepend offset. The DOM correction still belongs to commit.
|
||||
const virtualizer = rowVirtualizerRef.current!;
|
||||
const measurement = virtualizer.measurementsCache[anchorIndex];
|
||||
const correctedOffset =
|
||||
container && measurement
|
||||
? readingAnchor.project(container.scrollTop, {
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
})
|
||||
: container?.scrollTop;
|
||||
let visibleRange = range;
|
||||
if (
|
||||
container &&
|
||||
correctedOffset !== undefined &&
|
||||
Math.abs(correctedOffset - container.scrollTop) > 0.5
|
||||
) {
|
||||
const first = virtualizer.getVirtualItemForOffset(correctedOffset);
|
||||
const last = virtualizer.getVirtualItemForOffset(correctedOffset + container.clientHeight);
|
||||
if (first && last)
|
||||
visibleRange = { ...range, startIndex: first.index, endIndex: last.index };
|
||||
}
|
||||
return [
|
||||
...new Set([
|
||||
...defaultRangeExtractor(visibleRange),
|
||||
...(chatFindRowIndexes ?? []),
|
||||
...(anchorIndex < 0 ? [] : [anchorIndex]),
|
||||
]),
|
||||
].sort((left, right) => left - right);
|
||||
},
|
||||
[chatFindRowIndexes],
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
|
||||
);
|
||||
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
|
||||
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
|
||||
virtualOffsetListener.current = onOffset;
|
||||
const unsubscribe = observeElementOffset(instance, onOffset);
|
||||
return () => {
|
||||
virtualOffsetListener.current = null;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
const contentWidth = contentRef.current?.getBoundingClientRect().width;
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
@@ -404,31 +429,19 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||
estimateSize: (index: number) => {
|
||||
const row = segments.historyVirtualized[index];
|
||||
return row ? estimateStreamItemHeight(row, contentMaxWidth) : 120;
|
||||
const measured = row && measuredRowHeights.current.get(row.id);
|
||||
if (measured && measured.width === contentWidth) return measured.height;
|
||||
return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 120;
|
||||
},
|
||||
measureElement: measureVirtualElement,
|
||||
observeElementOffset: observeVirtualOffset,
|
||||
rangeExtractor,
|
||||
scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX,
|
||||
useAnimationFrameWithResizeObserver: true,
|
||||
overscan: 8,
|
||||
});
|
||||
useEffect(() => {
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
const viewportHeight = instance.scrollRect?.height ?? 0;
|
||||
const scrollOffset = instance.scrollOffset ?? 0;
|
||||
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
|
||||
return shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current,
|
||||
rowStart: item.start,
|
||||
scrollOffset,
|
||||
remainingDistanceFromBottom: remainingDistance,
|
||||
bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
|
||||
};
|
||||
}, [rowVirtualizer]);
|
||||
rowVirtualizerRef.current = rowVirtualizer;
|
||||
// Scroll correction belongs to the viewport's committed layout. The virtualizer
|
||||
// otherwise writes scrollTop before React applies its new row positions.
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false;
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
|
||||
@@ -442,7 +455,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
distanceFromHistoryStart: scrollContainer.scrollTop,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled,
|
||||
// An explicit jump owns the landing position. Revealing another local
|
||||
// page here changes the virtual index before the target has mounted.
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
@@ -453,29 +468,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (!isHistoryStartLoadingOperation(transition.state)) {
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
}
|
||||
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1);
|
||||
const anchorElement =
|
||||
contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null;
|
||||
if (scrollContainer && anchorRow && anchorElement) {
|
||||
historyStartPrependAnchorRef.current = {
|
||||
progressKey: olderHistoryProgressKey,
|
||||
rowId: anchorRow.id,
|
||||
viewportOffset:
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
};
|
||||
} else {
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
}
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
@@ -512,27 +507,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
const applyHistoryStartPrependAnchor = useStableEvent(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (
|
||||
!scrollContainer ||
|
||||
!contentNode ||
|
||||
!anchor ||
|
||||
!historyStartPrependAnchorActiveRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const anchorElement = findHistoryRowElement(contentNode, anchor.rowId);
|
||||
if (!anchorElement) {
|
||||
return;
|
||||
}
|
||||
const viewportOffset =
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top;
|
||||
scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset;
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
||||
});
|
||||
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -543,24 +517,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
return (
|
||||
!input ||
|
||||
input.isLoadingOlderHistory ||
|
||||
pendingVirtualRowMeasureFramesRef.current.size > 0
|
||||
);
|
||||
return !input || input.isLoadingOlderHistory;
|
||||
},
|
||||
onFrame: applyHistoryStartPrependAnchor,
|
||||
onSettle: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
@@ -569,59 +536,35 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scheduler.schedule();
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
|
||||
return;
|
||||
}
|
||||
historyStartPrependAnchorActiveRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
applyHistoryStartPrependAnchor();
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}, [
|
||||
applyHistoryStartPrependAnchor,
|
||||
evaluateHistoryStart,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
segments.historyMounted,
|
||||
segments.historyVirtualized,
|
||||
virtualTotalSize,
|
||||
]);
|
||||
|
||||
const measureVirtualizedRowElement = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node) {
|
||||
rowVirtualizer.measureElement(null);
|
||||
return;
|
||||
}
|
||||
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
|
||||
const existingFrame = pendingFrames.get(node);
|
||||
if (existingFrame !== undefined) {
|
||||
window.cancelAnimationFrame(existingFrame);
|
||||
}
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
pendingFrames.delete(node);
|
||||
if (isActiveRef.current && node.isConnected) {
|
||||
rowVirtualizer.measureElement(node);
|
||||
}
|
||||
});
|
||||
pendingFrames.set(node, frame);
|
||||
},
|
||||
// ResizeObserver runs before paint. Batch measurements and commit React's new
|
||||
// positions here so a resized image cannot overlap rows for an extra frame.
|
||||
// Use resizeItem exclusively; measureElement would install a second observer.
|
||||
const virtualRowObserver = useMemo(
|
||||
() =>
|
||||
new ResizeObserver((entries) => {
|
||||
if (!isActiveRef.current) return;
|
||||
flushSync(() => {
|
||||
for (const entry of entries) {
|
||||
const element = entry.target as HTMLElement;
|
||||
if (!element.isConnected) continue;
|
||||
rowVirtualizer.resizeItem(
|
||||
Number(element.dataset.index),
|
||||
measureVirtualElement(element, entry, rowVirtualizer),
|
||||
);
|
||||
}
|
||||
});
|
||||
}),
|
||||
[rowVirtualizer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingFrames = pendingVirtualRowMeasureFramesRef.current;
|
||||
return () => {
|
||||
for (const frame of pendingFrames.values()) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
pendingFrames.clear();
|
||||
};
|
||||
}, []);
|
||||
const measureVirtualizedRowElement = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node) return;
|
||||
virtualRowObserver.observe(node, { box: "border-box" });
|
||||
return () => virtualRowObserver.unobserve(node);
|
||||
},
|
||||
[virtualRowObserver],
|
||||
);
|
||||
useEffect(() => () => virtualRowObserver.disconnect(), [virtualRowObserver]);
|
||||
|
||||
const cancelPendingStickToBottom = useCallback(() => {
|
||||
const pendingFrame = pendingAutoScrollFrameRef.current;
|
||||
@@ -660,10 +603,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
cancelPendingStickToBottom();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
pendingVirtualRowMeasureFramesRef.current.clear();
|
||||
clearMouseScrollGesture();
|
||||
clearUpwardInputEvidence();
|
||||
lastTouchClientYRef.current = null;
|
||||
@@ -731,19 +670,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
onReadingPositionChange(null);
|
||||
return;
|
||||
}
|
||||
const readingLine = scrollContainer.getBoundingClientRect().top + READING_POSITION_OFFSET_PX;
|
||||
let readingRowId: string | null = null;
|
||||
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
|
||||
const rowId = element.dataset.historyRowId;
|
||||
if (!rowId) {
|
||||
continue;
|
||||
}
|
||||
readingRowId = rowId;
|
||||
if (element.getBoundingClientRect().bottom > readingLine) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
onReadingPositionChange(readingRowId);
|
||||
onReadingPositionChange(readingAnchor.getReadingRowId(scrollContainer.scrollTop));
|
||||
});
|
||||
|
||||
const updateScrollMetrics = useCallback(() => {
|
||||
@@ -769,6 +696,44 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
onNearBottomChange,
|
||||
});
|
||||
|
||||
const reconcileReadingPosition = useStableEvent(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!isActiveRef.current || !container || !content) return;
|
||||
const viewportTop = container.getBoundingClientRect().top;
|
||||
const scrollTop = container.scrollTop;
|
||||
const width = content.getBoundingClientRect().width;
|
||||
const rows = Array.from(
|
||||
content.querySelectorAll<HTMLElement>("[data-history-row-id]"),
|
||||
(element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const id = element.dataset.historyRowId!;
|
||||
measuredRowHeights.current.set(id, { width, height: bounds.height });
|
||||
return { id, top: bounds.top - viewportTop + scrollTop, height: bounds.height };
|
||||
},
|
||||
);
|
||||
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
|
||||
if (!shouldAnchor) readingAnchor.reset();
|
||||
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
|
||||
if (!shouldAnchor) {
|
||||
readingAnchor.reset();
|
||||
return;
|
||||
}
|
||||
if (Math.abs(correctedTop - container.scrollTop) > 0.5) {
|
||||
container.scrollTop = correctedTop;
|
||||
lastKnownScrollTopRef.current = container.scrollTop;
|
||||
// The DOM scroll event arrives later. Fill the corrected virtual range in
|
||||
// this layout so the reader does not get a frame containing only the pin.
|
||||
virtualOffsetListener.current?.(container.scrollTop, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply row positions and their scroll correction in the same paint, including
|
||||
// the ordinary-to-virtualized handoff and measurements of newly mounted rows.
|
||||
useLayoutEffect(() => {
|
||||
reconcileReadingPosition();
|
||||
}, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]);
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
@@ -801,11 +766,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
if (!followOutputRef.current && !isJumpSettling() && (scrolledUp || scrolledDown)) {
|
||||
readingAnchor.scroll(currentScrollTop);
|
||||
}
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
isJumpSettling,
|
||||
readingAnchor,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
@@ -814,8 +783,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
readingAnchor.reset();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -826,7 +794,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [evaluateHistoryStart, props.agentId]);
|
||||
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
@@ -959,9 +927,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
} else {
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
}
|
||||
if (historyStartPrependAnchorActiveRef.current) {
|
||||
applyHistoryStartPrependAnchor();
|
||||
}
|
||||
reconcileReadingPosition();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
@@ -980,7 +946,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [
|
||||
applyHistoryStartPrependAnchor,
|
||||
reconcileReadingPosition,
|
||||
evaluateHistoryStart,
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
@@ -1101,7 +1067,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
lastTouchClientYRef.current = null;
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
|
||||
// Read user movement before the virtualizer's bubbling listener flushes a
|
||||
// layout correction. Scroll events reuse the last committed row geometry.
|
||||
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true, capture: true });
|
||||
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
|
||||
scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true });
|
||||
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
|
||||
@@ -1115,7 +1083,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("scroll", handleDomScroll);
|
||||
scrollContainer.removeEventListener("scroll", handleDomScroll, true);
|
||||
scrollContainer.removeEventListener("wheel", handleWheel);
|
||||
scrollContainer.removeEventListener("keydown", handleKeyDown);
|
||||
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
|
||||
@@ -1188,9 +1156,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
overflowX: "hidden",
|
||||
overflowY: scrollEnabled ? "auto" : "hidden",
|
||||
overscrollBehaviorY: "contain",
|
||||
// The browser still anchors delayed image growth while following output.
|
||||
// Detached reading has one owner: reconcileReadingPosition.
|
||||
overflowAnchor: followOutput ? "auto" : "none",
|
||||
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
|
||||
};
|
||||
}, [isMobileBreakpoint, scrollEnabled]);
|
||||
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
|
||||
const viewportStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
position: "relative",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -94,6 +95,7 @@ export interface StreamRenderInput {
|
||||
forwardListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
|
||||
contentMaxWidth: number;
|
||||
imageContext?: AssistantImageContext;
|
||||
}
|
||||
|
||||
export interface ResolveStreamRenderStrategyInput {
|
||||
|
||||
@@ -1131,6 +1131,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
imageContext: { serverId: resolvedServerId, workspaceRoot },
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
splitWebVirtualizedHistory,
|
||||
type IndexedStreamItem,
|
||||
} from "./web-virtualization";
|
||||
@@ -124,8 +123,18 @@ describe("splitWebVirtualizedHistory", () => {
|
||||
|
||||
describe("estimateStreamItemHeight", () => {
|
||||
it("uses compact estimates for collapsed tool sequence rows", () => {
|
||||
expect(estimateStreamItemHeight(toolCall("tool", 1), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
|
||||
expect(estimateStreamItemHeight(thought("thought", 2), DEFAULT_CONTENT_MAX_WIDTH)).toBe(40);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item: toolCall("tool", 1),
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
).toBe(40);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item: thought("thought", 2),
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
).toBe(40);
|
||||
});
|
||||
|
||||
it("uses a larger estimate for user messages with image attachments", () => {
|
||||
@@ -147,7 +156,40 @@ describe("estimateStreamItemHeight", () => {
|
||||
],
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBe(220);
|
||||
expect(estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH })).toBe(
|
||||
220,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses local image dimensions only from the row's server and workspace", () => {
|
||||
clearAssistantImageMetadataCache();
|
||||
const imageContext = { serverId: "server-one", workspaceRoot: "/workspace/one" };
|
||||
setAssistantImageMetadata(
|
||||
{ source: "portrait.png", ...imageContext },
|
||||
{ width: 800, height: 1600 },
|
||||
);
|
||||
const item: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "local-image",
|
||||
text: "",
|
||||
timestamp: createTimestamp(2),
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight({ item, contentMaxWidth: 608, imageContext })).toBe(1264);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item,
|
||||
contentMaxWidth: 608,
|
||||
imageContext: { ...imageContext, workspaceRoot: "/workspace/two" },
|
||||
}),
|
||||
).toBe(464);
|
||||
expect(
|
||||
estimateStreamItemHeight({
|
||||
item,
|
||||
contentMaxWidth: 608,
|
||||
imageContext: { ...imageContext, serverId: "server-two" },
|
||||
}),
|
||||
).toBe(464);
|
||||
});
|
||||
|
||||
it("uses cached assistant image metadata when available", () => {
|
||||
@@ -166,56 +208,9 @@ describe("estimateStreamItemHeight", () => {
|
||||
timestamp: createTimestamp(2),
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item, DEFAULT_CONTENT_MAX_WIDTH)).toBeGreaterThan(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual row resize anchoring", () => {
|
||||
it("does not move the viewport when a visible row expands while detached", () => {
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: false,
|
||||
rowStart: 1200,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the reading position when a row above the viewport changes size", () => {
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
isHistoryStartPrependActive: false,
|
||||
rowStart: 800,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves history prepend and bottom following to their existing anchors", () => {
|
||||
const baseInput = {
|
||||
rowStart: 800,
|
||||
scrollOffset: 1000,
|
||||
remainingDistanceFromBottom: 5000,
|
||||
bottomThreshold: 64,
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
...baseInput,
|
||||
isHistoryStartPrependActive: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAdjustScrollForVirtualRowResize({
|
||||
...baseInput,
|
||||
isHistoryStartPrependActive: false,
|
||||
remainingDistanceFromBottom: 64,
|
||||
}),
|
||||
).toBe(false);
|
||||
estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH }),
|
||||
).toBeGreaterThan(220);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
|
||||
import {
|
||||
@@ -10,21 +11,6 @@ export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
|
||||
|
||||
export function shouldAdjustScrollForVirtualRowResize(input: {
|
||||
isHistoryStartPrependActive: boolean;
|
||||
rowStart: number;
|
||||
scrollOffset: number;
|
||||
remainingDistanceFromBottom: number;
|
||||
bottomThreshold: number;
|
||||
}): boolean {
|
||||
if (input.isHistoryStartPrependActive) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
input.remainingDistanceFromBottom > input.bottomThreshold && input.rowStart < input.scrollOffset
|
||||
);
|
||||
}
|
||||
|
||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||
};
|
||||
@@ -58,12 +44,28 @@ export interface WebVirtualizedHistoryWindow {
|
||||
mountedEntries: IndexedStreamItem[];
|
||||
}
|
||||
|
||||
export function estimateStreamItemHeight(item: StreamItem, contentMaxWidth: number): number {
|
||||
interface StreamItemHeightEstimateInput {
|
||||
item: StreamItem;
|
||||
contentMaxWidth: number;
|
||||
imageContext?: AssistantImageContext;
|
||||
}
|
||||
|
||||
export function estimateStreamItemHeight({
|
||||
item,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
}: StreamItemHeightEstimateInput): number {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return item.images && item.images.length > 0 ? 220 : 96;
|
||||
case "assistant_message":
|
||||
return estimateAssistantMessageHeightFromCache(item.text, contentMaxWidth) ?? 220;
|
||||
return (
|
||||
estimateAssistantMessageHeightFromCache({
|
||||
markdown: item.text,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
}) ?? 220
|
||||
);
|
||||
case "tool_call":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "thought":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { LucideProvider } from "lucide-react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||
@@ -45,6 +46,7 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -112,7 +114,7 @@ import {
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference } from "@/styles/theme";
|
||||
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
@@ -606,6 +608,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<HostConfirmationSheet />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
@@ -970,11 +973,13 @@ function RootAppTree() {
|
||||
return (
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</LucideProvider>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
@@ -7,10 +7,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { openPairScan } from "@/hosts/pair-scan-model";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -73,6 +75,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomWidth: 4,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
scanAgainButton: {
|
||||
marginTop: theme.spacing[6],
|
||||
},
|
||||
helperText: {
|
||||
marginTop: theme.spacing[6],
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -108,16 +113,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
||||
const raw = typeof result.data === "string" ? result.data.trim() : "";
|
||||
if (!raw) return null;
|
||||
|
||||
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
|
||||
return raw;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -129,9 +124,6 @@ export default function PairScanScreen() {
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
|
||||
|
||||
const navigateToPairedHost = useCallback(
|
||||
(serverId: string) => {
|
||||
@@ -144,6 +136,19 @@ export default function PairScanScreen() {
|
||||
[router, source],
|
||||
);
|
||||
|
||||
const [scan] = useState(() =>
|
||||
openPairScan({
|
||||
importConnectionLink: (link) =>
|
||||
getHostRuntimeStore().importConnectionLink(
|
||||
link,
|
||||
source === "onboarding" ? "hostRoot" : "hostSettings",
|
||||
),
|
||||
onConnected: navigateToPairedHost,
|
||||
}),
|
||||
);
|
||||
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
|
||||
useEffect(() => () => scan.close(), [scan]);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
try {
|
||||
router.back();
|
||||
@@ -159,30 +164,12 @@ export default function PairScanScreen() {
|
||||
}, [permission, requestPermission]);
|
||||
|
||||
const handleScan = useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (isPairing) return;
|
||||
const offerUrl = extractOfferUrlFromScan(result);
|
||||
if (!offerUrl) return;
|
||||
|
||||
const store = getHostRuntimeStore();
|
||||
if (passwordOfferUrl) return;
|
||||
setIsPairing(true);
|
||||
setScanError(null);
|
||||
void store
|
||||
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
|
||||
.then((outcome) => {
|
||||
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
|
||||
else setPasswordOfferUrl(offerUrl);
|
||||
return outcome;
|
||||
})
|
||||
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
|
||||
.finally(() => setIsPairing(false));
|
||||
},
|
||||
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
|
||||
(result: BarcodeScanningResult) =>
|
||||
scan.scan(typeof result.data === "string" ? result.data : ""),
|
||||
[scan],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
|
||||
const savePasswordPairing = useCallback(
|
||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||
[navigateToPairedHost],
|
||||
@@ -240,24 +227,38 @@ export default function PairScanScreen() {
|
||||
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
|
||||
onBarcodeScanned={handleScan}
|
||||
/>
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<View style={styles.scanFrame}>
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
|
||||
{scanState.status === "pairing" ? (
|
||||
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" && scanState.error ? (
|
||||
<Text style={helperTextStyle}>{scanState.error}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" ? (
|
||||
<Button
|
||||
style={styles.scanAgainButton}
|
||||
onPress={scan.scanAgain}
|
||||
testID="pair-scan-again"
|
||||
>
|
||||
{t("pairing.connectionMethods.scanQr.title")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<PairLinkModal
|
||||
visible={passwordOfferUrl !== null}
|
||||
initialUrl={passwordOfferUrl ?? undefined}
|
||||
initialPasswordRequired
|
||||
onClose={closePasswordModal}
|
||||
visible={scanState.status === "passwordRequired"}
|
||||
passwordRequired={
|
||||
scanState.status === "passwordRequired" ? scanState.passwordRequired : undefined
|
||||
}
|
||||
onClose={scan.closePassword}
|
||||
onSaved={savePasswordPairing}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
? assistantFileLinkQueryKey({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
ambiguousQuery: resolution.ambiguousQuery,
|
||||
token: resolution.token,
|
||||
})
|
||||
: DISABLED_QUERY_KEY,
|
||||
[resolution, serverId, workspaceRoot],
|
||||
@@ -179,7 +179,7 @@ function openAssistantFileLink(input: {
|
||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||
serverId: capturedConfig.serverId,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||
token: capturedResolution.token,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
@@ -245,14 +245,9 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
|
||||
function assistantFileLinkQueryKey(input: {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
}): AssistantFileLinkQueryKey {
|
||||
return [
|
||||
"assistantFileLink",
|
||||
input.serverId ?? null,
|
||||
input.workspaceRoot ?? null,
|
||||
input.ambiguousQuery,
|
||||
];
|
||||
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
|
||||
}
|
||||
|
||||
async function dispatchResolvedLink(input: {
|
||||
|
||||
@@ -415,8 +415,8 @@ export function useAssistantImage({
|
||||
const preview = dataImage ? dataImagePreview : filePreview;
|
||||
const previewUri = preview.status === "loaded" ? preview.uri : null;
|
||||
const uri = directUri ?? previewUri;
|
||||
const cachedMetadata = useMemo(
|
||||
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
|
||||
const cachedAspectRatio = useMemo(
|
||||
() => getAssistantImageMetadata({ source, workspaceRoot, serverId })?.aspectRatio ?? null,
|
||||
[serverId, source, workspaceRoot],
|
||||
);
|
||||
const [lifecycle, dispatchLifecycle] = useReducer(
|
||||
@@ -454,9 +454,9 @@ export function useAssistantImage({
|
||||
dispatch({
|
||||
type: "preview_created",
|
||||
uri,
|
||||
aspectRatio: cachedMetadata?.aspectRatio ?? null,
|
||||
aspectRatio: cachedAspectRatio,
|
||||
});
|
||||
}, [cachedMetadata, dispatch, uri]);
|
||||
}, [cachedAspectRatio, dispatch, uri]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
if (uri) {
|
||||
@@ -484,7 +484,7 @@ export function useAssistantImage({
|
||||
const metadata = dimensions
|
||||
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
|
||||
: null;
|
||||
const aspectRatio = metadata?.aspectRatio ?? cachedMetadata?.aspectRatio ?? null;
|
||||
const aspectRatio = metadata?.aspectRatio ?? cachedAspectRatio;
|
||||
if (!aspectRatio) {
|
||||
dispatch({
|
||||
type: "failed",
|
||||
@@ -495,7 +495,7 @@ export function useAssistantImage({
|
||||
}
|
||||
dispatch({ type: "image_loaded", uri, aspectRatio });
|
||||
},
|
||||
[cachedMetadata, dispatch, serverId, source, t, uri, workspaceRoot],
|
||||
[cachedAspectRatio, dispatch, serverId, source, t, uri, workspaceRoot],
|
||||
);
|
||||
|
||||
const acquisitionFailure = getAcquisitionFailure({
|
||||
@@ -539,6 +539,6 @@ export function useAssistantImage({
|
||||
return {
|
||||
status: "loading",
|
||||
binding,
|
||||
aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null,
|
||||
aspectRatio: (hasCurrentLifecycleUri ? lifecycle.aspectRatio : null) ?? cachedAspectRatio,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export interface AudioEngine {
|
||||
toggleMute(): boolean;
|
||||
isMuted(): boolean;
|
||||
|
||||
play(audio: AudioPlaybackSource): Promise<number>;
|
||||
play(audio: AudioPlaybackSource, signal?: AbortSignal): Promise<number>;
|
||||
stop(): void;
|
||||
clearQueue(): void;
|
||||
isPlaying(): boolean;
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
|
||||
|
||||
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { createPlaybackQueue } from "./playback";
|
||||
import { playFile } from "./file-playback";
|
||||
import { playPcm16 } from "./pcm";
|
||||
|
||||
export function createAudioEngine(
|
||||
callbacks: AudioEngineCallbacks,
|
||||
_options?: { traceLabel?: string },
|
||||
): AudioEngine {
|
||||
const native = require("@getpaseo/expo-two-way-audio");
|
||||
|
||||
const refs: {
|
||||
initialized: boolean;
|
||||
captureActive: boolean;
|
||||
muted: boolean;
|
||||
destroyed: boolean;
|
||||
} = {
|
||||
initialized: false,
|
||||
captureActive: false,
|
||||
muted: false,
|
||||
destroyed: false,
|
||||
};
|
||||
|
||||
const microphoneSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onMicrophoneData",
|
||||
(event: { data: Uint8Array }) => {
|
||||
if (!refs.captureActive || refs.muted) {
|
||||
return;
|
||||
}
|
||||
const pcm = event.data;
|
||||
callbacks.onCaptureData(pcm);
|
||||
},
|
||||
);
|
||||
const volumeSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onInputVolumeLevelData",
|
||||
(event: { data: number }) => {
|
||||
if (!refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
const level = refs.muted ? 0 : event.data;
|
||||
callbacks.onVolumeLevel(level);
|
||||
},
|
||||
);
|
||||
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
|
||||
"onAudioInterruption",
|
||||
(event: { data: string }) => {
|
||||
if (event.data !== "blocked") {
|
||||
return;
|
||||
}
|
||||
const wasCaptureActive = refs.captureActive;
|
||||
refs.captureActive = false;
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
if (wasCaptureActive) {
|
||||
callbacks.onInterruption?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function ensureInitialized(): Promise<void> {
|
||||
if (refs.initialized) {
|
||||
return;
|
||||
}
|
||||
const success = await native.initialize();
|
||||
if (!success) {
|
||||
throw new Error("expo-two-way-audio: native initialize() returned false");
|
||||
}
|
||||
refs.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the OS audio session as soon as we are neither capturing nor playing.
|
||||
* Holding it keeps the user's background music paused — on iOS the non-mixing
|
||||
* `.playAndRecord` category survives backgrounding and is re-asserted on every
|
||||
* foreground, so an unreleased session means their music never comes back.
|
||||
*/
|
||||
function releaseSessionIfIdle(): void {
|
||||
if (!refs.initialized || refs.destroyed) {
|
||||
return;
|
||||
}
|
||||
if (refs.captureActive || playback.isPlaying()) {
|
||||
return;
|
||||
}
|
||||
// The wrapper no-ops on binaries whose native module predates this function.
|
||||
native.releaseAudioSession();
|
||||
}
|
||||
|
||||
async function ensureMicrophonePermission(): Promise<void> {
|
||||
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
|
||||
if (!permission?.granted) {
|
||||
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
|
||||
}
|
||||
if (!permission?.granted) {
|
||||
throw new Error(
|
||||
"Microphone permission is required to capture audio. Please enable microphone access in system settings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let nextFileId = 0;
|
||||
async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise<number> {
|
||||
const bytes = new Uint8Array(await audio.arrayBuffer());
|
||||
if (signal.aborted) throw new Error("Playback stopped");
|
||||
if (audio.type.startsWith("audio/pcm")) {
|
||||
await ensureInitialized();
|
||||
return playPcm16(bytes, audio.type, signal, native);
|
||||
}
|
||||
// Capture owns its audio session while active. File playback alone must not
|
||||
// initialize the microphone or the native two-way engine.
|
||||
if (!refs.captureActive) {
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
allowsRecording: false,
|
||||
interruptionMode: "duckOthers",
|
||||
interruptionModeAndroid: "duckOthers",
|
||||
});
|
||||
}
|
||||
if (signal.aborted) throw new Error("Playback stopped");
|
||||
// AVPlayer needs a file extension to recognize local encoded audio on iOS.
|
||||
const extension =
|
||||
{
|
||||
"audio/wav": "wav",
|
||||
"audio/x-wav": "wav",
|
||||
"audio/wave": "wav",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp3": "mp3",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/aac": "aac",
|
||||
"audio/ogg": "ogg",
|
||||
"audio/flac": "flac",
|
||||
}[audio.type.split(";")[0].trim()] ?? "audio";
|
||||
const file = new File(Paths.cache, `paseo-audio-${Date.now()}-${nextFileId++}.${extension}`);
|
||||
try {
|
||||
file.write(bytes);
|
||||
const player = createAudioPlayer(file.uri, {
|
||||
updateInterval: 100,
|
||||
keepAudioSessionActive: refs.captureActive,
|
||||
});
|
||||
return await playFile(player, signal);
|
||||
} finally {
|
||||
if (file.exists) file.delete();
|
||||
}
|
||||
}
|
||||
const playback = createPlaybackQueue(playAudio, releaseSessionIfIdle);
|
||||
|
||||
return {
|
||||
async initialize() {
|
||||
await ensureInitialized();
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
if (refs.destroyed) {
|
||||
return;
|
||||
}
|
||||
refs.destroyed = true;
|
||||
playback.destroy();
|
||||
if (refs.captureActive) {
|
||||
native.toggleRecording(false);
|
||||
refs.captureActive = false;
|
||||
}
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
if (refs.initialized) {
|
||||
native.tearDown();
|
||||
refs.initialized = false;
|
||||
}
|
||||
microphoneSubscription.remove();
|
||||
volumeSubscription.remove();
|
||||
interruptionSubscription.remove();
|
||||
},
|
||||
|
||||
async startCapture() {
|
||||
if (refs.captureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureMicrophonePermission();
|
||||
await ensureInitialized();
|
||||
const isRecording = native.toggleRecording(true);
|
||||
if (!isRecording) {
|
||||
throw new Error(
|
||||
"Microphone capture could not start because Android audio focus is unavailable.",
|
||||
);
|
||||
}
|
||||
refs.captureActive = true;
|
||||
} catch (error) {
|
||||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||||
callbacks.onError?.(wrapped);
|
||||
throw wrapped;
|
||||
}
|
||||
},
|
||||
|
||||
async stopCapture() {
|
||||
if (refs.captureActive) {
|
||||
native.toggleRecording(false);
|
||||
}
|
||||
refs.captureActive = false;
|
||||
refs.muted = false;
|
||||
callbacks.onVolumeLevel(0);
|
||||
releaseSessionIfIdle();
|
||||
},
|
||||
|
||||
toggleMute() {
|
||||
refs.muted = !refs.muted;
|
||||
if (refs.muted) {
|
||||
callbacks.onVolumeLevel(0);
|
||||
}
|
||||
return refs.muted;
|
||||
},
|
||||
|
||||
isMuted() {
|
||||
return refs.muted;
|
||||
},
|
||||
|
||||
play: playback.play,
|
||||
stop: playback.stop,
|
||||
clearQueue: playback.clearQueue,
|
||||
isPlaying: playback.isPlaying,
|
||||
};
|
||||
}
|
||||
+49
-141
@@ -1,15 +1,8 @@
|
||||
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
AudioPlaybackSource,
|
||||
} from "@/voice/audio-engine-types";
|
||||
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
|
||||
|
||||
interface QueuedAudio {
|
||||
audio: AudioPlaybackSource;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
import { createPlaybackQueue } from "./playback";
|
||||
|
||||
function getAudioContextCtor(): typeof AudioContext | null {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -21,40 +14,6 @@ function getAudioContextCtor(): typeof AudioContext | null {
|
||||
return browserWindow.AudioContext ?? browserWindow.webkitAudioContext ?? null;
|
||||
}
|
||||
|
||||
function floatToInt16(sample: number): number {
|
||||
const clamped = Math.max(-1, Math.min(1, sample));
|
||||
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
|
||||
}
|
||||
|
||||
function resampleToPcm16(input: Float32Array, inputRate: number, outputRate: number): Uint8Array {
|
||||
if (input.length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
const ratio = inputRate / outputRate;
|
||||
const outputLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const output = new Int16Array(outputLength);
|
||||
for (let i = 0; i < outputLength; i += 1) {
|
||||
const sourceIndex = i * ratio;
|
||||
const i0 = Math.floor(sourceIndex);
|
||||
const i1 = Math.min(input.length - 1, i0 + 1);
|
||||
const frac = sourceIndex - i0;
|
||||
const sample = input[i0] * (1 - frac) + input[i1] * frac;
|
||||
output[i] = floatToInt16(sample);
|
||||
}
|
||||
|
||||
return new Uint8Array(output.buffer, output.byteOffset, output.byteLength);
|
||||
}
|
||||
|
||||
function parsePcmSampleRate(mimeType: string): number | null {
|
||||
const match = /rate=(\d+)/i.exec(mimeType);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rate = Number(match[1]);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
}
|
||||
|
||||
function pcm16LeToAudioBuffer(
|
||||
context: AudioContext,
|
||||
bytes: Uint8Array,
|
||||
@@ -98,14 +57,6 @@ export function createAudioEngine(
|
||||
gain: GainNode | null;
|
||||
started: boolean;
|
||||
muted: boolean;
|
||||
queue: QueuedAudio[];
|
||||
processingQueue: boolean;
|
||||
activePlayback: {
|
||||
source: AudioBufferSourceNode;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
settled: boolean;
|
||||
} | null;
|
||||
} = {
|
||||
playbackContext: null,
|
||||
captureContext: null,
|
||||
@@ -115,29 +66,31 @@ export function createAudioEngine(
|
||||
gain: null,
|
||||
started: false,
|
||||
muted: false,
|
||||
queue: [],
|
||||
processingQueue: false,
|
||||
activePlayback: null,
|
||||
};
|
||||
|
||||
async function ensurePlaybackContext(): Promise<AudioContext> {
|
||||
if (refs.playbackContext) {
|
||||
if (refs.playbackContext.state === "suspended") {
|
||||
await refs.playbackContext.resume().catch(() => undefined);
|
||||
}
|
||||
return refs.playbackContext;
|
||||
if (!refs.playbackContext) {
|
||||
const AudioContextCtor = getAudioContextCtor();
|
||||
if (!AudioContextCtor) throw new Error("AudioContext unavailable");
|
||||
refs.playbackContext = new AudioContextCtor();
|
||||
}
|
||||
|
||||
const AudioContextCtor = getAudioContextCtor();
|
||||
if (!AudioContextCtor) {
|
||||
throw new Error("AudioContext unavailable");
|
||||
}
|
||||
|
||||
const context = new AudioContextCtor();
|
||||
const context = refs.playbackContext;
|
||||
if (context.state === "suspended") {
|
||||
await context.resume().catch(() => undefined);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
context.resume(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error("Audio playback requires a user interaction")),
|
||||
2000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
refs.playbackContext = context;
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -162,7 +115,7 @@ export function createAudioEngine(
|
||||
return context;
|
||||
}
|
||||
|
||||
async function playAudio(audio: AudioPlaybackSource): Promise<number> {
|
||||
async function playAudio(audio: AudioPlaybackSource, signal: AbortSignal): Promise<number> {
|
||||
const context = await ensurePlaybackContext();
|
||||
const arrayBuffer = await audio.arrayBuffer();
|
||||
const type = (audio.type || "").toLowerCase();
|
||||
@@ -174,53 +127,39 @@ export function createAudioEngine(
|
||||
)
|
||||
: await decodeAudioData(context, arrayBuffer);
|
||||
|
||||
if (signal.aborted) throw new Error("Playback stopped");
|
||||
const durationSec = audioBuffer.duration;
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(context.destination);
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
refs.activePlayback = { source, resolve, reject, settled: false };
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
const active = refs.activePlayback;
|
||||
if (!active || active.source !== source || active.settled) {
|
||||
return;
|
||||
}
|
||||
active.settled = true;
|
||||
refs.activePlayback = null;
|
||||
fn();
|
||||
const abort = () => {
|
||||
source.stop();
|
||||
source.disconnect();
|
||||
reject(new Error("Playback stopped"));
|
||||
};
|
||||
|
||||
source.addEventListener("ended", () => {
|
||||
settle(() => resolve(durationSec));
|
||||
});
|
||||
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
source.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
source.disconnect();
|
||||
resolve(durationSec);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
try {
|
||||
source.start();
|
||||
} catch (error) {
|
||||
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
||||
signal.removeEventListener("abort", abort);
|
||||
source.disconnect();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function processQueue(): Promise<void> {
|
||||
if (refs.processingQueue || refs.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
refs.processingQueue = true;
|
||||
while (refs.queue.length > 0) {
|
||||
const item = refs.queue.shift()!;
|
||||
try {
|
||||
const duration = await playAudio(item.audio);
|
||||
item.resolve(duration);
|
||||
} catch (error) {
|
||||
item.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
refs.processingQueue = false;
|
||||
}
|
||||
const playback = createPlaybackQueue(playAudio);
|
||||
|
||||
async function stopCapture(): Promise<void> {
|
||||
refs.started = false;
|
||||
@@ -263,8 +202,7 @@ export function createAudioEngine(
|
||||
},
|
||||
|
||||
async destroy() {
|
||||
this.stop();
|
||||
this.clearQueue();
|
||||
playback.destroy();
|
||||
await stopCapture();
|
||||
|
||||
const playbackContext = refs.playbackContext;
|
||||
@@ -334,7 +272,8 @@ export function createAudioEngine(
|
||||
return;
|
||||
}
|
||||
|
||||
callbacks.onCaptureData(resampleToPcm16(input, context.sampleRate, 16000));
|
||||
const pcm = resampleToPcm16(input, context.sampleRate, 16000);
|
||||
callbacks.onCaptureData(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
@@ -370,40 +309,9 @@ export function createAudioEngine(
|
||||
return refs.muted;
|
||||
},
|
||||
|
||||
async play(audio: AudioPlaybackSource) {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
refs.queue.push({ audio, resolve, reject });
|
||||
if (!refs.processingQueue) {
|
||||
void processQueue();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (refs.activePlayback) {
|
||||
const active = refs.activePlayback;
|
||||
refs.activePlayback = null;
|
||||
try {
|
||||
active.source.stop();
|
||||
} catch {
|
||||
// Ignore best-effort stop errors.
|
||||
}
|
||||
if (!active.settled) {
|
||||
active.settled = true;
|
||||
active.reject(new Error("Playback stopped"));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearQueue() {
|
||||
while (refs.queue.length > 0) {
|
||||
refs.queue.shift()!.reject(new Error("Playback stopped"));
|
||||
}
|
||||
refs.processingQueue = false;
|
||||
},
|
||||
|
||||
isPlaying() {
|
||||
return refs.activePlayback !== null;
|
||||
},
|
||||
play: playback.play,
|
||||
stop: playback.stop,
|
||||
clearQueue: playback.clearQueue,
|
||||
isPlaying: playback.isPlaying,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface FilePlaybackStatus {
|
||||
playbackState: string;
|
||||
isLoaded: boolean;
|
||||
didJustFinish: boolean;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface FilePlayer {
|
||||
play(): void;
|
||||
remove(): void;
|
||||
addListener(
|
||||
event: "playbackStatusUpdate",
|
||||
listener: (status: FilePlaybackStatus) => void,
|
||||
): { remove(): void };
|
||||
}
|
||||
|
||||
/** Own completion, load failure and resource release for the native file backend. */
|
||||
export function playFile(player: FilePlayer, signal: AbortSignal): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
// expo-audio 1.x does not report Android decoder errors. A local file that
|
||||
// never becomes loaded must still release its queue slot and temporary file.
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (error?: unknown, duration = 0) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", abort);
|
||||
subscription.remove();
|
||||
player.remove();
|
||||
if (error) reject(error);
|
||||
else resolve(duration);
|
||||
};
|
||||
const abort = () => finish(new Error("Playback stopped"));
|
||||
let loaded = false;
|
||||
const subscription = player.addListener("playbackStatusUpdate", (status) => {
|
||||
if (status.playbackState === "error" || status.playbackState === "failed") {
|
||||
finish(new Error("Audio file could not be decoded"));
|
||||
} else if (status.didJustFinish) {
|
||||
finish(undefined, status.duration);
|
||||
} else if (status.isLoaded && status.duration > 0 && !loaded) {
|
||||
loaded = true;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(
|
||||
() => finish(new Error("Audio playback timed out")),
|
||||
(status.duration + 30) * 1000,
|
||||
);
|
||||
}
|
||||
});
|
||||
timeout = setTimeout(() => finish(new Error("Audio file could not be loaded")), 15000);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
else {
|
||||
try {
|
||||
player.play();
|
||||
} catch (error) {
|
||||
finish(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createAudioEngine } from "./audio-engine";
|
||||
export type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
|
||||
export { createPlayAudio } from "./play-audio";
|
||||
@@ -0,0 +1,124 @@
|
||||
const floatToInt16 = (sample: number): number => {
|
||||
const clamped = Math.max(-1, Math.min(1, sample));
|
||||
return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
|
||||
};
|
||||
|
||||
export const resampleToPcm16 = (
|
||||
input: Float32Array,
|
||||
inputRate: number,
|
||||
outputRate: number,
|
||||
): Int16Array => {
|
||||
if (input.length === 0) {
|
||||
return new Int16Array(0);
|
||||
}
|
||||
if (inputRate === outputRate) {
|
||||
const out = new Int16Array(input.length);
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
out[i] = floatToInt16(input[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ratio = inputRate / outputRate;
|
||||
const outputLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const out = new Int16Array(outputLength);
|
||||
for (let i = 0; i < outputLength; i++) {
|
||||
const sourceIndex = i * ratio;
|
||||
const i0 = Math.floor(sourceIndex);
|
||||
const i1 = Math.min(input.length - 1, i0 + 1);
|
||||
const frac = sourceIndex - i0;
|
||||
const sample = input[i0] * (1 - frac) + input[i1] * frac;
|
||||
out[i] = floatToInt16(sample);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export function parsePcmSampleRate(mimeType: string): number | null {
|
||||
const match = /rate=(\d+)/i.exec(mimeType);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rate = Number(match[1]);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
}
|
||||
|
||||
function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array {
|
||||
if (fromRate === toRate) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
const inputSamples = Math.floor(pcm.length / 2);
|
||||
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
|
||||
const out = new Uint8Array(outputSamples * 2);
|
||||
const ratio = fromRate / toRate;
|
||||
|
||||
const readInt16 = (sampleIndex: number): number => {
|
||||
const i = sampleIndex * 2;
|
||||
if (i + 1 >= pcm.length) {
|
||||
return 0;
|
||||
}
|
||||
const lo = pcm[i];
|
||||
const hi = pcm[i + 1];
|
||||
let value = (hi << 8) | lo;
|
||||
if (value & 0x8000) {
|
||||
value = value - 0x10000;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const writeInt16 = (sampleIndex: number, value: number): void => {
|
||||
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
|
||||
const i = sampleIndex * 2;
|
||||
out[i] = clamped & 0xff;
|
||||
out[i + 1] = (clamped >> 8) & 0xff;
|
||||
};
|
||||
|
||||
for (let i = 0; i < outputSamples; i++) {
|
||||
const srcPos = i * ratio;
|
||||
const i0 = Math.floor(srcPos);
|
||||
const frac = srcPos - i0;
|
||||
const s0 = readInt16(i0);
|
||||
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
|
||||
writeInt16(i, s0 + (s1 - s0) * frac);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
interface PcmOutput {
|
||||
resumePlayback(): void;
|
||||
playPCMData(bytes: Uint8Array): void;
|
||||
stopPlayback(): void;
|
||||
}
|
||||
|
||||
/** Keep voice output on the native communication engine and its echo reference. */
|
||||
export function playPcm16(
|
||||
bytes: Uint8Array,
|
||||
mimeType: string,
|
||||
signal: AbortSignal,
|
||||
output: PcmOutput,
|
||||
): Promise<number> {
|
||||
if (signal.aborted) return Promise.reject(new Error("Playback stopped"));
|
||||
const pcm = resamplePcm16(bytes, parsePcmSampleRate(mimeType) ?? 24000, 16000);
|
||||
const duration = pcm.length / 2 / 16000;
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const abort = () => {
|
||||
clearTimeout(timeout);
|
||||
output.stopPlayback();
|
||||
reject(new Error("Playback stopped"));
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
output.resumePlayback();
|
||||
output.playPCMData(pcm);
|
||||
timeout = setTimeout(() => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
resolve(duration);
|
||||
}, duration * 1000);
|
||||
} catch (error) {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createPlayAudio } from "./play-audio";
|
||||
import { playPcm16, parsePcmSampleRate, resampleToPcm16 } from "./pcm";
|
||||
import { playFile, type FilePlaybackStatus, type FilePlayer } from "./file-playback";
|
||||
|
||||
const source = { base64: "UklGRg==", mimeType: "audio/wav" };
|
||||
test("passes RPC file bytes to the shared engine and awaits completion", async () => {
|
||||
const owner = new AbortController();
|
||||
let finish!: () => void;
|
||||
const played = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let completed = false;
|
||||
const play = createPlayAudio(
|
||||
{
|
||||
async play(audio, signal) {
|
||||
expect(signal).toBe(owner.signal);
|
||||
expect(audio.type).toBe("audio/wav");
|
||||
expect(audio.size).toBe(4);
|
||||
expect(new Uint8Array(await audio.arrayBuffer())).toEqual(new Uint8Array([82, 73, 70, 70]));
|
||||
await played;
|
||||
return 1;
|
||||
},
|
||||
},
|
||||
owner.signal,
|
||||
);
|
||||
const result = play(source).then(() => {
|
||||
completed = true;
|
||||
return undefined;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(completed).toBe(false);
|
||||
finish();
|
||||
await result;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ ...source, base64: "garbage!" },
|
||||
{ ...source, base64: "" },
|
||||
{ ...source, mimeType: "text/plain" },
|
||||
])("rejects invalid input before reaching a decoder: %o", async (input) => {
|
||||
const play = createPlayAudio(
|
||||
{
|
||||
play() {
|
||||
throw new Error("Reached decoder");
|
||||
},
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
await expect(play(input)).rejects.toThrow(/^Audio must/);
|
||||
});
|
||||
|
||||
test("rejects calls from an unloaded plugin", async () => {
|
||||
const owner = new AbortController();
|
||||
owner.abort(new Error("Plugin unloaded"));
|
||||
const play = createPlayAudio(
|
||||
{
|
||||
play() {
|
||||
throw new Error("Reached decoder");
|
||||
},
|
||||
},
|
||||
owner.signal,
|
||||
);
|
||||
await expect(play(source)).rejects.toThrow("Playback stopped");
|
||||
});
|
||||
|
||||
test("voice PCM retains its native output and 16 kHz samples", async () => {
|
||||
const pcm = new Uint8Array([0, 0, 255, 127, 0, 128]);
|
||||
let written: Uint8Array | undefined;
|
||||
const duration = await playPcm16(
|
||||
pcm,
|
||||
"audio/pcm;rate=16000;bits=16",
|
||||
new AbortController().signal,
|
||||
{
|
||||
resumePlayback() {},
|
||||
playPCMData(bytes) {
|
||||
written = bytes;
|
||||
},
|
||||
stopPlayback() {},
|
||||
},
|
||||
);
|
||||
expect(written).toEqual(pcm);
|
||||
expect(duration).toBe(3 / 16000);
|
||||
});
|
||||
|
||||
test("voice PCM cancellation stops native output and rejects", async () => {
|
||||
let stopped = false;
|
||||
const owner = new AbortController();
|
||||
const result = playPcm16(new Uint8Array(32000), "audio/pcm;rate=16000;bits=16", owner.signal, {
|
||||
resumePlayback() {},
|
||||
playPCMData() {},
|
||||
stopPlayback() {
|
||||
stopped = true;
|
||||
},
|
||||
});
|
||||
owner.abort();
|
||||
await expect(result).rejects.toThrow("Playback stopped");
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
|
||||
function filePlayer() {
|
||||
let listener: (status: FilePlaybackStatus) => void = () => {};
|
||||
let removed = false;
|
||||
let started = false;
|
||||
const player: FilePlayer = {
|
||||
play() {
|
||||
started = true;
|
||||
},
|
||||
remove() {
|
||||
removed = true;
|
||||
},
|
||||
addListener(_event, callback) {
|
||||
listener = callback;
|
||||
return {
|
||||
remove() {
|
||||
listener = () => {};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
player,
|
||||
status: (status: Partial<FilePlaybackStatus>) =>
|
||||
listener({
|
||||
playbackState: "ready",
|
||||
duration: 1,
|
||||
didJustFinish: false,
|
||||
isLoaded: true,
|
||||
...status,
|
||||
}),
|
||||
removed: () => removed,
|
||||
started: () => started,
|
||||
};
|
||||
}
|
||||
|
||||
test("native file playback waits for completion and releases its player", async () => {
|
||||
const h = filePlayer();
|
||||
const promise = playFile(h.player, new AbortController().signal);
|
||||
expect(h.started()).toBe(true);
|
||||
h.status({ didJustFinish: true });
|
||||
await expect(promise).resolves.toBe(1);
|
||||
expect(h.removed()).toBe(true);
|
||||
});
|
||||
|
||||
test("native decoder errors reject and release the player", async () => {
|
||||
const h = filePlayer();
|
||||
const promise = playFile(h.player, new AbortController().signal);
|
||||
h.status({ playbackState: "failed" });
|
||||
await expect(promise).rejects.toThrow("could not be decoded");
|
||||
expect(h.removed()).toBe(true);
|
||||
});
|
||||
|
||||
test("native cancellation releases playback and settles the caller", async () => {
|
||||
const h = filePlayer();
|
||||
const owner = new AbortController();
|
||||
const promise = playFile(h.player, owner.signal);
|
||||
owner.abort(new Error("Plugin unloaded"));
|
||||
await expect(promise).rejects.toThrow("Playback stopped");
|
||||
expect(h.removed()).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["audio/pcm;rate=16000;bits=16", "AUDIO/PCM; rate=16000"])(
|
||||
"rejects raw PCM instead of initializing voice capture: %s",
|
||||
async (mimeType) => {
|
||||
const play = createPlayAudio(
|
||||
{
|
||||
play: async () => {
|
||||
throw new Error("Reached engine");
|
||||
},
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
await expect(play({ base64: "AAAAAA==", mimeType })).rejects.toThrow(
|
||||
"Pass an audio file, such as WAV or MP3",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Voice capture and browser dictation use this same PCM boundary.
|
||||
test("capture PCM preserves clipping, signed samples, and empty input", () => {
|
||||
expect(resampleToPcm16(new Float32Array([-2, -1, -0.5, 0, 0.5, 1, 2]), 16000, 16000)).toEqual(
|
||||
new Int16Array([-32768, -32768, -16384, 0, 16384, 32767, 32767]),
|
||||
);
|
||||
expect(resampleToPcm16(new Float32Array(), 48000, 16000)).toEqual(new Int16Array());
|
||||
});
|
||||
|
||||
test("capture PCM resamples with interpolation and preserves the last sample", () => {
|
||||
expect(resampleToPcm16(new Float32Array([-1, 0, 1]), 24000, 16000)).toEqual(
|
||||
new Int16Array([-32768, 16384]),
|
||||
);
|
||||
expect(resampleToPcm16(new Float32Array([0, 1]), 8000, 16000)).toEqual(
|
||||
new Int16Array([0, 16384, 32767, 32767]),
|
||||
);
|
||||
});
|
||||
|
||||
test("voice playback shares PCM sample-rate parsing", () => {
|
||||
expect(parsePcmSampleRate("audio/pcm;rate=16000;bits=16")).toBe(16000);
|
||||
expect(parsePcmSampleRate("audio/PCM;RATE=48000")).toBe(48000);
|
||||
expect(parsePcmSampleRate("audio/pcm")).toBeNull();
|
||||
expect(parsePcmSampleRate("audio/pcm;rate=0")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Buffer } from "buffer";
|
||||
import type { AudioEngine } from "./audio-engine-types";
|
||||
|
||||
/** RPC-friendly file input. No browser Blob, URL, or platform knowledge is needed. */
|
||||
export function createPlayAudio(engine: Pick<AudioEngine, "play">, signal: AbortSignal) {
|
||||
return async (source: { base64: string; mimeType: string }): Promise<void> => {
|
||||
if (signal.aborted) throw new Error("Playback stopped");
|
||||
if (
|
||||
!source ||
|
||||
typeof source.base64 !== "string" ||
|
||||
!/^[A-Za-z0-9+/]+={0,2}$/.test(source.base64) ||
|
||||
source.base64.length % 4 !== 0 ||
|
||||
!source.base64.length
|
||||
) {
|
||||
throw new Error("Audio must contain a non-empty base64-encoded file");
|
||||
}
|
||||
if (
|
||||
typeof source.mimeType !== "string" ||
|
||||
!/^audio\/[\w.+-]+(?:[ \t]*;[^\r\n]+)?$/i.test(source.mimeType)
|
||||
) {
|
||||
throw new Error("Audio must have an audio MIME type, such as audio/wav");
|
||||
}
|
||||
const bytes = Uint8Array.from(Buffer.from(source.base64, "base64"));
|
||||
const [mediaType, ...parameters] = source.mimeType.split(";");
|
||||
const type = [mediaType.trim().toLowerCase(), ...parameters].join(";");
|
||||
if (mediaType.trim().toLowerCase() === "audio/pcm") {
|
||||
throw new Error("Pass an audio file, such as WAV or MP3; raw PCM is not supported");
|
||||
}
|
||||
await engine.play(
|
||||
{
|
||||
type,
|
||||
size: bytes.byteLength,
|
||||
arrayBuffer: async () => bytes.buffer,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createPlaybackQueue } from "./playback";
|
||||
|
||||
function harness() {
|
||||
const started: string[] = [];
|
||||
const finishes: (() => void)[] = [];
|
||||
const queue = createPlaybackQueue<string>((source, signal) => {
|
||||
started.push(source);
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
finishes.push(() => resolve(1));
|
||||
signal.addEventListener("abort", () => reject(new Error("Playback stopped")), { once: true });
|
||||
});
|
||||
});
|
||||
return { queue, started, finishes };
|
||||
}
|
||||
|
||||
test("plays in order and resolves only after playback completes", async () => {
|
||||
const h = harness();
|
||||
const first = h.queue.play("first");
|
||||
const second = h.queue.play("second");
|
||||
expect(h.started).toEqual(["first"]);
|
||||
h.finishes[0]();
|
||||
await expect(first).resolves.toBe(1);
|
||||
expect(h.started).toEqual(["first", "second"]);
|
||||
h.finishes[1]();
|
||||
await expect(second).resolves.toBe(1);
|
||||
});
|
||||
|
||||
test("cancels one owner's queued and active work without stopping another owner", async () => {
|
||||
const h = harness();
|
||||
const owner = new AbortController();
|
||||
const first = h.queue.play("first", owner.signal);
|
||||
const queued = h.queue.play("cancelled", owner.signal);
|
||||
const other = h.queue.play("other");
|
||||
const rejected = Promise.all([
|
||||
expect(first).rejects.toThrow("Playback stopped"),
|
||||
expect(queued).rejects.toThrow("Playback stopped"),
|
||||
]);
|
||||
owner.abort(new Error("unloaded"));
|
||||
await rejected;
|
||||
expect(h.started).toEqual(["first", "other"]);
|
||||
h.finishes[1]();
|
||||
await expect(other).resolves.toBe(1);
|
||||
});
|
||||
|
||||
test("stop during loading cannot start cancelled audio or strand the next call", async () => {
|
||||
const h = harness();
|
||||
const first = h.queue.play("loading");
|
||||
const rejected = expect(first).rejects.toThrow("Playback stopped");
|
||||
h.queue.stop();
|
||||
await rejected;
|
||||
const second = h.queue.play("next");
|
||||
h.finishes[1]();
|
||||
await expect(second).resolves.toBe(1);
|
||||
});
|
||||
|
||||
test("a failed file does not block the queue", async () => {
|
||||
const queue = createPlaybackQueue<string>(async (source) => {
|
||||
if (source === "invalid") throw new Error("Invalid audio");
|
||||
return 2;
|
||||
});
|
||||
const failed = queue.play("invalid");
|
||||
const next = queue.play("valid");
|
||||
await expect(failed).rejects.toThrow("Invalid audio");
|
||||
await expect(next).resolves.toBe(2);
|
||||
});
|
||||
|
||||
test("ending the voice waiting cue leaves a plugin's active audio playing", async () => {
|
||||
const { createVoiceRuntime } = await import("../voice/voice-runtime");
|
||||
let finishPlugin!: () => void;
|
||||
let cueQueued!: () => void;
|
||||
const queued = new Promise<void>((resolve) => {
|
||||
cueQueued = resolve;
|
||||
});
|
||||
const output = createPlaybackQueue<{ type: string }>(
|
||||
(_source, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
finishPlugin = () => resolve(1);
|
||||
signal.addEventListener("abort", () => reject(new Error("Playback stopped")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const engine = {
|
||||
initialize: async () => {},
|
||||
destroy: async () => {
|
||||
output.destroy();
|
||||
},
|
||||
startCapture: async () => {},
|
||||
stopCapture: async () => {},
|
||||
toggleMute: () => false,
|
||||
isMuted: () => false,
|
||||
play(source: { type: string }, signal?: AbortSignal) {
|
||||
if (source.type.startsWith("audio/pcm")) cueQueued();
|
||||
return output.play(source, signal);
|
||||
},
|
||||
stop: output.stop,
|
||||
clearQueue: output.clearQueue,
|
||||
isPlaying: output.isPlaying,
|
||||
};
|
||||
const runtime = createVoiceRuntime({
|
||||
engine,
|
||||
getServerInfo: () => ({
|
||||
serverId: "host",
|
||||
hostname: "host",
|
||||
version: "1.0.0",
|
||||
capabilities: {
|
||||
voice: { dictation: { enabled: true, reason: "" }, voice: { enabled: true, reason: "" } },
|
||||
},
|
||||
}),
|
||||
activateKeepAwake: async () => {},
|
||||
deactivateKeepAwake: async () => {},
|
||||
});
|
||||
runtime.registerSession({
|
||||
serverId: "host",
|
||||
setVoiceMode: async () => {},
|
||||
sendVoiceAudioChunk: async () => {},
|
||||
audioPlayed: async () => {},
|
||||
abortRequest: async () => {},
|
||||
setAssistantAudioPlaying: () => {},
|
||||
});
|
||||
try {
|
||||
await runtime.startVoice("host", "agent");
|
||||
const result = engine.play({ type: "audio/wav" }).then(
|
||||
() => "finished",
|
||||
() => "stopped",
|
||||
);
|
||||
runtime.onTurnEvent("host", "agent", "turn_started");
|
||||
await queued;
|
||||
runtime.onAssistantAudioStarted("host");
|
||||
finishPlugin();
|
||||
expect(await result).toBe("finished");
|
||||
} finally {
|
||||
await runtime.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/** One queue for voice and plugin audio, including cancellation while loading. */
|
||||
export function createPlaybackQueue<Source>(
|
||||
start: (source: Source, signal: AbortSignal) => Promise<number>,
|
||||
onIdle: () => void = () => {},
|
||||
) {
|
||||
interface Item {
|
||||
source: Source;
|
||||
controller: AbortController;
|
||||
resolve: (duration: number) => void;
|
||||
reject: (error: unknown) => void;
|
||||
release: () => void;
|
||||
}
|
||||
const pending: Item[] = [];
|
||||
let active: Item | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
async function drain() {
|
||||
if (active) return;
|
||||
const item = pending.shift();
|
||||
if (!item) {
|
||||
onIdle();
|
||||
return;
|
||||
}
|
||||
active = item;
|
||||
try {
|
||||
if (item.controller.signal.aborted) throw new Error("Playback stopped");
|
||||
item.resolve(await start(item.source, item.controller.signal));
|
||||
} catch (error) {
|
||||
item.reject(error);
|
||||
} finally {
|
||||
item.release();
|
||||
active = null;
|
||||
void drain();
|
||||
}
|
||||
}
|
||||
|
||||
const stopped = () => new Error("Playback stopped");
|
||||
return {
|
||||
play(source: Source, signal?: AbortSignal): Promise<number> {
|
||||
if (destroyed) return Promise.reject(new Error("Audio engine destroyed"));
|
||||
if (signal?.aborted) return Promise.reject(new Error("Playback stopped"));
|
||||
return new Promise((resolve, reject) => {
|
||||
const controller = new AbortController();
|
||||
const cancel = () => {
|
||||
controller.abort();
|
||||
const index = pending.indexOf(item);
|
||||
if (index !== -1) {
|
||||
pending.splice(index, 1);
|
||||
item.release();
|
||||
reject(new Error("Playback stopped"));
|
||||
}
|
||||
};
|
||||
const item: Item = {
|
||||
source,
|
||||
controller,
|
||||
resolve,
|
||||
reject,
|
||||
release: () => signal?.removeEventListener("abort", cancel),
|
||||
};
|
||||
signal?.addEventListener("abort", cancel, { once: true });
|
||||
pending.push(item);
|
||||
void drain();
|
||||
});
|
||||
},
|
||||
stop() {
|
||||
active?.controller.abort();
|
||||
},
|
||||
clearQueue() {
|
||||
for (const item of pending.splice(0)) {
|
||||
item.release();
|
||||
item.reject(stopped());
|
||||
}
|
||||
},
|
||||
isPlaying: () => active !== null,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
this.clearQueue();
|
||||
this.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,11 +14,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetFlatList,
|
||||
type BottomSheetFlatListMethods,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -718,12 +714,6 @@ export function CommandCenter() {
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
const backdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
@@ -735,7 +725,7 @@ export function CommandCenter() {
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={backdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.sheetBackground}
|
||||
handleIndicatorStyle={styles.sheetHandle}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Platform, Pressable, Text, View } from "react-native";
|
||||
import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -13,13 +13,11 @@ import {
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
KEYBOARD_STATUS,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
@@ -75,11 +73,15 @@ export interface SheetHeader {
|
||||
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
|
||||
const NATIVE_DIALOG_SNAP_POINTS = ["100%"];
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
nativeModalRoot: {
|
||||
nativeDialogSurface: {
|
||||
flex: 1,
|
||||
},
|
||||
nativeDialogBackground: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
desktopOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
@@ -471,7 +473,7 @@ export interface AdaptiveModalSheetProps {
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** Size compact sheet content to the live snap height instead of its largest snap point. */
|
||||
sizeContentToCurrentSnapPoint?: boolean;
|
||||
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
|
||||
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
|
||||
@@ -526,33 +528,26 @@ export function AdaptiveModalSheet({
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb && visible) {
|
||||
// A newly opened sheet owns input. A keyboard belonging to the sheet below
|
||||
// would cover controls in the new sheet, which has no focused input yet.
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled: isMobile,
|
||||
isEnabled: isMobile || !isWeb,
|
||||
onClose,
|
||||
});
|
||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
|
||||
const nativeModalDismissNotifiedRef = useRef(!visible);
|
||||
const handleDismiss = useCallback(() => {
|
||||
handleSheetDismiss();
|
||||
onDismiss?.();
|
||||
}, [handleSheetDismiss, onDismiss]);
|
||||
const notifyNativeModalDismiss = useCallback(() => {
|
||||
if (nativeModalDismissNotifiedRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeModalDismissNotifiedRef.current = true;
|
||||
onDismiss?.();
|
||||
}, [onDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const desktopCardStyle = useMemo(
|
||||
() => [
|
||||
@@ -592,12 +587,6 @@ export function AdaptiveModalSheet({
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
nativeModalDismissNotifiedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile) return;
|
||||
if (visible) {
|
||||
@@ -615,12 +604,6 @@ export function AdaptiveModalSheet({
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb || isMobile || visible || Platform.OS !== "android") return;
|
||||
const timeout = setTimeout(notifyNativeModalDismiss, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [visible, isMobile, notifyNativeModalDismiss]);
|
||||
|
||||
if (isMobile) {
|
||||
const sheetContent = (
|
||||
<>
|
||||
@@ -658,7 +641,7 @@ export function AdaptiveModalSheet({
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
@@ -725,18 +708,28 @@ export function AdaptiveModalSheet({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
onDismiss={notifyNativeModalDismiss}
|
||||
hardwareAccelerated
|
||||
// Both native presentations share Gorhom's app-wide stack. Independent RN Modals
|
||||
// present from their React ancestor's controller, so a root-owned sibling dialog
|
||||
// cannot present while that controller already has a dialog open on iOS.
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
snapPoints={NATIVE_DIALOG_SNAP_POINTS}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
handleComponent={null}
|
||||
backgroundStyle={styles.nativeDialogBackground}
|
||||
enablePanDownToClose={false}
|
||||
enableHandlePanningGesture={false}
|
||||
enableContentPanningGesture={false}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
{/* Android Modal opens a separate window outside the app's gesture root. */}
|
||||
<GestureHandlerRootView style={styles.nativeModalRoot}>
|
||||
{desktopContent}
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
<View style={styles.nativeDialogSurface}>{desktopContent}</View>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,11 +294,23 @@ export interface AddHostModalProps {
|
||||
}
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
return (
|
||||
<AddHostModalContent
|
||||
key={String(visible)}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
|
||||
useHostMutations();
|
||||
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
|
||||
const [linkPairing] = useState(() => beginLinkPairing());
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -313,18 +325,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
setHost("");
|
||||
setPort("6767");
|
||||
setUseTls(false);
|
||||
setPassword("");
|
||||
setIsPasswordVisible(false);
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
advancedTarget.current = new PairingTargetTracker("", true);
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
|
||||
const connectIcon = useMemo(
|
||||
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
||||
[theme.colors.accentForeground],
|
||||
@@ -358,29 +358,25 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
|
||||
|
||||
// Each open mounts a fresh form (see AddHostModal), so closing needs no reset.
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
}, [isSaving, clearInput, onClose]);
|
||||
}, [isSaving, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setErrorMessage("");
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
}, [isSaving, onCancel, onClose]);
|
||||
|
||||
const handleSaveRelay = useCallback(
|
||||
async (relayUri: string) => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||
relayUri,
|
||||
password || undefined,
|
||||
);
|
||||
const result = await linkPairing.submit(relayUri, password || undefined);
|
||||
if (result.status === "cancelled") return;
|
||||
const { profile, serverId, hostname } = result;
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
@@ -396,9 +392,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
daemons,
|
||||
directConnectionLabels.invalidConnection,
|
||||
handleClose,
|
||||
linkPairing,
|
||||
onSaved,
|
||||
password,
|
||||
probeAndUpsertConnectionFromOfferUrl,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Github,
|
||||
HardDrive,
|
||||
Plus,
|
||||
Search,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
applyAvailableAddProjectHosts,
|
||||
backAddProjectPage,
|
||||
@@ -162,7 +162,7 @@ function FlowBackButton({ onPress }: { onPress: () => void }) {
|
||||
}
|
||||
|
||||
function methodIcon(method: AddProjectMethodId): FlowRowOption["icon"] {
|
||||
if (method === "github") return Github;
|
||||
if (method === "github") return GitHubIcon;
|
||||
if (method === "browse") return FolderOpen;
|
||||
if (method === "new-directory") return FolderPlus;
|
||||
return Search;
|
||||
@@ -656,7 +656,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}`
|
||||
: repository.nameWithOwner,
|
||||
subtitle: repository.description,
|
||||
icon: Github,
|
||||
icon: GitHubIcon,
|
||||
testID: `add-project-flow-repository-${repository.id}`,
|
||||
select: () =>
|
||||
setState((current) => openGithubLocationPage(current, page.hostId, repository)),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user