mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 04:45:11 +00:00
Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa6df8a959 | ||
|
|
ec179aa01e | ||
|
|
0563654cb0 | ||
|
|
6ead9a9e0b | ||
|
|
4d740fd4dd | ||
|
|
ddc45fe1c1 | ||
|
|
4ed83a0ef0 | ||
|
|
969b2938e9 | ||
|
|
b759977818 | ||
|
|
35acb5a914 | ||
|
|
0869f6df33 | ||
|
|
18b19f1e78 | ||
|
|
37c99c12b1 | ||
|
|
bf3d6b5fe0 | ||
|
|
4c072573fe | ||
|
|
ee4c26f48e | ||
|
|
19d791777e | ||
|
|
b2a83d335f | ||
|
|
c523b3ad9b | ||
|
|
4858e62270 | ||
|
|
d6efcc453b | ||
|
|
2e90865134 | ||
|
|
603c52b294 | ||
|
|
737792ad7c | ||
|
|
3edd502aac | ||
|
|
bd3d86749c | ||
|
|
b152308340 | ||
|
|
2bd55c5ff7 | ||
|
|
f7a442f4d4 | ||
|
|
1f79e07feb | ||
|
|
c7f55fd13f | ||
|
|
7349b26b56 | ||
|
|
0d9cf2b982 | ||
|
|
dc4379ee01 | ||
|
|
8e0a18f595 | ||
|
|
b927be1bc7 | ||
|
|
e755886c4f | ||
|
|
857808f7c9 | ||
|
|
ad5e76b133 | ||
|
|
d7e8099288 | ||
|
|
27bbf5355b | ||
|
|
3536689ffb | ||
|
|
322c542eaf | ||
|
|
fa8dcb372a | ||
|
|
59ab8b8c1f | ||
|
|
75a1ccdcee | ||
|
|
4b67d035ce | ||
|
|
5afbd0d7d3 | ||
|
|
d30aa16beb | ||
|
|
a6a37b568d | ||
|
|
93d21e5612 | ||
|
|
caae991a0f | ||
|
|
046ac7b339 | ||
|
|
6213df73dc | ||
|
|
02d3a05d2b | ||
|
|
175f36d64a | ||
|
|
dd2281d82c | ||
|
|
2a9395e2d1 | ||
|
|
85ce1aa828 | ||
|
|
d13438ba50 | ||
|
|
cf9eaa8d29 | ||
|
|
ec52097924 | ||
|
|
a30b784eb2 | ||
|
|
af6ca46ae3 | ||
|
|
8dc979ec2f | ||
|
|
70aeb7b45b | ||
|
|
8b3b2a9ea3 | ||
|
|
b6952995be | ||
|
|
c558af91eb | ||
|
|
7791dcee3b | ||
|
|
e532a24327 | ||
|
|
1aba2a5f23 | ||
|
|
aead84bf0f | ||
|
|
7980ad008b | ||
|
|
7c35ad90b9 | ||
|
|
9eccb4722b | ||
|
|
536846eb60 | ||
|
|
cc16ac6ac4 | ||
|
|
fd9aebd622 | ||
|
|
21709812bb | ||
|
|
d426088c3f | ||
|
|
b5c25e5c10 | ||
|
|
525ee7f222 | ||
|
|
065c487a90 | ||
|
|
4df5307eaa | ||
|
|
aa7cabb455 | ||
|
|
9e607a3aa3 | ||
|
|
68818067f2 | ||
|
|
2240d4effe | ||
|
|
18c002cbf2 | ||
|
|
47a7463182 | ||
|
|
a4878656e2 | ||
|
|
aade09d056 | ||
|
|
a29e06bf1b | ||
|
|
a886bfbcdd | ||
|
|
88b46c6781 | ||
|
|
ee6a2bba77 | ||
|
|
83f4ef9909 | ||
|
|
a99f18b405 | ||
|
|
6e5742fbc6 | ||
|
|
97d39a0158 | ||
|
|
8b8957ab77 | ||
|
|
603ee5ced1 | ||
|
|
de1b85ecfb | ||
|
|
33c818cc6b | ||
|
|
de67a7370c | ||
|
|
f684804821 | ||
|
|
e7432cb2a5 | ||
|
|
4aeace7a17 | ||
|
|
79b9e7447b | ||
|
|
c91948048b | ||
|
|
63ea3e5561 | ||
|
|
4c9c59ea2b | ||
|
|
d46ee6558c | ||
|
|
abcccce846 | ||
|
|
48a1497fc0 | ||
|
|
2e1747a145 | ||
|
|
4181f8d738 | ||
|
|
11d7267e33 | ||
|
|
82687ec95b | ||
|
|
9442b5a1b4 | ||
|
|
6cfe903127 | ||
|
|
f9779a14a5 |
@@ -56,7 +56,7 @@ jobs:
|
||||
filters: .github/ci-paths.yml
|
||||
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/changelog-utils.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/sync-release-notes-from-changelog.test.mjs scripts/upload-release-assets.test.mjs
|
||||
|
||||
quality:
|
||||
name: quality
|
||||
|
||||
+144
@@ -1,5 +1,149 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.2 - 2026-11-11
|
||||
|
||||
- **Background tasks:** Paseo now shows the background tasks an agent is running, such as shells and monitors, above the composer, with a Stop button for each. Works with Claude Code, Codex and OpenCode v2. ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **Plugin sidebar views:** A plugin can replace the sidebar's content with its own layout of workspaces, agents, projects and terminals across every connected host. Pick the view per device under Settings → Sidebar; Paseo's sidebar header and footer stay in place. ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
- **Plugin composer controls:** Plugins can add their own controls to the composer, and read and set its prompt, attachments, provider, model, mode and thinking. They can also put the cursor in any agent's composer, add items to New workspace, and store their own metadata on agents and workspaces. ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
|
||||
- **Faster diff loading:** Opening Changes shows the file tree right away while file contents load. Large diffs arrive in batches, so the rest of the app keeps responding while they load, and diffs take priority over the daemon's background worktree scans. ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
|
||||
- **Polish:** The chat stays still when a turn ends, and returning to a chat that has not changed shows it as it is with no refresh. Tool rows show their final title and icon from the first frame, the context window popover opens with usage already loaded, and sidebar hover cards open one at a time. ([#8944](https://github.com/getpaseo/paseo/pull/8944), [#9175](https://github.com/getpaseo/paseo/pull/9175), [#9242](https://github.com/getpaseo/paseo/pull/9242), [#9248](https://github.com/getpaseo/paseo/pull/9248), [#9212](https://github.com/getpaseo/paseo/pull/9212), [#9071](https://github.com/getpaseo/paseo/pull/9071))
|
||||
- **Rebuilt mobile chat timeline:** The chat timeline on iOS and Android was rebuilt. Expanding or collapsing a turn summary, todo card or tool-call group keeps the line you tapped in place, and on Android so do Show more, Show less and plan cards. ([#8717](https://github.com/getpaseo/paseo/pull/8717), [#9085](https://github.com/getpaseo/paseo/pull/9085))
|
||||
- **Keyboard shortcuts on mobile:** With a keyboard connected, iPad and Android now run the same shortcuts as desktop, including your custom bindings. Shortcut hints and Settings → Shortcuts appear when a keyboard is detected. Shortcuts also work in narrow desktop and browser windows. ([#8625](https://github.com/getpaseo/paseo/pull/8625) by [@jeroenfrenken](https://github.com/jeroenfrenken), [@lstroud](https://github.com/lstroud))
|
||||
- **Touch-sized tablet controls:** On iPad and other touch-screen devices, the wide layout now has touch-sized buttons, rows and bars. The composer toolbar folds its controls into the model menu when space runs out, and swiping right on the workspace reveals a collapsed sidebar as an overlay. ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739) by [@zeyugao](https://github.com/zeyugao))
|
||||
- **Undo workspace archive:** Archiving a workspace in the app now shows a "Workspace archived" toast for six seconds. Undo, or Cmd/Ctrl+Z, restores the workspace with its tabs and agents. On desktop, sidebar workspace rows also get an archive button on hover. ([#8904](https://github.com/getpaseo/paseo/pull/8904), [#9104](https://github.com/getpaseo/paseo/pull/9104))
|
||||
- **Fewer reconnects on return:** Returning to the app keeps a working connection, including one in the middle of a large response, and reconnects once when it has to. On a host with several saved connections, Paseo stays on the one that works. ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
|
||||
|
||||
### Added
|
||||
|
||||
- Added a Change base picker in Changes for worktree workspaces, so diffs, the commit list and Git actions can use another branch ([#8642](https://github.com/getpaseo/paseo/pull/8642), [#8934](https://github.com/getpaseo/paseo/pull/8934) by [@Myasuka](https://github.com/Myasuka))
|
||||
- Added links for issue and pull request references such as `#123` and `owner/repo#123` in agent replies, plans, the pull request panel and file previews, for GitHub, GitLab, Gitea, Forgejo and Codeberg ([#8636](https://github.com/getpaseo/paseo/pull/8636))
|
||||
- Added iPhone Duo layout support: workspace actions sit in the system vertical bar, and the sidebar and workspace split on either side of the fold ([#8741](https://github.com/getpaseo/paseo/pull/8741), [#8901](https://github.com/getpaseo/paseo/pull/8901) by [@zeyugao](https://github.com/zeyugao))
|
||||
- Added fx as a provider you can add from the provider catalog in Settings → Hosts → Providers ([#9160](https://github.com/getpaseo/paseo/pull/9160))
|
||||
- Added background agents: an agent or plugin can create a helper with `background: true` that opens no tab, sends no notification and stays out of the sidebar and default agent lists ([#8683](https://github.com/getpaseo/paseo/pull/8683))
|
||||
- Added syntax highlighting for Terraform and HCL files (`.tf`, `.tfvars`, `.hcl`) ([#9106](https://github.com/getpaseo/paseo/pull/9106))
|
||||
- Added `PASEO_WORKSPACE_ID` to each agent's environment so an agent can identify, rename or archive its own workspace ([#8939](https://github.com/getpaseo/paseo/pull/8939) by [@DTTerastar](https://github.com/DTTerastar))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed opening a workspace to restore the tab you left instead of jumping to the agent that needs attention ([#8935](https://github.com/getpaseo/paseo/pull/8935))
|
||||
- Changed question cards so picking an option no longer advances automatically; continue with Next or Submit ([#8525](https://github.com/getpaseo/paseo/pull/8525))
|
||||
- Changed the slash command menu to show loading and error states with Retry, and to stop selecting `/exit` by default ([#8905](https://github.com/getpaseo/paseo/pull/8905))
|
||||
- Changed the workspace header and tabs to switch to compact controls when Explorer or a split pane narrows the workspace ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088) by [@zeyugao](https://github.com/zeyugao))
|
||||
- Changed sending a message to a Pi agent whose process has exited to fail immediately with an instruction to reload the agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
|
||||
- Changed Rename workspace in the sidebar on desktop and wide web layouts to edit the title in place instead of opening a dialog ([#9280](https://github.com/getpaseo/paseo/pull/9280))
|
||||
- Changed What's new and the website changelog to show a release's highlights first, with the full list behind Show all changes ([#9281](https://github.com/getpaseo/paseo/pull/9281))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the working indicator disappearing when returning to a chat whose agent finished while you were away ([#9170](https://github.com/getpaseo/paseo/pull/9170))
|
||||
- Fixed chats with short collapsed turns opening with empty space until you scrolled ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
|
||||
- Fixed Collapse turns hiding an earlier reply inside a later turn's summary line after an idle Claude, Codex or OpenCode agent was resumed ([#9098](https://github.com/getpaseo/paseo/pull/9098))
|
||||
- Fixed the missing space between a prompt and the reply or collapsed-turn summary line below it ([#9009](https://github.com/getpaseo/paseo/pull/9009))
|
||||
- Fixed a long user message showing without its fade and Show more for one frame when a chat opened ([#9302](https://github.com/getpaseo/paseo/pull/9302))
|
||||
- Fixed Markdown tables showing as literal pipes when written directly below a paragraph with no blank line ([#8485](https://github.com/getpaseo/paseo/pull/8485) by [@ragokan](https://github.com/ragokan), [@suk-6](https://github.com/suk-6), [@thsrite](https://github.com/thsrite))
|
||||
- Fixed linked badge images that share a line with text rendering as full-width blocks on web and desktop ([#8530](https://github.com/getpaseo/paseo/pull/8530))
|
||||
- Fixed View diagram opening fullscreen instead of returning to the inline Mermaid diagram after View source ([#8932](https://github.com/getpaseo/paseo/pull/8932) by [@luoph](https://github.com/luoph))
|
||||
- Fixed mouse-wheel scrolling running in the opposite direction in chats on Android ([#8693](https://github.com/getpaseo/paseo/pull/8693) by [@sjeon87](https://github.com/sjeon87))
|
||||
- Fixed unsent composer text and attachments being deleted when an agent tab was closed ([#9079](https://github.com/getpaseo/paseo/pull/9079))
|
||||
- Fixed pasting text with hyperlinks into the composer dropping the link URLs; links now paste as Markdown ([#8972](https://github.com/getpaseo/paseo/pull/8972))
|
||||
- Fixed the composer losing focus after clicking Send with a mouse ([#9015](https://github.com/getpaseo/paseo/pull/9015))
|
||||
- Fixed the composer resting 50pt above the bottom edge on iPhone ([#8823](https://github.com/getpaseo/paseo/pull/8823) by [@zeyugao](https://github.com/zeyugao))
|
||||
- Fixed Add host sending you to Settings from the sidebar and New workspace ([#8512](https://github.com/getpaseo/paseo/pull/8512))
|
||||
- Fixed a failed agent's red dot and Failed grouping staying in the sidebar after opening the agent or choosing Mark as read ([#8831](https://github.com/getpaseo/paseo/pull/8831))
|
||||
- Fixed a workspace's unread indicator staying on after it was cleared while the agent's runtime was closed ([#9087](https://github.com/getpaseo/paseo/pull/9087))
|
||||
- Fixed unarchiving a workspace not restoring the agents that were archived with it ([#9210](https://github.com/getpaseo/paseo/pull/9210))
|
||||
- Fixed the context window popover closing by itself whenever the chat scrolled during a streaming reply ([#8742](https://github.com/getpaseo/paseo/pull/8742) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed the sidebar usage summary updating only when Usage was opened ([#7806](https://github.com/getpaseo/paseo/pull/7806) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed tooltips and menus above their trigger briefly covering it on Android when their content grew after opening ([#9247](https://github.com/getpaseo/paseo/pull/9247))
|
||||
- Fixed History showing "Could not load history" after reloading the web app on the History screen ([#9029](https://github.com/getpaseo/paseo/pull/9029))
|
||||
- Fixed section links in an HTML file preview replacing the preview with the Paseo app instead of scrolling within the file ([#8559](https://github.com/getpaseo/paseo/pull/8559) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed Files Refresh leaving a newly created file hidden when its folder was collapsed during the refresh ([#8582](https://github.com/getpaseo/paseo/pull/8582) by [@hadan8977](https://github.com/hadan8977))
|
||||
- Fixed the branch switcher showing "No branches found" while branches are still loading or after the request fails ([#8938](https://github.com/getpaseo/paseo/pull/8938))
|
||||
- Fixed commit history showing a doubled slash, no rename status and no diff for a file moved up a directory ([#8509](https://github.com/getpaseo/paseo/pull/8509) by [@hadan8977](https://github.com/hadan8977))
|
||||
- Fixed a merged pull request disappearing from its workspace when the checkout was behind the pull request's head
|
||||
- Fixed workspace Git watchers staying in fallback polling after a watcher setup timeout
|
||||
- Fixed abandoned connections staying open for up to 45 seconds after the web or desktop app reconnected ([#9067](https://github.com/getpaseo/paseo/pull/9067))
|
||||
- Fixed a connection routed through another host closing with "The receiving client cannot keep up with host output" while fetching a very large timeline ([#9109](https://github.com/getpaseo/paseo/pull/9109))
|
||||
- Fixed a connected host staying in error after it was updated to a supported version ([#8423](https://github.com/getpaseo/paseo/pull/8423) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed handshake retries and a once-per-second `relay_e2ee_channel_error` warning continuing after a connection attempt to an unreachable host had timed out ([#9025](https://github.com/getpaseo/paseo/pull/9025), [#9038](https://github.com/getpaseo/paseo/pull/9038))
|
||||
- Fixed app diagnostics reporting `Last online: never` for a host that had connected and then disconnected ([#9054](https://github.com/getpaseo/paseo/pull/9054), [#9058](https://github.com/getpaseo/paseo/pull/9058))
|
||||
- Fixed shell commands started by a Codex turn continuing to run after the turn is stopped or interrupted ([#7525](https://github.com/getpaseo/paseo/pull/7525) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed Claude turns that stop on an API error, such as a session limit or an invalid API key, being reported as completed instead of failed ([#8480](https://github.com/getpaseo/paseo/pull/8480) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed Claude processes staying in memory until the daemon restarted when an idle chat was reopened without sending a prompt ([#9019](https://github.com/getpaseo/paseo/pull/9019) by [@Brandon168](https://github.com/Brandon168))
|
||||
- Fixed Claude agents not resuming after a graceful daemon stop or restart when agent recovery is turned on ([#8686](https://github.com/getpaseo/paseo/pull/8686) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- Fixed the effort control missing in the composer for Claude models that come from Claude's `settings.json` ([#8699](https://github.com/getpaseo/paseo/pull/8699) by [@ragokan](https://github.com/ragokan), [@yahao123](https://github.com/yahao123))
|
||||
- Fixed importing a Claude session failing with `Invalid string length` when its transcript is very large ([#8457](https://github.com/getpaseo/paseo/pull/8457) by [@qwist1233-cpu](https://github.com/qwist1233-cpu))
|
||||
- Fixed Claude turns that end at the maximum-turn limit losing their final token counts and cost ([#8519](https://github.com/getpaseo/paseo/pull/8519))
|
||||
- Fixed Codex "Apply file changes" approvals showing no file name or diff before you accept ([#8585](https://github.com/getpaseo/paseo/pull/8585) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed the timeline showing only the first file's diff when Codex edits several files in one patch ([#8613](https://github.com/getpaseo/paseo/pull/8613) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed Codex session discovery and startup intermittently failing with a SQLite initialization error when several Codex operations start at once ([#9008](https://github.com/getpaseo/paseo/pull/9008) by [@colonelpanic8](https://github.com/colonelpanic8), [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Fixed Codex reconnect attempts and warnings not appearing in the timeline ([#9370](https://github.com/getpaseo/paseo/pull/9370) by [@whatamelon](https://github.com/whatamelon))
|
||||
- Fixed OpenCode conversations that were mid-turn during a daemon restart showing idle and ignoring Stop ([#8698](https://github.com/getpaseo/paseo/pull/8698))
|
||||
- Fixed OpenCode being told you declined a tool call when you sent a message while its approval was pending ([#8477](https://github.com/getpaseo/paseo/pull/8477))
|
||||
- Fixed reopened Pi conversations missing the messages from before a context compaction ([#8523](https://github.com/getpaseo/paseo/pull/8523))
|
||||
- Fixed archived Pi chats failing to open with `spawn pi ENOENT` after their working folder was removed ([#8442](https://github.com/getpaseo/paseo/pull/8442) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed reading an archived Pi chat leaving a Pi process running when idle runtime cleanup is disabled ([#8554](https://github.com/getpaseo/paseo/pull/8554) by [@rylwin](https://github.com/rylwin))
|
||||
- Fixed resumed pi-subagents runs missing from the Subagents track ([#9261](https://github.com/getpaseo/paseo/pull/9261) by [@xxachangxx](https://github.com/xxachangxx))
|
||||
- Fixed a prompt sent to an ACP agent whose process has exited staying in running forever instead of failing with an error ([#8406](https://github.com/getpaseo/paseo/pull/8406) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed an ACP agent staying stuck on "waiting for permission" after it abandoned an approval request and ended its turn ([#8548](https://github.com/getpaseo/paseo/pull/8548) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed changing the mode of an ACP agent failing with `invalid_params` after the agent sends a config update ([#8912](https://github.com/getpaseo/paseo/pull/8912) by [@a-sh](https://github.com/a-sh))
|
||||
- Fixed Paseo showing and saving an unavailable model after selecting it on a running ACP agent ([#8957](https://github.com/getpaseo/paseo/pull/8957))
|
||||
- Fixed importing a session switching it from its own model to the provider's default model ([#8948](https://github.com/getpaseo/paseo/pull/8948))
|
||||
- Fixed a message from another agent cancelling the recipient's pending question or approval before you could answer it ([#8453](https://github.com/getpaseo/paseo/pull/8453) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed an agent never being notified of completion after sending a prompt to a closed or archived agent on a connected host ([#8575](https://github.com/getpaseo/paseo/pull/8575))
|
||||
- Fixed creating a child agent failing with "Caller agent not found" after a daemon restart until the parent chat had been opened ([#8714](https://github.com/getpaseo/paseo/pull/8714))
|
||||
- Fixed an agent still showing as failed after it resumed from a provider error and completed its next turn ([#8083](https://github.com/getpaseo/paseo/pull/8083) by [@luoph](https://github.com/luoph))
|
||||
- Fixed a detached subagent still showing under its archived parent until refresh ([#8903](https://github.com/getpaseo/paseo/pull/8903) by [@luoph](https://github.com/luoph))
|
||||
- Fixed opening an agent failing with "Working directory does not exist" instead of showing its saved conversation ([#8737](https://github.com/getpaseo/paseo/pull/8737))
|
||||
- Fixed the daemon re-reading all archived agent and workspace history on every workspace update ([#8735](https://github.com/getpaseo/paseo/pull/8735))
|
||||
- Fixed the daemon repeatedly running Git commands in a workspace folder that was deleted while its file watcher was in fallback polling ([#8462](https://github.com/getpaseo/paseo/pull/8462) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed workspace service connections staying open after the browser tab closes or reloads ([#8421](https://github.com/getpaseo/paseo/pull/8421) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed the daemon web UI answering missing `/.well-known/*` requests with the app page instead of a 404 ([#8622](https://github.com/getpaseo/paseo/pull/8622) by [@HomicideZero](https://github.com/HomicideZero))
|
||||
- Fixed schedules accepting cron steps after a single number, such as `0/15 * * * *`, and running them hourly; they are now rejected ([#8607](https://github.com/getpaseo/paseo/pull/8607) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed cron schedules that set both day-of-month and day-of-week running only when both matched instead of when either matches ([#8611](https://github.com/getpaseo/paseo/pull/8611) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `paseo logs --json` printing the plain-text transcript instead of JSON ([#8589](https://github.com/getpaseo/paseo/pull/8589) by [@hadan8977](https://github.com/hadan8977))
|
||||
- Fixed the `create_agent` tool not telling agents that creating an agent on another provider needs a mode ([#8483](https://github.com/getpaseo/paseo/pull/8483) by [@guerinnoah](https://github.com/guerinnoah))
|
||||
- Fixed terminals failing to open on Windows machines where Windows PowerShell cannot run `Get-CimInstance` ([#8818](https://github.com/getpaseo/paseo/pull/8818))
|
||||
- Fixed the terminal typing its version reply into the shell when a program queries the terminal version ([#8419](https://github.com/getpaseo/paseo/pull/8419) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed a finger drag in the web terminal scrolling history about twice as far as the drag ([#8776](https://github.com/getpaseo/paseo/pull/8776) by [@sinomoe](https://github.com/sinomoe))
|
||||
- Fixed mouse scrolling in terminal programs such as Copilot CLI stopping after switching away from the terminal and back ([#9004](https://github.com/getpaseo/paseo/pull/9004) by [@imink](https://github.com/imink))
|
||||
- Fixed dropping several files onto a remote terminal pasting their paths with no space between them ([#8946](https://github.com/getpaseo/paseo/pull/8946) by [@sebastianarena](https://github.com/sebastianarena))
|
||||
- Fixed the `browser_fill` tool reporting success without updating React-controlled inputs and textareas ([#9022](https://github.com/getpaseo/paseo/pull/9022))
|
||||
- Fixed `browser_evaluate` failing on pages whose Content Security Policy omits `unsafe-eval` ([#8973](https://github.com/getpaseo/paseo/pull/8973) by [@amustapha](https://github.com/amustapha))
|
||||
- Fixed `browser_logs` omitting the HTTP status of each network request ([#8956](https://github.com/getpaseo/paseo/pull/8956) by [@amustapha](https://github.com/amustapha))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Added plugin subscriptions, which stream live events from the daemon to a plugin's panels, and binary (`Uint8Array`) payloads for plugin RPCs and events ([#8527](https://github.com/getpaseo/paseo/pull/8527))
|
||||
- Added an agent's background task count (`backgroundWork.count`) and `background` flag to plugin agent snapshots, and a `background` flag to agent hooks ([#9362](https://github.com/getpaseo/paseo/pull/9362))
|
||||
- Changed Settings → Plugins to open a plugin's settings from a cog button, with a tab per settings screen ([#8675](https://github.com/getpaseo/paseo/pull/8675))
|
||||
- Added an upward chevron to plugin composer pills that open a menu or popover ([#8673](https://github.com/getpaseo/paseo/pull/8673))
|
||||
- Changed daemon startup to launch configured plugins concurrently instead of one after another ([#8427](https://github.com/getpaseo/paseo/pull/8427) by [@DAnaeto](https://github.com/DAnaeto))
|
||||
- Fixed registry plugin installs failing with "npm artifact changed since review" when the host's npm uses a registry mirror ([#8756](https://github.com/getpaseo/paseo/pull/8756) by [@tlipoca9](https://github.com/tlipoca9))
|
||||
- Fixed a plugin the daemon terminated for an invalid message staying listed with no exit reported ([#9285](https://github.com/getpaseo/paseo/pull/9285) by [@Brandon168](https://github.com/Brandon168))
|
||||
- Fixed ACP provider plugins dropping the token usage an agent reports in its prompt response ([#9396](https://github.com/getpaseo/paseo/pull/9396) by [@Myasuka](https://github.com/Myasuka))
|
||||
- Fixed the published `@getpaseo/*` npm packages declaring no license; they now declare Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816) by [@mdlawson](https://github.com/mdlawson), [@ragokan](https://github.com/ragokan))
|
||||
- Fixed forking from an earlier reply failing with "This checkpoint changed after it was created" after a plugin updated one of its timeline cards ([#8746](https://github.com/getpaseo/paseo/pull/8746) by [@monarchmaisuriya](https://github.com/monarchmaisuriya))
|
||||
- Fixed Collapse turns leaving a turn open, or folding to an earlier reply, when a plugin renders the final reply ([#8811](https://github.com/getpaseo/paseo/pull/8811) by [@geoqiao](https://github.com/geoqiao))
|
||||
- Fixed plugin screens showing "This plugin surface is unavailable" while the plugin catalog is still loading after an app reload ([#8123](https://github.com/getpaseo/paseo/pull/8123) by [@vs-tahir](https://github.com/vs-tahir))
|
||||
- Fixed the Workspaces list being pushed out of the mobile sidebar on short screens when plugins add many navigation items ([#8727](https://github.com/getpaseo/paseo/pull/8727) by [@jaredwang](https://github.com/jaredwang))
|
||||
- Fixed timeline plugins not receiving the sender, event and original text on agent-message tool calls ([#8492](https://github.com/getpaseo/paseo/pull/8492) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed plugin RPCs failing when their input or output schema uses a transform ([#8550](https://github.com/getpaseo/paseo/pull/8550))
|
||||
|
||||
### Notes
|
||||
|
||||
- Background tasks, the Change base picker, archive undo, background agents, plugin subscriptions and fx need both the host and the app on this version
|
||||
|
||||
## 0.15.4 - 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort ([#9233](https://github.com/getpaseo/paseo/pull/9233) by [@gengjiawen](https://github.com/gengjiawen), [@gudcks](https://github.com/gudcks))
|
||||
|
||||
### Notes
|
||||
|
||||
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
|
||||
|
||||
## 0.15.3 - 2026-11-06
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,5 +1,149 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.2 - 2026-11-11
|
||||
|
||||
- **后台任务**:Paseo 现在会在 Composer 上方显示 Agent 正在运行的后台任务,例如 shell 和监视器,每项都带“停止”按钮。支持 Claude Code、Codex 和 OpenCode v2。 ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162),贡献者:[@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、Agent、项目和终端。在“设置 → 侧边栏”中为每台设备选择视图;Paseo 的侧边栏页眉和页脚保持不变。 ([#8906](https://github.com/getpaseo/paseo/pull/8906))
|
||||
- **插件 Composer 控件**:插件可以向 Composer 添加自己的控件,并读取和设置其中的提示词、附件、Provider、模型、模式和思考强度。插件还可以把光标放到任意 Agent 的 Composer 中、向“新建工作区”添加条目,并在 Agent 和工作区上保存自己的元数据。 ([#8744](https://github.com/getpaseo/paseo/pull/8744), [#9209](https://github.com/getpaseo/paseo/pull/9209), [#9207](https://github.com/getpaseo/paseo/pull/9207))
|
||||
- **更快的 diff 加载**:打开“更改”时会立即显示文件树,文件内容随后加载。大型 diff 分批到达,加载期间应用的其他部分照常响应,并且 diff 优先于 daemon 的后台 worktree 扫描。 ([#8907](https://github.com/getpaseo/paseo/pull/8907), [#9241](https://github.com/getpaseo/paseo/pull/9241), [#9243](https://github.com/getpaseo/paseo/pull/9243))
|
||||
- **细节打磨**:一轮对话结束时聊天保持不动,返回没有变化的聊天时直接显示原样,无需刷新。工具行从第一帧起就显示最终标题和图标,上下文窗口浮层打开时用量已加载完成,侧边栏悬停卡片一次只打开一个。 ([#8944](https://github.com/getpaseo/paseo/pull/8944), [#9175](https://github.com/getpaseo/paseo/pull/9175), [#9242](https://github.com/getpaseo/paseo/pull/9242), [#9248](https://github.com/getpaseo/paseo/pull/9248), [#9212](https://github.com/getpaseo/paseo/pull/9212), [#9071](https://github.com/getpaseo/paseo/pull/9071))
|
||||
- **重建移动端聊天时间线**:iOS 和 Android 上的聊天时间线已重建。展开或折叠轮次摘要、待办卡片或工具调用组时,你点按的那一行保持原位;在 Android 上,“显示更多”“收起”和计划卡片也是如此。 ([#8717](https://github.com/getpaseo/paseo/pull/8717), [#9085](https://github.com/getpaseo/paseo/pull/9085))
|
||||
- **移动端键盘快捷键**:连接键盘后,iPad 和 Android 现在可使用与桌面端相同的快捷键,包括你自定义的绑定。检测到键盘时会显示快捷键提示和“设置 → 快捷键”。在较窄的桌面端和浏览器窗口中快捷键同样可用。 ([#8625](https://github.com/getpaseo/paseo/pull/8625),贡献者:[@jeroenfrenken](https://github.com/jeroenfrenken)、[@lstroud](https://github.com/lstroud))
|
||||
- **适合触控的平板控件**:在 iPad 和其他触屏设备上,宽屏布局现在使用适合触控尺寸的按钮、行和栏。Composer 工具栏在空间不足时会把控件收进模型菜单;在工作区上向右滑动,可将已折叠的侧边栏以浮层方式显示。 ([#8748](https://github.com/getpaseo/paseo/pull/8748), [#8885](https://github.com/getpaseo/paseo/pull/8885), [#8891](https://github.com/getpaseo/paseo/pull/8891), [#8909](https://github.com/getpaseo/paseo/pull/8909), [#9151](https://github.com/getpaseo/paseo/pull/9151), [#9238](https://github.com/getpaseo/paseo/pull/9238), [#8739](https://github.com/getpaseo/paseo/pull/8739),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- **撤销工作区归档**:在应用中归档工作区时,现在会显示“Workspace 已归档”提示,持续六秒。点击“撤销”或按 Cmd/Ctrl+Z 可恢复该工作区及其标签页和 Agent。在桌面端,侧边栏的工作区行在悬停时还会显示归档按钮。 ([#8904](https://github.com/getpaseo/paseo/pull/8904), [#9104](https://github.com/getpaseo/paseo/pull/9104))
|
||||
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,Paseo 会留在可用的那个连接上。 ([#8651](https://github.com/getpaseo/paseo/pull/8651), [#9086](https://github.com/getpaseo/paseo/pull/9086), [#8855](https://github.com/getpaseo/paseo/pull/8855), [#9116](https://github.com/getpaseo/paseo/pull/9116))
|
||||
|
||||
### 新增
|
||||
|
||||
- 在“更改”中为 worktree 工作区新增基准分支选择器(Change base),diff、提交列表和 Git 操作可改用其他分支 ([#8642](https://github.com/getpaseo/paseo/pull/8642), [#8934](https://github.com/getpaseo/paseo/pull/8934),贡献者:[@Myasuka](https://github.com/Myasuka))
|
||||
- 新增 Issue 和 PR 引用链接:Agent 回复、计划、PR 面板和文件预览中的 `#123`、`owner/repo#123` 等引用会链接到 GitHub、GitLab、Gitea、Forgejo 或 Codeberg ([#8636](https://github.com/getpaseo/paseo/pull/8636))
|
||||
- 新增 iPhone Duo 布局支持:工作区操作位于系统竖向栏中,侧边栏和工作区分列折痕两侧 ([#8741](https://github.com/getpaseo/paseo/pull/8741), [#8901](https://github.com/getpaseo/paseo/pull/8901),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- 新增 fx Provider,可在“设置 → Hosts → 提供方”的 Provider 目录中添加 ([#9160](https://github.com/getpaseo/paseo/pull/9160))
|
||||
- 新增后台 Agent:Agent 或插件可通过 `background: true` 创建辅助 Agent,它不打开标签页、不发送通知,也不出现在侧边栏和默认 Agent 列表中 ([#8683](https://github.com/getpaseo/paseo/pull/8683))
|
||||
- 新增 Terraform 和 HCL 文件(`.tf`、`.tfvars`、`.hcl`)的语法高亮 ([#9106](https://github.com/getpaseo/paseo/pull/9106))
|
||||
- 在每个 Agent 的环境中新增 `PASEO_WORKSPACE_ID`,Agent 可据此识别、重命名或归档自己的工作区 ([#8939](https://github.com/getpaseo/paseo/pull/8939),贡献者:[@DTTerastar](https://github.com/DTTerastar))
|
||||
|
||||
### 变更
|
||||
|
||||
- 打开工作区时恢复你离开时的标签页,不再跳转到需要关注的 Agent ([#8935](https://github.com/getpaseo/paseo/pull/8935))
|
||||
- 问题卡片中选择选项后不再自动前进;请用“下一步”或“提交”继续 ([#8525](https://github.com/getpaseo/paseo/pull/8525))
|
||||
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/exit` ([#8905](https://github.com/getpaseo/paseo/pull/8905))
|
||||
- 文件浏览器(Explorer)或分屏使工作区变窄时,工作区页眉和标签页改用紧凑控件 ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- 向进程已退出的 Pi Agent 发送消息时立即失败,并提示重新加载该 Agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
|
||||
- 在桌面端和宽屏网页布局中,侧边栏的“重命名 workspace”改为在原处编辑标题,不再打开对话框 ([#9280](https://github.com/getpaseo/paseo/pull/9280))
|
||||
- “更新内容”和网站更新日志改为先显示每个版本的亮点,完整列表收在“显示全部变更”之后 ([#9281](https://github.com/getpaseo/paseo/pull/9281))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Agent 在你离开期间完成后,返回聊天时工作指示器消失的问题 ([#9170](https://github.com/getpaseo/paseo/pull/9170))
|
||||
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
|
||||
- 修复空闲的 Claude、Codex 或 OpenCode Agent 恢复后,“折叠轮次”把较早的回复藏进后一轮摘要行的问题 ([#9098](https://github.com/getpaseo/paseo/pull/9098))
|
||||
- 修复提示词与其下方的回复或折叠轮次摘要行之间缺少间距的问题 ([#9009](https://github.com/getpaseo/paseo/pull/9009))
|
||||
- 修复聊天打开时,较长的用户消息有一帧不显示渐隐效果和“展开”的问题 ([#9302](https://github.com/getpaseo/paseo/pull/9302))
|
||||
- 修复紧跟在段落之后、中间没有空行的 Markdown 表格显示为原始竖线的问题 ([#8485](https://github.com/getpaseo/paseo/pull/8485),贡献者:[@ragokan](https://github.com/ragokan)、[@suk-6](https://github.com/suk-6)、[@thsrite](https://github.com/thsrite))
|
||||
- 修复网页版和桌面端中与文字同行的带链接徽章图片被渲染成整行宽度的问题 ([#8530](https://github.com/getpaseo/paseo/pull/8530))
|
||||
- 修复“查看源码”后点击“查看图表”会打开全屏、而不是回到内联 Mermaid 图表的问题 ([#8932](https://github.com/getpaseo/paseo/pull/8932),贡献者:[@luoph](https://github.com/luoph))
|
||||
- 修复 Android 上聊天中鼠标滚轮滚动方向相反的问题 ([#8693](https://github.com/getpaseo/paseo/pull/8693),贡献者:[@sjeon87](https://github.com/sjeon87))
|
||||
- 修复关闭 Agent 标签页时未发送的 Composer 文本和附件被删除的问题 ([#9079](https://github.com/getpaseo/paseo/pull/9079))
|
||||
- 修复向 Composer 粘贴带超链接的文本时丢失链接地址的问题;链接现在以 Markdown 形式粘贴 ([#8972](https://github.com/getpaseo/paseo/pull/8972))
|
||||
- 修复用鼠标点击“发送”后 Composer 失去焦点的问题 ([#9015](https://github.com/getpaseo/paseo/pull/9015))
|
||||
- 修复 iPhone 上 Composer 停在距底部边缘 50pt 处的问题 ([#8823](https://github.com/getpaseo/paseo/pull/8823),贡献者:[@zeyugao](https://github.com/zeyugao))
|
||||
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#8512](https://github.com/getpaseo/paseo/pull/8512))
|
||||
- 修复打开失败的 Agent 或选择 Mark as read 后,其红点和“失败”分组仍留在侧边栏的问题 ([#8831](https://github.com/getpaseo/paseo/pull/8831))
|
||||
- 修复在 Agent 运行时已关闭的情况下清除未读后,工作区的未读指示仍然亮着的问题 ([#9087](https://github.com/getpaseo/paseo/pull/9087))
|
||||
- 修复取消归档工作区时未恢复随之归档的 Agent 的问题 ([#9210](https://github.com/getpaseo/paseo/pull/9210))
|
||||
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#8742](https://github.com/getpaseo/paseo/pull/8742),贡献者:[@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复侧边栏用量摘要只在打开“使用情况”时才更新的问题 ([#7806](https://github.com/getpaseo/paseo/pull/7806),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复 Android 上位于触发元素上方的提示和菜单在打开后内容变多时短暂盖住触发元素的问题 ([#9247](https://github.com/getpaseo/paseo/pull/9247))
|
||||
- 修复在“历史”页面刷新网页版后显示“无法加载历史”的问题 ([#9029](https://github.com/getpaseo/paseo/pull/9029))
|
||||
- 修复 HTML 文件预览中的章节链接把预览替换成 Paseo 应用、而不是在文件内滚动的问题 ([#8559](https://github.com/getpaseo/paseo/pull/8559),贡献者:[@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复“文件”刷新期间文件夹处于折叠状态时,新建的文件一直不显示的问题 ([#8582](https://github.com/getpaseo/paseo/pull/8582),贡献者:[@hadan8977](https://github.com/hadan8977))
|
||||
- 修复分支仍在加载或请求失败时,分支切换器显示“没有找到分支。”的问题 ([#8938](https://github.com/getpaseo/paseo/pull/8938))
|
||||
- 修复文件被移到上一级目录时,提交历史显示双斜杠、没有重命名状态也没有 diff 的问题 ([#8509](https://github.com/getpaseo/paseo/pull/8509),贡献者:[@hadan8977](https://github.com/hadan8977))
|
||||
- 修复检出落后于 PR 头部时,已合并的 PR 从其工作区中消失的问题
|
||||
- 修复工作区 Git 监视器在设置超时后一直停留在回退轮询的问题
|
||||
- 修复网页版或桌面端重连后,被放弃的连接最多保持打开 45 秒的问题 ([#9067](https://github.com/getpaseo/paseo/pull/9067))
|
||||
- 修复通过另一台主机路由的连接在获取超大时间线时以“The receiving client cannot keep up with host output”关闭的问题 ([#9109](https://github.com/getpaseo/paseo/pull/9109))
|
||||
- 修复已连接的主机在更新到受支持的版本后仍停留在错误状态的问题 ([#8423](https://github.com/getpaseo/paseo/pull/8423),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复连接不可达主机的尝试超时后,握手重试和每秒一次的 `relay_e2ee_channel_error` 警告仍在继续的问题 ([#9025](https://github.com/getpaseo/paseo/pull/9025), [#9038](https://github.com/getpaseo/paseo/pull/9038))
|
||||
- 修复应用诊断对曾经连接后又断开的主机报告 `Last online: never` 的问题 ([#9054](https://github.com/getpaseo/paseo/pull/9054), [#9058](https://github.com/getpaseo/paseo/pull/9058))
|
||||
- 修复 Codex 轮次被停止或中断后,该轮次启动的 shell 命令仍继续运行的问题 ([#7525](https://github.com/getpaseo/paseo/pull/7525),贡献者:[@thomasvan](https://github.com/thomasvan))
|
||||
- 修复因 API 错误(例如会话限额或 API key 无效)而停止的 Claude 轮次被报告为已完成而非失败的问题 ([#8480](https://github.com/getpaseo/paseo/pull/8480),贡献者:[@RRD-dev](https://github.com/RRD-dev)、[@luoph](https://github.com/luoph))
|
||||
- 修复重新打开空闲聊天但未发送提示词时,Claude 进程一直留在内存中直到 daemon 重启的问题 ([#9019](https://github.com/getpaseo/paseo/pull/9019),贡献者:[@Brandon168](https://github.com/Brandon168))
|
||||
- 修复开启 Agent 恢复后,daemon 正常停止或重启时 Claude Agent 不恢复的问题 ([#8686](https://github.com/getpaseo/paseo/pull/8686),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- 修复来自 Claude `settings.json` 的 Claude 模型在 Composer 中缺少推理强度控件的问题 ([#8699](https://github.com/getpaseo/paseo/pull/8699),贡献者:[@ragokan](https://github.com/ragokan)、[@yahao123](https://github.com/yahao123))
|
||||
- 修复对话记录非常大时导入 Claude 会话因 `Invalid string length` 失败的问题 ([#8457](https://github.com/getpaseo/paseo/pull/8457),贡献者:[@qwist1233-cpu](https://github.com/qwist1233-cpu))
|
||||
- 修复在最大轮次限制处结束的 Claude 轮次丢失最终 token 数和费用的问题 ([#8519](https://github.com/getpaseo/paseo/pull/8519))
|
||||
- 修复 Codex 的“Apply file changes”审批在你接受之前不显示文件名和 diff 的问题 ([#8585](https://github.com/getpaseo/paseo/pull/8585),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复 Codex 在一个补丁中编辑多个文件时,时间线只显示第一个文件的 diff 的问题 ([#8613](https://github.com/getpaseo/paseo/pull/8613),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 修复多个 Codex 操作同时启动时,Codex 会话发现和启动偶发 SQLite 初始化错误的问题 ([#9008](https://github.com/getpaseo/paseo/pull/9008),贡献者:[@colonelpanic8](https://github.com/colonelpanic8)、[@michaelmwu](https://github.com/michaelmwu))
|
||||
- 修复 Codex 的重连尝试和警告未显示在时间线中的问题 ([#9370](https://github.com/getpaseo/paseo/pull/9370),贡献者:[@whatamelon](https://github.com/whatamelon))
|
||||
- 修复 daemon 重启时正处于轮次中的 OpenCode 对话显示为空闲并忽略“停止”的问题 ([#8698](https://github.com/getpaseo/paseo/pull/8698))
|
||||
- 修复审批待处理期间你发送消息时,OpenCode 被告知你拒绝了工具调用的问题 ([#8477](https://github.com/getpaseo/paseo/pull/8477))
|
||||
- 修复重新打开的 Pi 对话缺少上下文压缩之前的消息的问题 ([#8523](https://github.com/getpaseo/paseo/pull/8523))
|
||||
- 修复工作文件夹被移除后,已归档的 Pi 聊天因 `spawn pi ENOENT` 无法打开的问题 ([#8442](https://github.com/getpaseo/paseo/pull/8442),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复关闭空闲运行时清理的情况下,读取已归档的 Pi 聊天会遗留一个 Pi 进程的问题 ([#8554](https://github.com/getpaseo/paseo/pull/8554),贡献者:[@rylwin](https://github.com/rylwin))
|
||||
- 修复恢复的 pi-subagents 运行未出现在 Subagent 栏中的问题 ([#9261](https://github.com/getpaseo/paseo/pull/9261),贡献者:[@xxachangxx](https://github.com/xxachangxx))
|
||||
- 修复向进程已退出的 ACP Agent 发送提示词后一直停留在运行中、而不是报错失败的问题 ([#8406](https://github.com/getpaseo/paseo/pull/8406),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复 ACP Agent 放弃审批请求并结束轮次后一直卡在“waiting for permission”的问题 ([#8548](https://github.com/getpaseo/paseo/pull/8548),贡献者:[@3ae3ae](https://github.com/3ae3ae))
|
||||
- 修复 ACP Agent 发送配置更新后,更改其模式因 `invalid_params` 失败的问题 ([#8912](https://github.com/getpaseo/paseo/pull/8912),贡献者:[@a-sh](https://github.com/a-sh))
|
||||
- 修复在运行中的 ACP Agent 上选择不可用的模型后,Paseo 仍显示并保存该模型的问题 ([#8957](https://github.com/getpaseo/paseo/pull/8957))
|
||||
- 修复导入会话时把它从自身的模型切换为 Provider 默认模型的问题 ([#8948](https://github.com/getpaseo/paseo/pull/8948))
|
||||
- 修复来自其他 Agent 的消息在你作答之前取消接收方待处理的提问或审批的问题 ([#8453](https://github.com/getpaseo/paseo/pull/8453),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复向已连接主机上已关闭或已归档的 Agent 发送提示词后,发送方 Agent 一直收不到完成通知的问题 ([#8575](https://github.com/getpaseo/paseo/pull/8575))
|
||||
- 修复 daemon 重启后,在打开父聊天之前创建子 Agent 因“Caller agent not found”失败的问题 ([#8714](https://github.com/getpaseo/paseo/pull/8714))
|
||||
- 修复 Agent 从 Provider 错误中恢复并完成下一轮次后仍显示为失败的问题 ([#8083](https://github.com/getpaseo/paseo/pull/8083),贡献者:[@luoph](https://github.com/luoph))
|
||||
- 修复已分离的子 Agent 在刷新之前仍显示在其已归档的父 Agent 之下的问题 ([#8903](https://github.com/getpaseo/paseo/pull/8903),贡献者:[@luoph](https://github.com/luoph))
|
||||
- 修复打开 Agent 时因“Working directory does not exist”失败、而不是显示其已保存对话的问题 ([#8737](https://github.com/getpaseo/paseo/pull/8737))
|
||||
- 修复 daemon 在每次工作区更新时重新读取全部已归档 Agent 和工作区历史的问题 ([#8735](https://github.com/getpaseo/paseo/pull/8735))
|
||||
- 修复工作区文件夹在其文件监视器处于回退轮询期间被删除后,daemon 仍反复在其中运行 Git 命令的问题 ([#8462](https://github.com/getpaseo/paseo/pull/8462),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复浏览器标签页关闭或刷新后,工作区服务连接仍保持打开的问题 ([#8421](https://github.com/getpaseo/paseo/pull/8421),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复 daemon 网页界面对不存在的 `/.well-known/*` 请求返回应用页面而非 404 的问题 ([#8622](https://github.com/getpaseo/paseo/pull/8622),贡献者:[@HomicideZero](https://github.com/HomicideZero))
|
||||
- 修复定时任务接受单个数字后的 cron 步长(例如 `0/15 * * * *`)并按每小时运行的问题;现在会拒绝这类表达式 ([#8607](https://github.com/getpaseo/paseo/pull/8607),贡献者:[@Mar-garet](https://github.com/Mar-garet))
|
||||
- 修复同时设置了日期和星期的 cron 定时任务只在两者都匹配时运行、而不是任一匹配即运行的问题 ([#8611](https://github.com/getpaseo/paseo/pull/8611),贡献者:[@Mar-garet](https://github.com/Mar-garet))
|
||||
- 修复 `paseo logs --json` 输出纯文本对话记录而非 JSON 的问题 ([#8589](https://github.com/getpaseo/paseo/pull/8589),贡献者:[@hadan8977](https://github.com/hadan8977))
|
||||
- 修复 `create_agent` 工具没有告知 Agent 在其他 Provider 上创建 Agent 需要指定模式的问题 ([#8483](https://github.com/getpaseo/paseo/pull/8483),贡献者:[@guerinnoah](https://github.com/guerinnoah))
|
||||
- 修复在 Windows PowerShell 无法运行 `Get-CimInstance` 的 Windows 机器上终端无法打开的问题 ([#8818](https://github.com/getpaseo/paseo/pull/8818))
|
||||
- 修复程序查询终端版本时,终端把版本应答输入到 shell 中的问题 ([#8419](https://github.com/getpaseo/paseo/pull/8419),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复在网页版终端中用手指拖动时,历史滚动距离约为拖动距离两倍的问题 ([#8776](https://github.com/getpaseo/paseo/pull/8776),贡献者:[@sinomoe](https://github.com/sinomoe))
|
||||
- 修复切换离开终端再返回后,Copilot CLI 等终端程序中的鼠标滚动失效的问题 ([#9004](https://github.com/getpaseo/paseo/pull/9004),贡献者:[@imink](https://github.com/imink))
|
||||
- 修复向远程终端拖入多个文件时,粘贴的路径之间没有空格的问题 ([#8946](https://github.com/getpaseo/paseo/pull/8946),贡献者:[@sebastianarena](https://github.com/sebastianarena))
|
||||
- 修复 `browser_fill` 工具在未更新 React 受控输入框和文本域的情况下报告成功的问题 ([#9022](https://github.com/getpaseo/paseo/pull/9022))
|
||||
- 修复 `browser_evaluate` 在内容安全策略未包含 `unsafe-eval` 的页面上失败的问题 ([#8973](https://github.com/getpaseo/paseo/pull/8973),贡献者:[@amustapha](https://github.com/amustapha))
|
||||
- 修复 `browser_logs` 缺少每个网络请求的 HTTP 状态的问题 ([#8956](https://github.com/getpaseo/paseo/pull/8956),贡献者:[@amustapha](https://github.com/amustapha))
|
||||
|
||||
### 插件
|
||||
|
||||
- 新增插件订阅,可将 daemon 的实时事件流式推送到插件面板;插件 RPC 和事件支持二进制(`Uint8Array`)负载 ([#8527](https://github.com/getpaseo/paseo/pull/8527))
|
||||
- 插件的 Agent 快照新增后台任务数量(`backgroundWork.count`)和 `background` 标记,Agent 钩子新增 `background` 标记 ([#9362](https://github.com/getpaseo/paseo/pull/9362))
|
||||
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#8675](https://github.com/getpaseo/paseo/pull/8675))
|
||||
- 为会打开菜单或浮层的插件 Composer 胶囊按钮新增向上的箭头 ([#8673](https://github.com/getpaseo/paseo/pull/8673))
|
||||
- daemon 启动时改为并发启动已配置的插件,不再逐个启动 ([#8427](https://github.com/getpaseo/paseo/pull/8427),贡献者:[@DAnaeto](https://github.com/DAnaeto))
|
||||
- 修复主机的 npm 使用镜像源时,从插件库安装插件因“npm artifact changed since review”失败的问题 ([#8756](https://github.com/getpaseo/paseo/pull/8756),贡献者:[@tlipoca9](https://github.com/tlipoca9))
|
||||
- 修复 daemon 因无效消息终止插件后,该插件仍留在列表中且未报告退出的问题 ([#9285](https://github.com/getpaseo/paseo/pull/9285),贡献者:[@Brandon168](https://github.com/Brandon168))
|
||||
- 修复 ACP Provider 插件丢弃 Agent 在提示词响应中报告的 token 用量的问题 ([#9396](https://github.com/getpaseo/paseo/pull/9396),贡献者:[@Myasuka](https://github.com/Myasuka))
|
||||
- 修复已发布的 `@getpaseo/*` npm 包未声明许可证的问题;现在声明为 Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816),贡献者:[@mdlawson](https://github.com/mdlawson)、[@ragokan](https://github.com/ragokan))
|
||||
- 修复插件更新了自己的某张时间线卡片后,从较早的回复分叉因“This checkpoint changed after it was created”失败的问题 ([#8746](https://github.com/getpaseo/paseo/pull/8746),贡献者:[@monarchmaisuriya](https://github.com/monarchmaisuriya))
|
||||
- 修复由插件渲染最终回复时,“折叠轮次”让轮次保持展开或折叠到较早回复的问题 ([#8811](https://github.com/getpaseo/paseo/pull/8811),贡献者:[@geoqiao](https://github.com/geoqiao))
|
||||
- 修复应用刷新后插件目录仍在加载时,插件页面显示“This plugin surface is unavailable”的问题 ([#8123](https://github.com/getpaseo/paseo/pull/8123),贡献者:[@vs-tahir](https://github.com/vs-tahir))
|
||||
- 修复插件添加了许多导航项时,矮屏上的工作区列表被挤出移动端侧边栏的问题 ([#8727](https://github.com/getpaseo/paseo/pull/8727),贡献者:[@jaredwang](https://github.com/jaredwang))
|
||||
- 修复时间线插件在 Agent 消息工具调用上收不到发送方、事件和原始文本的问题 ([#8492](https://github.com/getpaseo/paseo/pull/8492),贡献者:[@RRD-dev](https://github.com/RRD-dev)、[@luoph](https://github.com/luoph))
|
||||
- 修复输入或输出 schema 使用 transform 时插件 RPC 失败的问题 ([#8550](https://github.com/getpaseo/paseo/pull/8550))
|
||||
|
||||
### 说明
|
||||
|
||||
- 后台任务、基准分支选择器、归档撤销、后台 Agent、插件订阅和 fx 需要主机和应用都更新到此版本
|
||||
|
||||
## 0.15.4 - 2026-11-10
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 Claude Fable 5.2 和 Mythos 5.2,默认推理强度为 medium ([#9233](https://github.com/getpaseo/paseo/pull/9233),贡献者:[@gengjiawen](https://github.com/gengjiawen)、[@gudcks](https://github.com/gudcks))
|
||||
|
||||
### 说明
|
||||
|
||||
- 两个模型均需 Claude Code 2.1.321 或更高版本;仅在 Claude Code 提供访问权限时显示 Mythos 5.2
|
||||
|
||||
## 0.15.3 - 2026-11-06
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -257,7 +257,7 @@ Workspace archive is a separate lifecycle. Archiving or removing a worktree can
|
||||
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
|
||||
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
the route exposes explicit Unarchive and Restore actions.
|
||||
|
||||
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
|
||||
The daemon runs one archive lane per workspace, shared by every connection
|
||||
@@ -281,9 +281,12 @@ does not flip it back. Tabs and layout are purged only once the undo window clos
|
||||
archive committed.
|
||||
|
||||
History navigation opens the selected agent without changing either archive state. Workspace
|
||||
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
|
||||
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
|
||||
history hydration. Other archived agents stay archived.
|
||||
**Unarchive**, **Restore**, Undo, and reopening the directory recover the agents closed by that
|
||||
workspace archive, including
|
||||
after a daemon restart or after the toast expires. Agents archived individually beforehand stay
|
||||
archived. A failed worktree recreation leaves the workspace and its agents archived for retry.
|
||||
Each recovered agent runs the provider's native unarchive hook before interactive resume and
|
||||
history hydration.
|
||||
|
||||
Opening an agent is a navigation choice, independent of whether its details are cached. The
|
||||
layout retains that choice across reload while the panel fetches the agent from the daemon.
|
||||
|
||||
@@ -39,6 +39,19 @@ The trigger can only stay put when the list has scroll room for the change. When
|
||||
content fits the viewport, or a collapse at the end would need an offset past the end, the list
|
||||
ends at its end and the trigger moves by the difference.
|
||||
|
||||
## State that survives a remount
|
||||
|
||||
The list mounts a row again whenever it opens the chat, brings a web row back into the history
|
||||
window, or reassigns a native row's LegendList container. State held in the row resets each time.
|
||||
Keep a disclosure's open state in the per-agent store the timeline provides, keyed by item id:
|
||||
`ToolCallExpansionContext` for tool calls, `UserMessageExpansionContext` for long user messages.
|
||||
|
||||
Decide a row's first painted shape in the commit that mounts it. `MessagePreview` measures its body
|
||||
in a layout effect, which commits before paint on web and Fabric. `onLayout` lands after the first
|
||||
frame: a long message measured there paints capped with no fade or show more, then grows by the
|
||||
control's height a frame later. `onLayout` still tracks later size changes. A zero height comes from
|
||||
a hidden panel and is ignored.
|
||||
|
||||
## Web
|
||||
|
||||
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
|
||||
|
||||
@@ -112,6 +112,15 @@ need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
|
||||
|
||||
With an emulator or device connected, run the hardware keyboard's window-dispatch regression tests
|
||||
from `packages/app/android`:
|
||||
|
||||
```bash
|
||||
./gradlew :paseo-hardware-keyboard:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
These check terminal arrow ownership, held-key repeats and modifiers, and ordinary text-field editing.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
|
||||
|
||||
@@ -340,6 +340,14 @@ the number of Git processes that have started but not exited. Every Git command
|
||||
including initial workspace reads, filesystem-triggered refreshes, background checks, and explicit
|
||||
requests.
|
||||
|
||||
Commands wait in one of three lanes, drained in order: `high` (worktree creation and archive),
|
||||
`normal` (everything a caller waits for, including diffs and explicit refreshes), and `background`
|
||||
(workspace reconciliation, refreshes triggered by watchers, polls and fetches, and the fetches
|
||||
themselves). Daemon-initiated fan-out must run in the background lane: one rescan over ~90 worktrees
|
||||
queues over 1,000 commands, and in the normal lane every command a cold diff runs waited behind
|
||||
them. Set the lane on the runner with `createRunGitCommand(provenance, { priority })`, or around a
|
||||
whole operation with `runWithGitCommandPriority`.
|
||||
|
||||
Environment variables override `config.json`:
|
||||
|
||||
| Environment variable | Setting |
|
||||
|
||||
+15
-13
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which uses React Native’s press lifecycle on native with a short press delay so scrolling cancels pending feedback. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/components/settings/headings/settings-section.tsx`). A docs link in its header is its `link` prop, which renders `Label ↗` on the title's rail; a ghost button with an arrow is wrong. A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -40,9 +40,11 @@ Weight has three tiers, applied by role:
|
||||
|
||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text. The composer's empty placeholder is quieter; it uses `foregroundExtraMuted`.
|
||||
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control, and for the composer's empty placeholder. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
|
||||
|
||||
A theme states places and identity; `packages/app/src/styles/derive-colors.ts` derives their relationships. Sidebar hover and selected fills step from the sidebar toward the foreground. For plugin themes, passive text on those states derives against the actual state surface; page tertiary text is a separate relationship. Explicit plugin semantic overrides take precedence over derived defaults. Built-in themes retain their chosen passive text. `packages/app/src/styles/theme-contrast.test.ts` checks the built-in and representative plugin palettes against contrast floors.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
@@ -70,9 +72,9 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Touch-sized toolbar glyphs are 20pt at the app's 1.5px stroke. An icon-only control's glyph matches the header icons and rests at `foreground`; a glyph beside a label rests at `foregroundMuted` so the two read as one quiet unit (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
|
||||
|
||||
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`).
|
||||
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). Touch-sized the disc keeps a 6pt clearance from its neighbour, because it is always painted and a pressed neighbour would merge with it. The attach button sits 2pt closer to the picker than its highlight would put it: the plus is all thin arms, so the full gap reads wide. That offset is set by eye.
|
||||
|
||||
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
|
||||
|
||||
@@ -130,7 +132,7 @@ A section or group explains itself through the `info` prop on `<SettingsSection>
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. A row that opens something (`<SettingsDetailRow>`, a collapsible row's header) is at least as tall as one holding a default control, so a list where only some of those rows carry a switch keeps one row height. Other rows take their content's height. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `sm` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
|
||||
@@ -144,7 +146,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl
|
||||
|
||||
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
|
||||
|
||||
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
|
||||
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and touch-sized the end highlights overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
|
||||
|
||||
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
|
||||
|
||||
@@ -203,11 +205,11 @@ Terminology:
|
||||
|
||||
## 11. States
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` leads the line it relates to, never trails a label: a settings row's hint takes `hintStatus="busy"` while its work runs, and `hintStatus="pending"` holds the line with a skeleton until its value is read (`packages/app/src/components/settings/row-header.tsx`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Screen-level empty states use a centered, muted noun phrase and at most one ghost button (`packages/app/src/screens/sessions-screen.tsx`). Sidebar list empty states use a quiet bordered card with a left-aligned `sm` title, a muted `sm` description, and `sm` `<Button>` actions. Use `secondary` for the primary action and `outline` for a secondary action (`packages/app/src/components/sidebar/empty-states.tsx`). Keep both forms short and free of illustrations.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/components/ui/form-field.tsx`). A settings row that is broken says so in its hint with `hintStatus="error"`: a red dot and the error in `statusDanger`.
|
||||
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
|
||||
@@ -229,13 +231,13 @@ Changing state must not move the layout. A row that grows when its badge arrives
|
||||
|
||||
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
||||
|
||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. A fixed right chevron means navigation. An expandable row uses a trailing right chevron that rotates down when expanded (`SettingsCollapsibleRow`); pressing the row reveals its child rows in place.
|
||||
A trailing chevron opens a detail, as a page or a sheet (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the pressable; use `<SettingsDetailRow>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx`), provider row (`packages/app/src/provider-settings/internal/provider-row.tsx`), 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`).
|
||||
|
||||
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
|
||||
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles passes its `<Switch>` as the `control` of `<SettingsDetailRow>`, before the chevron; the switch stops the press from reaching the row (`packages/app/src/provider-settings/internal/provider-row.tsx`). A list the user adds to ends with a muted `<SettingsAddRow>`. Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
|
||||
|
||||
@@ -267,7 +269,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`, except the composer's empty placeholder, which uses `foregroundExtraMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
@@ -284,7 +286,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
| Pattern | Reference |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/provider-settings/internal/provider-list.tsx` |
|
||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||
|
||||
@@ -8,7 +8,7 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
|
||||
|
||||
`unsubscribe()` is an awaited barrier. Once it resolves, no scan, reconciliation, queued event, native handle, or callback may remain. `updateIgnore()` is the equivalent barrier for excluded paths: events queued below a newly excluded root cannot be delivered after it resolves. Treat path disappearance while scanning, attaching, or receiving a watcher error as rename/archive churn. Surface permission and resource errors so WorkspaceGit can enter bounded polling.
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. A subscription reports changes after `subscribe()` resolves, not the files that already existed. macOS FSEvents can still replay writes made moments before the stream started. Consumers read their initial state after `subscribe()` resolves; WorkspaceGit re-reads its checkout once observation setup completes, because its first refresh runs in parallel with setup. The Linux startup scan therefore emits nothing; only a scoped reconciliation of new topology reports files found before their directory's watcher was installed. Startup creates made every cold diff compute and discard a first revision. Benchmark startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
|
||||
|
||||
|
||||
@@ -446,6 +446,13 @@ Before you write a new one, ask:
|
||||
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
|
||||
`contentSize` round-trip entirely. **But only if the height is genuinely
|
||||
bounded**. Verify before you commit.
|
||||
6. **Can the content change size after the panel paints, and does the panel
|
||||
sit above its anchor?** Hold it by its bottom edge with `holdSurfaceEdge`
|
||||
and `surfaceFrameStyles` from `components/ui/anchor.ts`. Native delivers
|
||||
`onLayout` after the frame that shows the new size, so a panel placed by
|
||||
`top` paints over its anchor until the next placement lands, about 150 ms
|
||||
on Android. Web hides this because React commits the resize and the
|
||||
re-placement in one task.
|
||||
|
||||
Then copy the closest canonical file and trim.
|
||||
|
||||
|
||||
@@ -113,6 +113,38 @@ host that is either a known cloud host or one the CLI is already authenticated
|
||||
to. Adapter probes must not make anonymous HTTP requests to remote-derived
|
||||
hosts, and adapters must not route credentials to an unauthenticated host.
|
||||
|
||||
### Web links: `forgeWeb`
|
||||
|
||||
The daemon publishes `forgeWeb` on the workspace descriptor and on
|
||||
`checkout_status_response`. The app builds every forge URL from it instead of
|
||||
parsing `origin` itself.
|
||||
|
||||
- `host` is the browsable host: an `ssh -G` alias such as `github.com-work` and
|
||||
a cloud SSH host such as `ssh.github.com` resolve to the cloud host; self-hosted
|
||||
hosts pass through.
|
||||
- `referenceRepository` is the repository that owns issue and change-request
|
||||
numbers: the parent when `origin` is a fork. `#123` links there; `owner/repo#123`
|
||||
keeps the repository you wrote.
|
||||
- Branch and file links stay on the `origin` repository. A feature branch usually
|
||||
exists only on the fork.
|
||||
|
||||
An adapter opts into `referenceRepository` with the optional
|
||||
`ForgeService.getReferenceRepository`. GitHub reads `parent` from `gh repo view`,
|
||||
GitLab reads `forked_from_project` from `glab api`, and the Gitea family reads
|
||||
`parent` from `tea api`. The lookup always names the `origin` repository
|
||||
explicitly, so it does not depend on `gh`'s default-repository choice, and it
|
||||
rejects on any CLI failure. `packages/server/src/server/forge-web.ts` keeps a
|
||||
successful answer for as long as the remote stays the same. The first snapshot
|
||||
publishes `{ host }` immediately and the reference repository follows in a later
|
||||
update, so a slow or signed-out forge CLI never holds back the checkout's git
|
||||
state or pull request status. A failed lookup (signed out, network down) leaves
|
||||
links on `origin` and is retried by the next refresh or pull request status poll
|
||||
once a minute has passed.
|
||||
|
||||
Daemons older than v0.16.0 omit the field; the app then derives the host from the
|
||||
remote and links `#N` to `origin`. That fallback is tagged `COMPAT(forgeWeb)` in
|
||||
`packages/app/src/git/forge-url.ts`.
|
||||
|
||||
## App
|
||||
|
||||
Each app forge splits into two modules so pure logic never imports the client
|
||||
|
||||
@@ -61,6 +61,10 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
|
||||
example because the agent could not be loaded or the requested mode could not be set, leaves no
|
||||
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
|
||||
as an unfinished delivery.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
|
||||
@@ -20,9 +20,16 @@ settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
|
||||
after the focused text control has already handled the press, so the pipeline's
|
||||
`preventDefault` cannot stop a key from typing. A key the text control would type
|
||||
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
|
||||
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
|
||||
active in the current focus scope, such as Shift+Tab in the composer.
|
||||
`sources/native.ts` computes the claims from the published bindings and recomputes
|
||||
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
|
||||
text controls do not type them. Android takes no claims: a focused `EditText`
|
||||
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
@@ -85,6 +92,11 @@ Escape interrupts an agent only in the message input or an unclaimed surface.
|
||||
A focused terminal excludes that binding. The source leaves terminal Escape alone;
|
||||
terminal Escape/Ctrl forwarding is a separate feature.
|
||||
|
||||
Terminal arrows never pass through the shortcut pipeline. The Android hardware
|
||||
module claims them while the terminal's hidden input (`nativeID`) owns the focus.
|
||||
`TextInput.onKeyPress` only sees IME keys, and an arrow that falls through moves
|
||||
the hidden caret and desyncs the next text change, so the pipeline cannot own them.
|
||||
|
||||
## Keys shared with text editing
|
||||
|
||||
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
|
||||
|
||||
+13
-4
@@ -27,9 +27,17 @@ interface MobilePanelSelection {
|
||||
Every semantic target change increments `revision`. Repeating the current target is idempotent.
|
||||
Compact panel selection is not persisted; a cold start begins at `agent`.
|
||||
|
||||
Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App
|
||||
chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or
|
||||
`hidden`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
An overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`.
|
||||
A compact Settings visit entered from the sidebar suspends it: the host stays mounted and
|
||||
laid out on native. Its target and scroll position survive until return to the originating
|
||||
workspace. Route changes and `onLayout` both precede native screen appearance, so the sidebar
|
||||
stays visible until Settings emits the native navigator's `transitionEnd`. Web uses layout.
|
||||
Input and activity stop during the handoff. Widening or leaving for another destination discards
|
||||
the return. Commands issued while suspended settle immediately behind Settings, so explicit
|
||||
agent navigation does not reveal a closing sidebar animation.
|
||||
|
||||
App chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`,
|
||||
`dock`, `hidden`, or `suspended`) to `panel-store`, which drops any target the layout does not present. A stale
|
||||
`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening
|
||||
would block the navigation swipe, which begins only from `agent`.
|
||||
|
||||
@@ -120,7 +128,8 @@ definition, no longer eligible to begin.
|
||||
the overlay host fills the window.
|
||||
- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and
|
||||
workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination:
|
||||
every sidebar destination closes the overlay before it navigates.
|
||||
workspace destinations close the overlay before navigating. Settings suspends the compact
|
||||
sidebar for the duration of its visit instead.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
|
||||
@@ -510,6 +510,18 @@ before calling status; it exposes your diagnostic through the normal provider di
|
||||
A command-backed provider without status is available when its executable resolves. Providers with
|
||||
neither command nor status retain connection-based availability.
|
||||
|
||||
Implement optional `installation` to fill the **Installation** section of provider settings. The
|
||||
bridge sends each callback as a `provider.installation.*` request carrying the launch `status`
|
||||
receives plus `resolved`. `resolved: false` means the executable was not found and `command` is
|
||||
the configured name, so answer `not_installed` instead of spawning it. Inspect and update-check
|
||||
requests use the 30 s plugin request timeout; the daemon's 25 s update-check deadline sits below
|
||||
it, so a slow check reports that deadline. `update` has no request timeout and streams output
|
||||
through `provider.installation.report`. When the daemon stops waiting on any of the three, it
|
||||
sends `provider.installation.cancel`, which aborts the callback's `signal`.
|
||||
The rules shared with built-ins are in [providers.md](providers.md#installation-and-updates); the
|
||||
author contract is in the
|
||||
[public provider guide](../public-docs/plugins/providers.md#report-installation-and-updates).
|
||||
|
||||
A same-ID config entry without `extends` overrides the plugin's `enabled`, `command`, `env`, label,
|
||||
description, and model configuration through the normal provider registry. `enabled: false` disables
|
||||
selection and discovery. An entry with `extends` defines the user's own provider, shadows the plugin,
|
||||
@@ -624,6 +636,21 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute workspace row badge items
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
|
||||
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
|
||||
items after its own.
|
||||
|
||||
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
|
||||
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
|
||||
the row adds no separator around plugin items; a plugin renders its slot.
|
||||
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
|
||||
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
|
||||
their own group below a separator. A missing key is visible.
|
||||
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
|
||||
stay off them.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
@@ -712,6 +739,29 @@ implementations provide it:
|
||||
in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments),
|
||||
`DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock.
|
||||
|
||||
`focus` differs per implementation on purpose. A running agent's model reveals the agent with
|
||||
`navigateToAgent` when `ComposerInput.isRevealed()` is false, and holds one pending request until an
|
||||
input that is revealed attaches. `isRevealed` is stricter than `isLive`: a compact overlay panel and
|
||||
an unfocused pane leave the input live but unable to take the keyboard, so the agent is revealed
|
||||
first, which also moves pane focus. `Composer` makes `revealed` part of `ComposerInput`, so a change
|
||||
re-runs `attachInput`, which is what tells the pending request the input is on screen. The draft
|
||||
half has nothing to navigate to and does not pull the user back to a draft they left, so it rejects
|
||||
when not revealed. Sheets, menus, pickers and modals the composer opens over itself do not count
|
||||
against `revealed`: `takeInputFocus` closes them through the `OverlayDismissalScope` in
|
||||
`components/ui/overlay-dismissal.tsx`, and places the cursor only after the commit that removes
|
||||
them. A web modal hands focus back to the button that opened it in that commit, so placing the
|
||||
cursor earlier loses it. The overlay primitives register while open: every controlled bottom
|
||||
sheet through `useIsolatedBottomSheetVisibility`, `AdaptiveModalSheet`'s desktop dialog, `MenuRoot`,
|
||||
`Combobox`, and `Modal` through its `onRequestClose`. Their close must close; a surface built on none
|
||||
of them must call `useDismissibleOverlay`. Overlapping requests share each overlay's close, and the
|
||||
latest request places the cursor while the earlier ones settle with it. A native input reports
|
||||
focus synchronously and refuses it while not editable, such as during dictation, so the native
|
||||
branch checks `isFocused()` instead of trusting the call. The agent panel opens the scope around its whole chat
|
||||
because the tracks above the composer render outside `Composer`, and the composer's own scope
|
||||
joins it. `toPluginComposer` clears the Command Center's focus restore, which would
|
||||
otherwise take the cursor back when the Command Center closes. Native `focus` opens the keyboard, so
|
||||
the app's web-only `focusInput` stays separate and plugins go through `takeInputFocus`.
|
||||
|
||||
`ComposerAgent` stays for the built-in controls. Add a capability to the `ComposerModel`, both
|
||||
implementations and `toPluginComposer`; never branch on `context` in the host.
|
||||
|
||||
|
||||
+54
-1
@@ -150,6 +150,15 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
|
||||
|
||||
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
|
||||
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
|
||||
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
|
||||
previous file also keeps whatever head it had recorded, which can be an older branch than the one
|
||||
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
|
||||
the newest file or the newest message wins. Import listings hand back a file path, so persist the
|
||||
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
|
||||
handle as both.
|
||||
|
||||
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
@@ -160,7 +169,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. V2 discovers definitions from each bound session's catalog: plugin-contributed tools can be absent from the global manifest, and an allowlist cannot register a missing definition. Registration is location-wide, so session filtering and caller-scoped execution must remain in place. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
@@ -254,6 +263,50 @@ promise for completion: equal results, including equal discovery timestamps, emi
|
||||
|
||||
---
|
||||
|
||||
## Installation and updates
|
||||
|
||||
The provider list and the **Installation** section of a provider's page read `AgentClient.installation`. Built-ins set
|
||||
it on the client; plugins set `ProviderRegistration.installation`, which the plugin bridge maps to
|
||||
the same interface. `ProviderInstallations` in `packages/server/src/server/agent/provider-installation/`
|
||||
owns deadlines, caching and the one running update per provider, so a provider only answers.
|
||||
|
||||
Declare only what the vendor offers:
|
||||
|
||||
- `installUrl` and `updateUrl` link the vendor's own install and update docs.
|
||||
- `inspect` reports the version the CLI prints. Versions are opaque: Paseo shows them as written
|
||||
and never parses or compares them.
|
||||
- `checkForUpdates` asks the source the CLI's own updater reads, such as the vendor's release
|
||||
endpoint or the CLI's `update --check`. Leave it out when that source is inside the binary or
|
||||
depends on how the CLI was installed, which is why Copilot and OpenCode have no check. Do not
|
||||
build a release lookup of your own.
|
||||
- `update` runs the CLI's own update command and passes its output through, using
|
||||
`runUpdateCommand`. Paseo adds no verification, signature check or rollback, so the update does
|
||||
exactly what the same command does in a terminal.
|
||||
|
||||
The three calls are separate because they cost differently. `inspect` runs for every provider
|
||||
whenever the provider list shows, because each row states its version; a provider's page shares the
|
||||
row's request. It has a 20 s deadline and a 30 s cache. `checkForUpdates` usually reaches the
|
||||
network, so the app asks only after `inspect` reports the CLI installed, and a conclusive answer
|
||||
holds for an hour while `inspect` reports the same version string. Report every part of the version
|
||||
the check compares, such as Muse's build, or a CLI updated outside Paseo keeps its stale answer. `update` runs for minutes, streams output, and is aborted only when the daemon
|
||||
stops.
|
||||
|
||||
The update check only informs. Once `inspect` reports the CLI installed, the app offers **Update**
|
||||
for a provider that declares `update`, or **Update instructions** for one that declares only
|
||||
`updateUrl`, whatever the check answers and whether the provider has one. The check's answer shows
|
||||
beside the action when it has one. Copilot and OpenCode have no check, so they show **Update** alone.
|
||||
|
||||
Write the success message for the provider's process model. A CLI that Paseo starts once per agent
|
||||
uses `updatedForNewAgents()`. OpenCode agents share one server that keeps the old binary running,
|
||||
so its message asks the user to restart the daemon.
|
||||
|
||||
`createCliInstallation()` covers a CLI that prints `--version` and updates with a subcommand. It
|
||||
resolves the configured command, so a profile that extends a built-in reports and updates the CLI
|
||||
that profile launches. Clients for `extends: "acp"` providers declare no installation.
|
||||
|
||||
Test with fake executables, as `providers/provider-update-checks.test.ts` does. Running a real
|
||||
updater during development replaces the CLI on your machine.
|
||||
|
||||
## Usage sources
|
||||
|
||||
See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Pi resumed subagents — #9200
|
||||
|
||||
## Scope and provenance
|
||||
|
||||
An AI agent ran this QA through Playwright/CDP attached to the **real macOS Electron renderer**, using an isolated desktop-managed daemon. The user authorized the run; this is not a claim that the user personally operated the UI. No production daemon, installed package, or daily host configuration was changed.
|
||||
|
||||
Desktop evidence was captured on the modified **0.15.3 development checkout** at base `e260f93cc7b6098f6b175bd0e5c7eb24d3f03cf4`, with Electron 44.2.0, Pi 1.5.0, and installed pi-subagents 0.86.0. The final branch was subsequently fast-forwarded to `d46ee6558c043f9fb377d20f05ff662ce27a31f6` (0.15.4). The adapter and test file stayed byte-for-byte identical. The lockfile changes only workspace version references; third-party dependency entries did not change. Server declarations were rebuilt and the checks below passed on the final base. **These screenshots are not a 0.15.4 release-package test.**
|
||||
|
||||
## Before and automated checks
|
||||
|
||||
Issue #9200 reports ordinary async spawn appearing in the track while successive successful resumes disappear. The original adapter rejects any `action` and requires top-level `agent`/`task`, which a resume does not supply. A separate file-only transcript deduplication also prevents a new attempt row from following Pi's reused session file.
|
||||
|
||||
[Captured command output](validation.txt) contains:
|
||||
|
||||
- RED on the original adapter: a successful resume returns no descriptor instead of a running row.
|
||||
- RED on the intermediate implementation with old file-only deduplication: the next resumed row gets no transcript.
|
||||
- Final **21/21** tests passing, including consecutive resumes with a real shared temporary JSONL, polling, notice/replay routing, late old-notice isolation, `bg_wait`, failed/management calls, missing launch identity, and the task alias. Existing spawn fixtures still pass.
|
||||
- Full `npm run format`, `npm run typecheck`, `npm run lint`, and `git diff --check` passing after the base update.
|
||||
|
||||
Only the specific adapter test file was run, not the full suite. CI has not been run. There is no old-desktop before screenshot; the reported failure and the two actual RED runs are the before evidence.
|
||||
|
||||
## Real desktop steps and observations
|
||||
|
||||
1. Built server/app dependencies and launched the existing desktop dev runner with a separate home, Electron userData, Pi configuration/session directory, and async root. The final endpoints were loopback-only: daemon 6768, Metro 8082, and CDP 9223. The renderer's desktop bridge confirmed its own managed daemon/home/server identity. Only the existing pi-subagents extension and a marker-only test agent were enabled; child tasks used `sleep`/`printf`, not workspace edits.
|
||||
2. Created a Pi parent through the normal Paseo **CLI/API**, asking it to call `subagent({agent:"qa-marker", task, async:true})`. This produced A. The model first enabled the subagent tool, then performed the ordinary spawn. This step was not submitted through the composer.
|
||||
3. Used the real Electron **composer** to ask Pi for `subagent({action:"resume", id:A, message})`, then resumed B the same way to produce C. No top-level `agent`, `task`, or `async` was supplied to these resume calls. All three finished with their requested markers.
|
||||
4. Opened the provider-owned track while C ran: it showed `1 working`, the follow-up description, and `qa-marker`. Opening that row showed the reused child conversation and the current Shell call. Each resume had a new run/async directory, while all attempts reused the same session JSONL.
|
||||
5. While C was still running, clicked **Archive finished subagents**. Completed A/B rows disappeared, while C remained visible as `1 working`.
|
||||
6. Submitted a nonexistent resume ID through the composer. Pi returned an error and the parent reported “not found”; no new row or async run appeared.
|
||||
7. The first B screenshot was taken after B finished because screenshot capture waited for fonts. It is not offered as running-state proof. A supplementary real resume from A, B_OBSERVE, was captured as `1 working` and opened successfully. It also finished. The final track contained exactly the four successful runs, not a fifth phantom from the failed resume.
|
||||
|
||||
The [selected real tool arguments/results, final statuses, and four completion notices](desktop-observations.json) preserve the test UUIDs. B's `revivedFrom` is A; C's is B. This file contains selected fields from persisted Pi entries/status files, not synthesized launch details. The [dialogue DOM excerpt](dialogue-excerpt.txt) records opening a resumed child. Test-root paths are replaced with `<QA_ROOT>` and the encoded workspace directory with `<QA_WORKSPACE>`; no auth or models configuration is included.
|
||||
|
||||
### Screenshots
|
||||
|
||||
Unmodified captures, except for the explicitly described dialogue crop. They contain only isolated test prompts, test UUIDs, and app UI; no account, credentials, or user-home paths.
|
||||
|
||||
**C running in the native provider-owned track:**
|
||||
|
||||

|
||||
|
||||
**After archiving finished rows, C remains running:**
|
||||
|
||||

|
||||
|
||||
**Opened C conversation, current Shell call and parent link:** this is an unaltered crop of the bottom 880×245 pixels of the real child-pane capture, omitting the long temporary session path above it. No state was redrawn.
|
||||
|
||||

|
||||
|
||||
**Final four completed rows, with the failed resume reported above:**
|
||||
|
||||

|
||||
|
||||
## Coverage and limits
|
||||
|
||||
| Surface | Tested |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------- |
|
||||
| macOS Electron development desktop | Yes — real renderer, composer, track, child pane, archive-finished action |
|
||||
| Pi RPC / Paseo WebSocket / daemon | Yes — real provider calls and persisted tool results/notices |
|
||||
| Standalone browser web | No |
|
||||
| iOS / Android | No |
|
||||
| Windows / Linux desktop | No |
|
||||
| Packaged release / CI | No |
|
||||
|
||||
Each resumed attempt intentionally gets its own row and reads the whole reused child transcript, including earlier history; it does not slice history by attempt. Old-notice isolation is also covered by the automated regression and the earlier real-notice host replay, rather than forcing a delayed notification in the desktop run.
|
||||
|
||||
Short-lived duplicate Shell text was observed in the streaming child pane; its cause was not established and it is not claimed fixed here. The earlier direct-SDK smoke's turn-end persistence error did not recur through this normal RPC run. All four test runners exited, the parent settled idle, and the temporary workspace stayed empty. No claims are made about old workflow issues or daemon-restart global adoption.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"capture": "Selected fields from actual Pi session entries and final status files; omitted unrelated metadata. Test UUIDs unchanged; all test-root absolute paths replaced with <QA_ROOT>.",
|
||||
"calls": [
|
||||
{
|
||||
"timestamp": "2026-11-10T04:08:31.691Z",
|
||||
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
|
||||
"arguments": {
|
||||
"agent": "qa-marker",
|
||||
"task": "Run sleep 18 then printf PASEO_DESKTOP_A; do not read or write any files",
|
||||
"async": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:08:32.010Z",
|
||||
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"asyncId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"results": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:10:22.901Z",
|
||||
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"message": "Run sleep 18 then printf PASEO_DESKTOP_B; do not read or write files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:10:23.219Z",
|
||||
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"asyncId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"results": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:13:36.923Z",
|
||||
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_C; do not read or write files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:13:37.597Z",
|
||||
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"asyncId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"results": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:16:53.099Z",
|
||||
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"message": "Output PASEO_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:16:53.109Z",
|
||||
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
|
||||
"isError": true,
|
||||
"details": {},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Async run not found for '00000000-0000-0000-0000-000000000000'."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:17:07.330Z",
|
||||
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
|
||||
"arguments": {
|
||||
"action": "resume",
|
||||
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:17:07.668Z",
|
||||
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
|
||||
"isError": false,
|
||||
"details": {
|
||||
"mode": "single",
|
||||
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"asyncId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"results": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"finalStatuses": [
|
||||
{
|
||||
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||
"state": "complete",
|
||||
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
},
|
||||
{
|
||||
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"state": "complete",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
},
|
||||
{
|
||||
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"state": "complete",
|
||||
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
},
|
||||
{
|
||||
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||
"state": "complete",
|
||||
"revivedFrom": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||
}
|
||||
],
|
||||
"completionNotices": [
|
||||
{
|
||||
"timestamp": "2026-11-10T04:09:07.245Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_A\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:10:53.423Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:14:37.834Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_C\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-11-10T04:17:37.887Z",
|
||||
"customType": "subagent-notify",
|
||||
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B_OBSERVE\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
Excerpt of actual Electron renderer DOM captured after opening the resumed child.
|
||||
Test-root paths replaced with <QA_ROOT>; unrelated earlier history omitted.
|
||||
|
||||
Task: You are reviving a previous subagent conversation.
|
||||
|
||||
Original run: 832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122
|
||||
Original agent: qa-marker
|
||||
Original session file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl
|
||||
|
||||
Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.
|
||||
|
||||
Follow-up:
|
||||
Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files
|
||||
Show more
|
||||
12:17 PM
|
||||
QA #9200 — Pi resume A→B→C
|
||||
Open parent
|
||||
@@ -0,0 +1,196 @@
|
||||
Captured command output. Only private absolute paths are replaced with <REPO_ROOT>/<USER_HOME>.
|
||||
RED runs are intentional regression failures, not final failures.
|
||||
|
||||
=== Before fix: resume arguments rejected (expected RED, exit 1) ===
|
||||
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||
|
||||
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||
|
||||
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
|
||||
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚙️ suppress all logs with { quiet: true }
|
||||
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ suppress all logs with { quiet: true }
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (10 tests | 1 failed) 7ms
|
||||
× registers a successful resume as a new async attempt without spawn arguments 6ms
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > registers a successful resume as a new async attempt without spawn arguments
|
||||
AssertionError: expected undefined to deeply equal [ { type: 'upsert', …(5) } ]
|
||||
|
||||
- Expected:
|
||||
[
|
||||
{
|
||||
"description": "Continue",
|
||||
"id": "resume-b",
|
||||
"status": "running",
|
||||
"title": "Subagent",
|
||||
"toolCallId": "resume-b",
|
||||
"type": "upsert",
|
||||
},
|
||||
]
|
||||
|
||||
+ Received:
|
||||
undefined
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:66:32
|
||||
64| },
|
||||
65| });
|
||||
66| expect(resumed?.subagents).toEqual([
|
||||
| ^
|
||||
67| {
|
||||
68| type: "upsert",
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 1 failed (10)
|
||||
Start at 21:05:21
|
||||
Duration 474ms (transform 245ms, setup 31ms, import 328ms, tests 7ms, environment 0ms)
|
||||
|
||||
|
||||
=== Intermediate implementation: old file-only transcript deduplication (expected RED, exit 1) ===
|
||||
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||
|
||||
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||
|
||||
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
|
||||
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚡️ secrets for agents: https://dotenvx.com/as2
|
||||
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ override existing env vars with { override: true }
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (16 tests | 1 failed) 1329ms
|
||||
× follows consecutive resumed attempts and keeps stale notices on their own rows 1320ms
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > follows consecutive resumed attempts and keeps stale notices on their own rows
|
||||
AssertionError: expected false to be true // Object.is equality
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
- true
|
||||
+ false
|
||||
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:78
|
||||
153| ]);
|
||||
154| replay.mapToolCall(call);
|
||||
155| await expect.poll(() => hasChildTimeline(received, `resume-${r…
|
||||
| ^
|
||||
156| }
|
||||
157| const oldNotice = {
|
||||
|
||||
Caused by: Error: Matcher did not succeed in time.
|
||||
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:9
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 1 failed | 6 passed (16)
|
||||
Start at 21:09:04
|
||||
Duration 1.85s (transform 247ms, setup 24ms, import 389ms, tests 1.33s, environment 0ms)
|
||||
|
||||
|
||||
=== Final baseline d46ee655: GREEN, exit 0 ===
|
||||
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||
|
||||
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||
|
||||
|
||||
Test Files 1 passed (1)
|
||||
Tests 21 passed (21)
|
||||
Start at 13:07:04
|
||||
Duration 1.97s (transform 256ms, setup 26ms, import 355ms, tests 1.45s, environment 0ms)
|
||||
|
||||
|
||||
=== Final typecheck, exit 0 ===
|
||||
$ npm run typecheck
|
||||
|
||||
> paseo@0.15.4 typecheck
|
||||
> npm run typecheck --workspaces --if-present
|
||||
|
||||
|
||||
> @getpaseo/builtin-plugins@0.15.4 typecheck
|
||||
> tsgo -p tsconfig.json --noEmit
|
||||
|
||||
|
||||
> @getpaseo/expo-two-way-audio@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/highlight@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/plugin@0.15.4 typecheck
|
||||
> tsgo --noEmit && npm run typecheck:examples
|
||||
|
||||
|
||||
> @getpaseo/plugin@0.15.4 typecheck:examples
|
||||
> npm --prefix ../../plugin-examples/markdown-panel install --ignore-scripts --no-audit --no-fund && tsgo -p tsconfig.examples.json --noEmit
|
||||
|
||||
|
||||
up to date in 433ms
|
||||
|
||||
> @getpaseo/protocol@0.15.4 pretypecheck
|
||||
> npm run generate:validators
|
||||
|
||||
|
||||
> @getpaseo/protocol@0.15.4 generate:validators
|
||||
> node scripts/generate-validation-aot.mjs
|
||||
|
||||
generated src/generated/validation/ws-outbound.aot.ts: envelope + 250 session message validators
|
||||
|
||||
> @getpaseo/protocol@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/client@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/server@0.15.4 typecheck
|
||||
> tsgo -p tsconfig.server.typecheck.json --noEmit
|
||||
|
||||
|
||||
> @getpaseo/app@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/relay@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/website@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
> @getpaseo/desktop@0.15.4 typecheck
|
||||
> tsgo --noEmit -p tsconfig.json
|
||||
|
||||
|
||||
> @getpaseo/cli@0.15.4 typecheck
|
||||
> tsgo --noEmit
|
||||
|
||||
|
||||
=== Final lint, exit 0 ===
|
||||
$ npm run lint
|
||||
|
||||
> paseo@0.15.4 lint
|
||||
> oxlint
|
||||
|
||||
Found 0 warnings and 0 errors.
|
||||
Finished in 2.3s on 5442 files with 177 rules using 12 threads.
|
||||
|
||||
=== Final full formatter, exit 0; no unrelated changes ===
|
||||
$ npm run format
|
||||
|
||||
> paseo@0.15.4 format
|
||||
> oxfmt .
|
||||
|
||||
Finished in 2684ms on 5928 files using 12 threads.
|
||||
|
||||
$ git diff --check
|
||||
(exit 0; no output)
|
||||
+82
-5
@@ -248,6 +248,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
|
||||
- The user-facing beta instructions, including the public TestFlight join link for the `Paseo Beta` group, live in `public-docs/updates.md` and its `zh-CN` pair. Update them when a beta path is added or removed.
|
||||
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
@@ -488,7 +489,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
|
||||
- The website download page defaults to GitHub's latest published **stable** release.
|
||||
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
|
||||
- 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`.
|
||||
- Homebrew, the Play 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. The iOS row swaps the App Store pill for the public TestFlight link (`testFlightBetaUrl` in `packages/website/src/downloads.tsx`). When another surface gains a beta path, add its row back in `packages/website/src/routes/{-$locale}/download.tsx`.
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
@@ -594,7 +595,7 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
|
||||
|
||||
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
|
||||
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Content between the release heading and the first `###` is the release's [highlights](#changelog-highlights). The What's new sheet, the website changelog and the GitHub release body show the highlights and keep the sections collapsed behind one control: Show all changes in the app and on the website, and an All changes `<details>` block that `Release Notes Sync` writes into the release body, so the file itself never needs HTML. A release with nothing before its first `###` shows its sections. Older entries that open with summary paragraphs are treated as highlights. The app, the website and the release scripts each parse the file with their own code, and installed apps keep the parser they shipped with, so those two heading rules are fixed: new kinds of content go inside the highlights or a section as Markdown, never behind a new heading level or marker. `scripts/test-support/changelog-structure.json` holds the cases all three parsers are tested against; change a structure rule there first. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
@@ -613,7 +614,7 @@ Older prerelease bodies on GitHub keep the notes they were synced with; `Release
|
||||
|
||||
- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both. `packages/website/e2e/i18n.spec.ts` fails CI on the release commit when any entry has no current translation.
|
||||
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice. Delete Chinese entries whose English entry was renamed.
|
||||
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
|
||||
- Keep the English highlights, sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
@@ -656,7 +657,7 @@ claims a general improvement, rewrite it with the concrete change.
|
||||
|
||||
## Changelog conciseness
|
||||
|
||||
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
|
||||
Every section bullet must be scannable at a glance. The changelog is not release documentation — it's a list. [Highlights](#changelog-highlights) get more room; every other bullet follows these limits.
|
||||
|
||||
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
|
||||
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
|
||||
@@ -666,6 +667,57 @@ Every bullet must be scannable at a glance. The changelog is not release documen
|
||||
- **Stop after identifying the change.** Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs.
|
||||
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
|
||||
|
||||
## Changelog highlights
|
||||
|
||||
Every release entry leads with highlights: the changes a user would name first when asked what is
|
||||
new. They are a list above the first `###` section, ranked as
|
||||
[Changelog ordering](#changelog-ordering) describes. The sections below are the full record; a
|
||||
reader who stops after the highlights should have missed nothing they would ask about.
|
||||
|
||||
```markdown
|
||||
## 0.16.0 - 2026-11-20
|
||||
|
||||
- **Undo workspace archive:** Archiving a workspace in the app now shows a "Workspace archived" toast for six seconds. Undo, or Cmd/Ctrl+Z, restores the workspace with its tabs and agents. ([#8904](https://github.com/getpaseo/paseo/pull/8904))
|
||||
|
||||
### Added
|
||||
```
|
||||
|
||||
- **The lead-in names what is new in four words at most.** Bold it, colon inside the bold. A reader
|
||||
who sees only the bold words should know what this release added or changed. A category lead-in
|
||||
such as `**Polish:**` gathers several related changes.
|
||||
- **The lead-in is not a place or something Paseo already had.** A device, screen or area names
|
||||
where the change landed. A feature name that was true of the last release hides the news: Paseo
|
||||
already had keyboard shortcuts, so the lead-in says they now work on mobile.
|
||||
- **The first sentence says the plain thing.** Write what a user would tell a friend: Paseo now
|
||||
shows the background tasks an agent is running. Leave out the conditions and mechanics that make
|
||||
it true.
|
||||
- **Describe the new behavior, not the old one.** Say what is faster or better now, and do not
|
||||
dwell on how slow or broken it was before. A number is welcome as a short aside after the new
|
||||
behavior is stated: seconds, a percentage, a multiple.
|
||||
- **Do not list what a sentence already covers.** After "the same shortcuts as desktop", naming
|
||||
five of them adds nothing. Keep a list only when it sets the scope, such as the providers a
|
||||
feature works with.
|
||||
- **The description runs one to three sentences.** Sentences end with periods, and the attribution
|
||||
follows the last one. [Changelog wording](#changelog-wording) applies in full.
|
||||
- **A highlight replaces the section bullets it covers.** A PR appears once in an entry, so its
|
||||
attribution moves to the highlight.
|
||||
- **A highlight is something a user would name.** Everything else stays a section bullet. A patch
|
||||
release still has one: the change it ships for.
|
||||
- **Fixes are routine and stay in `### Fixed`.** Do not add a summary of fixes. A fix, or a group
|
||||
of related fixes, becomes a highlight only when it passes the same test as a feature, and then
|
||||
it is written as what works now: `**Fewer reconnects on return:**`.
|
||||
|
||||
| Avoid | Write |
|
||||
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `**iPad and touch screens:**` | `**Touch-sized tablet controls:**` |
|
||||
| `**Hardware keyboard shortcuts:**` | `**Keyboard shortcuts on mobile:**` |
|
||||
| When an agent ends its turn while shells it started are still running, a pill lists them | Paseo now shows the background tasks an agent is running |
|
||||
| A diff no longer waits 4 to 40 seconds behind worktree scans | Opening Changes shows the file tree right away, and diffs load about 6 times sooner on hosts with many worktrees |
|
||||
| The same shortcuts as desktop: command center, file picker, new workspace, workspace jumps | The same shortcuts as desktop, including your custom bindings |
|
||||
|
||||
Before showing a draft, read only the bold lead-ins top to bottom. Each one must answer "what is
|
||||
new?" without its description, and each must outrank the one below it.
|
||||
|
||||
## Changelog attribution
|
||||
|
||||
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
|
||||
@@ -697,7 +749,32 @@ Rules:
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
Rank highlights by how much of a user's day the change touches: how many users get it, and how
|
||||
often they meet it. Put every highlight in one of four groups, then order each group by the same
|
||||
question.
|
||||
|
||||
1. **New capabilities.** Something a user could not see or do in the last release:
|
||||
`Background tasks`, `Plugin sidebar views`.
|
||||
2. **Everyday things that got better.** Speed, steadiness and polish in what users already do
|
||||
every session: `Faster diff loading`, `Polish`.
|
||||
3. **Changes for part of the audience.** They need a particular device, provider or setup:
|
||||
`Keyboard shortcuts on mobile`, `Touch-sized tablet controls`.
|
||||
4. **Small conveniences and highlighted fixes.** A user is glad to find them and would not update
|
||||
for them: `Undo workspace archive`, `Fewer reconnects on return`. Fixes are routine, so one
|
||||
that earned a highlight still follows everything new or improved.
|
||||
|
||||
- **Reach decides before kind.** A new capability that needs a hardware keyboard is in group 3.
|
||||
- **Rank the whole list in one pass.** The order you collected the pull requests in, the alphabet
|
||||
and the order of the sections below are not rankings.
|
||||
- **Show the groups with the draft.** List each highlight under its group when you ask for
|
||||
approval, so the maintainer corrects the test and not one position.
|
||||
- **A maintainer's note that a change is big moves that change up.** It does not rank the changes
|
||||
it names against each other, and it does not lower the ones it leaves out.
|
||||
- **A correction to one highlight is a correction to the test.** Work out which group or question
|
||||
you got wrong, rank every highlight again, and then edit the file. Moving only the named item
|
||||
leaves the same mistake in the others.
|
||||
|
||||
Bullets within each section (Added, Changed, Fixed) follow this order:
|
||||
|
||||
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
|
||||
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
|
||||
|
||||
@@ -117,11 +117,13 @@ agent, so an agent the daemon has not loaded answers `behind` and resumes in tha
|
||||
|
||||
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host
|
||||
is offline and until a reconnected chat's check answers, so an unchanged chat never shows a second
|
||||
toast. A check on cached content shows nothing. Updating messages appears for a chat that is behind,
|
||||
until the fetch completes, and for a check still unanswered `FRESHNESS_CHECK_QUIET_MS` after it was
|
||||
sent. That delay measures the daemon, so it starts at the request: on a cold start the cache read
|
||||
alone can take longer. Socket connectivity alone cannot certify that the displayed conversation is
|
||||
current. The timeline owner publishes freshness; the view renders it without a toast timer or a
|
||||
toast. Returning from background on a socket that stayed connected shows nothing until the check
|
||||
answers: the chat stayed subscribed, so a slow answer is no reason to doubt it, and a socket that
|
||||
died without closing fails the foreground liveness probe and becomes a reconnect. Updating messages
|
||||
appears for a chat that is behind, until the fetch completes, and for an opened chat whose check is
|
||||
still unanswered `FRESHNESS_CHECK_QUIET_MS` after it was sent. That delay measures the daemon, so it
|
||||
starts at the request: on a cold start the cache read alone can take longer. Socket connectivity
|
||||
alone cannot certify that the displayed conversation is current. The timeline owner publishes freshness; the view renders it without a toast timer or a
|
||||
separate resume workflow.
|
||||
|
||||
The turn footer in a chat follows its messages, not the agent status. Turn activity comes from the
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ Gotchas:
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Settings retains its screen and form; `AppearanceText` refreshes numeric text styles at the leaf, and its font fields use `withUnistyles` to refresh input styles without replacing the inputs. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
|
||||
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
|
||||
|
||||
Notes
|
||||
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
|
||||
|
||||
Notes
|
||||
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
|
||||
|
||||
Notes
|
||||
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
|
||||
|
||||
Notes
|
||||
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
|
||||
sha256-e6BNeOJvy1uyDe/XW9hdr7NBgJ+t9tlNum5oLNefREI=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/server": "0.15.3",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/server": "0.16.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -39890,11 +39890,11 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/relay": "0.16.0-beta.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40149,7 +40149,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
@@ -40233,8 +40233,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40244,8 +40244,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40267,7 +40267,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/highlight": "0.15.3",
|
||||
"@getpaseo/plugin": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@getpaseo/client": "0.16.0-beta.2",
|
||||
"@getpaseo/highlight": "0.16.0-beta.2",
|
||||
"@getpaseo/plugin": "0.16.0-beta.2",
|
||||
"@getpaseo/protocol": "0.16.0-beta.2",
|
||||
"@getpaseo/relay": "0.16.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41474,7 +41474,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43084,7 +43084,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.16.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
expectProviderInstalledInSettings,
|
||||
findAcpCatalogProvider,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openAddProvider,
|
||||
openProviderDetail,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
seedSavedSettingsHosts,
|
||||
@@ -47,7 +48,7 @@ test.describe("ACP provider catalog", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
|
||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
|
||||
@@ -69,7 +70,7 @@ test("adds fx from the catalog with only a config entry", async ({ page }, testI
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
|
||||
await findAcpCatalogProvider(page, "fx");
|
||||
await page.screenshot({ path: testInfo.outputPath("fx-catalog.png") });
|
||||
@@ -104,7 +105,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
@@ -127,7 +128,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
testInfo.outputPath(`hoplite-removed-${theme}.json`),
|
||||
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
|
||||
);
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
await findAcpCatalogProvider(page, "Hoplite");
|
||||
await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
|
||||
await installAcpCatalogProvider(page, "Hoplite");
|
||||
@@ -140,11 +141,11 @@ for (const theme of ["light", "dark"] as const) {
|
||||
}
|
||||
|
||||
async function removeHoplite(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.getByTestId("provider-actions-hoplite").click();
|
||||
await openProviderDetail(page, "Hoplite");
|
||||
page.once("dialog", (dialog) => {
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId("provider-remove-hoplite").click();
|
||||
await page.getByTestId("provider-remove").click();
|
||||
}
|
||||
|
||||
test("unsupported host asks for an update without writing Hoplite config", async ({
|
||||
@@ -164,7 +165,7 @@ test("unsupported host asks for an update without writing Hoplite config", async
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, outdatedDaemon.serverId);
|
||||
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
|
||||
await openAddProviderArea(page);
|
||||
await openAddProvider(page);
|
||||
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
|
||||
const errorToast = page.getByTestId("app-toast-message");
|
||||
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
seedLongMockAgentTimeline,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
answerFreshnessCheck,
|
||||
commitTimelineTurnsWhileDisconnected,
|
||||
disconnectViewedTimeline,
|
||||
expectResumeAnsweredWithoutFetch,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
restoreUnchangedViewedTimeline,
|
||||
restoreViewedTimelineHoldingCatchUp,
|
||||
restoreViewedTimelineWithHeldResponse,
|
||||
reopenWithFreshnessCheckUnanswered,
|
||||
waitForResumeAnswer,
|
||||
reopenAppWatchingForUpdatingToast,
|
||||
watchForUpdatingToast,
|
||||
@@ -101,6 +103,27 @@ test.describe("Agent timeline resume", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("opening cached history reports an unanswered check until the daemon answers", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 3 });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
|
||||
await reopenWithFreshnessCheckUnanswered(page, gate, agent);
|
||||
await answerFreshnessCheck(page, gate);
|
||||
|
||||
expectResumeAnsweredWithoutFetch(gate, requests);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("returning to a changed chat reports the update and shows the new messages", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -37,7 +37,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 213)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
@@ -64,7 +64,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(17, 17, 17)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(29, 29, 29)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("pure-black-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -644,6 +644,22 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Crossing into part of the block keeps the selected code lines as a fenced block.
|
||||
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
|
||||
);
|
||||
|
||||
// So does starting partway through the block and leaving it.
|
||||
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
|
||||
);
|
||||
|
||||
// Selecting a whole line runs off its end into the newline node. A trailing
|
||||
// newline auto-executes the line when it is pasted into a terminal.
|
||||
await selectAssistantAcrossCodeLines(page, "const", " ");
|
||||
@@ -729,13 +745,25 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
initialPrompt: "Render structured text.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
mockAssistantResponse: [
|
||||
"First paragraph.",
|
||||
"",
|
||||
"- List item.",
|
||||
"",
|
||||
"> Quote text.",
|
||||
"",
|
||||
"```ts",
|
||||
"const answer = 1;",
|
||||
"```",
|
||||
"",
|
||||
"Third paragraph.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -760,6 +788,9 @@ test.describe("on a touch-capable desktop browser", () => {
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("List item.");
|
||||
expect(selected).toContain("Quote text.");
|
||||
expect(selected).toContain("const answer = 1;");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -125,11 +125,13 @@ async function renameWorkspaceViaSidebar(
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
|
||||
const renameInput = page.getByTestId(`${modalPrefix}-input`);
|
||||
await expect(renameInput).toBeVisible({ timeout: 10_000 });
|
||||
// Desktop-width sidebars rename in place.
|
||||
const renameInput = page.getByTestId(
|
||||
`sidebar-workspace-rename-input-${serverId}:${input.workspaceId}`,
|
||||
);
|
||||
await expect(renameInput).toBeFocused({ timeout: 10_000 });
|
||||
await renameInput.fill(input.title);
|
||||
await page.getByTestId(`${modalPrefix}-submit`).click();
|
||||
await renameInput.press("Enter");
|
||||
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +306,24 @@ test("streamed Changes shows the tree before completion and applies a one-file r
|
||||
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
|
||||
});
|
||||
|
||||
test("reopening compact Changes keeps the painted diff while it resubscribes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
const stream = await holdDiffCompletion(page);
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
stream.finish();
|
||||
const firstFile = explorer.getByTestId("diff-file-0");
|
||||
await expect(firstFile).toBeVisible({ timeout: 30_000 });
|
||||
const painted = await firstFile.getAttribute("aria-label");
|
||||
|
||||
await explorer.getByTestId("workspace-tab-files").click();
|
||||
stream.hold();
|
||||
await explorer.getByTestId("workspace-tab-changes_tree").click();
|
||||
await expect.poll(() => stream.held, { timeout: 30_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
await expect(firstFile).toHaveAccessibleName(painted ?? "");
|
||||
stream.finish();
|
||||
});
|
||||
|
||||
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(252);
|
||||
await Promise.all(
|
||||
@@ -371,6 +389,12 @@ async function holdDiffCompletion(page: Page) {
|
||||
get completed() {
|
||||
return completed;
|
||||
},
|
||||
get held() {
|
||||
return completions.length;
|
||||
},
|
||||
hold: () => {
|
||||
holding = true;
|
||||
},
|
||||
finish: () => {
|
||||
holding = false;
|
||||
completions.splice(0).forEach((send) => send());
|
||||
@@ -2273,13 +2297,9 @@ async function changeCodeTypographyFromSettings(
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
|
||||
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
|
||||
const sizeField = page.getByLabel("Code font size");
|
||||
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
|
||||
await page.getByLabel("Code font family").fill(typography.fontFamily);
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
|
||||
await sizeField.fill(String(typography.fontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
openSheetAsTab,
|
||||
expectDeclinedDiscardPrompt,
|
||||
expectSheetAtSnap,
|
||||
promptMentioning,
|
||||
replaceSheetFileText,
|
||||
selectTabFromSwitcher,
|
||||
sheet,
|
||||
@@ -28,17 +29,6 @@ import {
|
||||
|
||||
const FILE_LINK = "linked.ts:3";
|
||||
|
||||
function promptMentioning(target: string): string {
|
||||
return [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedSubagent } from "../support/helpers/subagents";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -918,11 +918,12 @@ test.describe("touch composer tracks", () => {
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
const narrowWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
for (const [index, width] of wideWidths.entries())
|
||||
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
for (const [index, pill] of [tasks, subagents, changes].entries())
|
||||
await expect
|
||||
.poll(async () => (await pill.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
selectComposerDraftRange,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
|
||||
// The popover has no semantic role; its rows are React Native Pressables.
|
||||
function fileSuggestions(page: Page) {
|
||||
@@ -32,31 +33,33 @@ async function sendEmailIntact(page: Page, email: string): Promise<void> {
|
||||
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const email of [
|
||||
"user <12345+user@noreply.example.com>",
|
||||
"alice@example.com",
|
||||
"noreply+@example.com",
|
||||
]) {
|
||||
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-email-",
|
||||
title: "Email mention regression",
|
||||
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await test.step("complete a workspace file mention with Enter", async () => {
|
||||
await selectReadmeMention(page);
|
||||
});
|
||||
await test.step("send an email without offering or inserting workspace files", async () => {
|
||||
await sendEmailIntact(page, email);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
test("Enter sends email formats intact and still completes file mentions", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-email-",
|
||||
title: "Email mention regression",
|
||||
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
|
||||
});
|
||||
}
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
for (const email of [
|
||||
"user <12345+user@noreply.example.com>",
|
||||
"alice@example.com",
|
||||
"noreply+@example.com",
|
||||
]) {
|
||||
await test.step(`complete a file mention, then send ${email} intact`, async () => {
|
||||
await selectReadmeMention(page);
|
||||
await sendEmailIntact(page, email);
|
||||
// Start the next draft only after the daemon and visible agent have settled.
|
||||
const reply = await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
expect(reply.status).toBe("idle");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
|
||||
page,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fillComposerDraft,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
@@ -29,7 +30,9 @@ async function pressInComposer(page: Page, key: string) {
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
test("recalls, edits, and sends messages while keeping slash autocomplete usable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
@@ -37,49 +40,44 @@ test.describe("Composer recall", () => {
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
await test.step("walk sent history, edit a recalled draft, and send it", async () => {
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
});
|
||||
await test.step("recall a slash message without opening its picker, then edit it", async () => {
|
||||
await expectComposerDraft(page, "");
|
||||
const reply = await session.client.waitForFinish(session.agentId, 15_000);
|
||||
expect(reply.status).toBe("idle");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 4);
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
composerControl,
|
||||
expectGlyphMatchesLabel,
|
||||
expectIconOnlyGlyph,
|
||||
readComposerControlTone,
|
||||
readComposerPlaceholderTone,
|
||||
readComposerTextTone,
|
||||
} from "../support/helpers/composer-toolbar-tone";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const THINKING = "agent-thinking-selector";
|
||||
const MODE = "mode-control";
|
||||
const ATTACH = "message-input-attach-button";
|
||||
const COMBINED_MODEL = "combined-model-selector";
|
||||
|
||||
async function openSeededComposer(
|
||||
page: import("@playwright/test").Page,
|
||||
repoPrefix: string,
|
||||
readyTestId = MODE,
|
||||
) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
title: "Toolbar tone",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await expect(composerControl(page, readyTestId)).toBeVisible({ timeout: 30_000 });
|
||||
return agent;
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Composer toolbar glyph tone", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test.describe("on a touch screen", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 1024, height: 1366 } });
|
||||
|
||||
test("a glyph beside its label matches the label while an icon-only glyph stays foreground", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-touch-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
const attach = await readComposerControlTone(composerControl(page, ATTACH));
|
||||
|
||||
expectGlyphMatchesLabel(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
expectIconOnlyGlyph(attach, "attach");
|
||||
expect(attach.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
|
||||
thinking.label,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a compact non-touch window", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 590, height: 844 } });
|
||||
|
||||
test("the icon-only thinking glyph stays foreground and the labelled mode glyph matches its label", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-compact-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
const attach = await readComposerControlTone(composerControl(page, ATTACH));
|
||||
|
||||
expectIconOnlyGlyph(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
expectIconOnlyGlyph(attach, "attach");
|
||||
expect(thinking.glyph, "the icon-only glyphs share one tone").toBe(attach.glyph);
|
||||
expect(thinking.glyph, "an icon-only glyph is brighter than a labelled glyph").not.toBe(
|
||||
mode.label,
|
||||
);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a folded compact non-touch window", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 480, height: 844 } });
|
||||
|
||||
test("the empty composer placeholder is quieter than its labels", async ({ page }) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-placeholder-", COMBINED_MODEL);
|
||||
try {
|
||||
const placeholder = await readComposerPlaceholderTone(composerLocator(page));
|
||||
const extraMuted = await readComposerTextTone(
|
||||
composerControl(page, "combined-model-selector-thinking-label"),
|
||||
);
|
||||
const muted = await readComposerTextTone(
|
||||
composerControl(page, "combined-model-selector-model-label"),
|
||||
);
|
||||
|
||||
expect(placeholder, "the composer paints a placeholder").not.toBeNull();
|
||||
expect(extraMuted, "the folded thinking label paints the extra-muted tone").not.toBeNull();
|
||||
expect(placeholder, "the placeholder uses the extra-muted tone").toBe(extraMuted);
|
||||
expect(placeholder, "the placeholder is quieter than a labelled glyph").not.toBe(muted);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("with a wide pointer", () => {
|
||||
test.use({ hasTouch: false, viewport: { width: 1280, height: 800 } });
|
||||
|
||||
test("a labelled glyph stays muted and matches its label", async ({ page }) => {
|
||||
const agent = await openSeededComposer(page, "composer-tone-pointer-");
|
||||
try {
|
||||
const thinking = await readComposerControlTone(composerControl(page, THINKING));
|
||||
const mode = await readComposerControlTone(composerControl(page, MODE));
|
||||
|
||||
expectGlyphMatchesLabel(thinking, "thinking");
|
||||
expectGlyphMatchesLabel(mode, "mode");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
openAgent,
|
||||
pressContextWindowMeter,
|
||||
qaScreenshot,
|
||||
recordContextWindowFrames,
|
||||
refreshUsageCard,
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
@@ -299,9 +300,10 @@ for (const theme of ["light", "dark"] as const) {
|
||||
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 expect(usageCard(closedEarly, "claude:work").getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
await usage.resumeAgent(agent);
|
||||
const resumed = gate();
|
||||
const finished = gate();
|
||||
usage.answerNext({
|
||||
@@ -385,6 +387,58 @@ for (const theme of ["light", "dark"] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
test("an open with loaded usage paints it in its first frame and never moves", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
usage.answerNext([onWorkLogin(claude!)]);
|
||||
const loaded = await hoverContextWindowMeter(page);
|
||||
await expect(usageCard(loaded, "claude:work")).toBeVisible();
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
// The reopen's own request stays unanswered until the card has been on screen a while.
|
||||
const refetch = gate();
|
||||
usage.answerNext({ stream: [refetch.promise, onWorkLogin(claude!)] });
|
||||
const recording = await recordContextWindowFrames(page);
|
||||
const reopened = await hoverContextWindowMeter(page);
|
||||
await page.waitForTimeout(300);
|
||||
refetch.open();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(usageCard(reopened, "claude:work")).toBeVisible();
|
||||
const frames = (await recording.stop()).filter((frame) => frame.mounted);
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
expect(frames.filter((frame) => frame.loading)).toEqual([]);
|
||||
expect(frames.filter((frame) => frame.usageCards !== 1)).toEqual([]);
|
||||
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
|
||||
});
|
||||
|
||||
test("usage arriving after the card opens never moves its bottom edge", async ({ page, agent }) => {
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
|
||||
const arrival = gate();
|
||||
usage.answerNext({ stream: [arrival.promise, onWorkLogin(claude!)] });
|
||||
const recording = await recordContextWindowFrames(page);
|
||||
const card = await hoverContextWindowMeter(page);
|
||||
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
arrival.open();
|
||||
await expect(usageCard(card, "claude:work")).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
const frames = (await recording.stop()).filter((frame) => frame.mounted);
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
expect(frames.some((frame) => frame.loading)).toBe(true);
|
||||
expect(frames.at(-1)?.usageCards).toBe(1);
|
||||
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
|
||||
});
|
||||
|
||||
test("context details support keyboard entry and dismiss when the viewport moves", async ({
|
||||
page,
|
||||
agent,
|
||||
|
||||
@@ -127,8 +127,42 @@ for (const scenario of [
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Start the original task.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("an edited agent draft starts after its first start failed", async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}) => {
|
||||
await creation.openAgentDraft();
|
||||
await startup.fail();
|
||||
await creation.submitPrompt("Start the original task.");
|
||||
await creation.expectStartupFailure();
|
||||
await creation.submitPrompt("Start the edited task.");
|
||||
await creation.expectPromptVisible("Start the edited task.");
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const entry of [
|
||||
{
|
||||
name: "agent draft",
|
||||
@@ -159,7 +193,9 @@ for (const entry of [
|
||||
await entry.settled(creation);
|
||||
await creation.expectCreatedAgentError();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("created-agent-prompt-error.png"),
|
||||
});
|
||||
await creation.reloadAgent();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ensureExplorerSidebar,
|
||||
openFilesPanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
dragTabIntoExplorer,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
@@ -194,26 +195,6 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
|
||||
}
|
||||
});
|
||||
|
||||
async function dragTabIntoExplorer(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
tabId: string,
|
||||
) {
|
||||
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
|
||||
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("moves agents into Explorer and keeps the shared Agent launcher available", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
downloadFileFromPreview,
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
@@ -141,6 +142,29 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("downloads an assistant-linked binary outside the workspace", async ({ page }, testInfo) => {
|
||||
const filePath = testInfo.outputPath("outside.bin");
|
||||
const source = Buffer.from([0, 1, 128, 255]);
|
||||
await writeFile(filePath, source);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "outside-binary-",
|
||||
title: "Outside binary",
|
||||
initialPrompt: "Show the file",
|
||||
featureValues: { mockAssistantResponse: `[Open binary](${filePath})` },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "Open binary");
|
||||
const downloaded = await downloadFileFromPreview(page);
|
||||
expect(downloaded.suggestedFilename()).toBe("outside.bin");
|
||||
const savedFile = testInfo.outputPath("downloaded.bin");
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "screenshot-markdown-link-",
|
||||
|
||||
@@ -2,7 +2,12 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import {
|
||||
downloadFileFromExplorer,
|
||||
downloadFileFromPreview,
|
||||
openFileFromExplorer,
|
||||
openFileExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
@@ -473,3 +478,21 @@ test("downloads a file over direct TCP with its original filename and bytes", as
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
test("downloads an unsupported binary from its preview with original filename and bytes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fileName = "rapport données.bin";
|
||||
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
|
||||
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, fileName);
|
||||
|
||||
const downloaded = await downloadFileFromPreview(page);
|
||||
expect(downloaded.suggestedFilename()).toBe(fileName);
|
||||
const savedFile = testInfo.outputPath(fileName);
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
addProjectFromSetup,
|
||||
continueSetup,
|
||||
expectSetupStep,
|
||||
expectSheetCoversSetupCard,
|
||||
finishSetup,
|
||||
goToSetupStep,
|
||||
hostGivesAgentsPaseoTools,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
recordFirstLaunch,
|
||||
setupModal,
|
||||
setupPrimaryButton,
|
||||
setupSheet,
|
||||
showSetupAgainFromSettings,
|
||||
skipSetup,
|
||||
startSetupHost,
|
||||
@@ -35,7 +37,11 @@ import {
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
installAcpCatalogProvider,
|
||||
openAddProvider,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
|
||||
@@ -59,11 +65,28 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
|
||||
await test.step("Providers shows the same provider list as Settings", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Your agents");
|
||||
await expectSetupStep(page, "Your providers");
|
||||
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
|
||||
});
|
||||
|
||||
await test.step("a provider's detail and Add provider cover the setup card and lead back to it", async () => {
|
||||
await setupModal(page)
|
||||
.getByRole("button", { name: "Claude provider details", exact: true })
|
||||
.click();
|
||||
const detail = setupSheet(page, "provider-settings-sheet");
|
||||
await expectSheetCoversSetupCard(page, detail);
|
||||
await detail.getByRole("button", { name: "Back to providers", exact: true }).click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
|
||||
await setupModal(page).getByTestId("provider-list-add").click();
|
||||
const add = setupSheet(page, "add-provider-sheet");
|
||||
await expectSheetCoversSetupCard(page, add);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(add).toHaveCount(0);
|
||||
await expectSetupStep(page, "Your providers");
|
||||
});
|
||||
|
||||
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
@@ -103,6 +126,33 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a provider removed in setup can be added back and removed again", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const host = await startSetupHost("re-add");
|
||||
const detail = setupSheet(page, "provider-settings-sheet");
|
||||
const remove = detail.getByTestId("provider-remove");
|
||||
page.on("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Your providers");
|
||||
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, "Junie");
|
||||
await remove.click();
|
||||
await expect(detail).toHaveCount(0);
|
||||
|
||||
// Adding it back opens the same provider's sheet, which must not still be removing it.
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, "Junie");
|
||||
await expect(remove).toHaveText("Remove");
|
||||
await expect(remove).toBeEnabled();
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
|
||||
const host = await startSetupHost("first-launch");
|
||||
|
||||
@@ -145,7 +195,7 @@ test("changing the theme keeps the setup window in place on every step", async (
|
||||
|
||||
for (const title of [
|
||||
"Welcome to Paseo",
|
||||
"Your agents",
|
||||
"Your providers",
|
||||
"Add your projects",
|
||||
"Let agents use Paseo",
|
||||
"Take your agents with you",
|
||||
@@ -418,7 +468,7 @@ test("in the compact layout, Finish on Orchestration applies its switches", asyn
|
||||
await openAppAsNewUser(page, host);
|
||||
await step("Welcome to Paseo");
|
||||
await primary("Continue").click();
|
||||
await step("Your agents");
|
||||
await step("Your providers");
|
||||
await primary("Continue").click();
|
||||
await step("Add your projects");
|
||||
await primary("Continue").click();
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
|
||||
import {
|
||||
expectToolbarNeverCondensedOrClipped,
|
||||
recordToolbarFramesFromLoad,
|
||||
withComposerControlsExample,
|
||||
withPickerComposerItem,
|
||||
withToggleAndWatcherComposerItems,
|
||||
} from "../support/helpers/plugin-composer-items";
|
||||
|
||||
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -10,3 +16,66 @@ test("a narrow composer's overflow sheet shows a plugin item's row", async ({ pa
|
||||
await composer.expectFeatureRow("Insert checklist");
|
||||
});
|
||||
});
|
||||
|
||||
test("a toolbar with room keeps a plugin toggle inline at full density", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withToggleAndWatcherComposerItems(page, async (composer) => {
|
||||
// Wider than what renders, too narrow for two 125-wide "Plugin item" estimates.
|
||||
await composer.setWindowWidth(1000);
|
||||
await composer.expectToolbarWidthBetween(470, 620);
|
||||
await composer.expectToggleInlineAtFullDensity();
|
||||
});
|
||||
});
|
||||
|
||||
test("a toolbar without room moves a plugin toggle to the overflow sheet and back", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withToggleAndWatcherComposerItems(page, async (composer) => {
|
||||
await composer.setWindowWidth(1000);
|
||||
await composer.expectToggleInlineAtFullDensity();
|
||||
// Too narrow for full labels, wide enough that the controls have not folded away.
|
||||
await composer.setWindowWidth(880);
|
||||
await composer.expectToolbarWidthBetween(300, 395);
|
||||
await composer.expectToggleInOverflow();
|
||||
await composer.closeOverflowSheet();
|
||||
await composer.setWindowWidth(1000);
|
||||
await composer.expectToggleInlineAtFullDensity();
|
||||
});
|
||||
});
|
||||
|
||||
test("an open plugin picker closes when its trigger moves to the overflow", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPickerComposerItem(page, async (composer) => {
|
||||
await composer.setWindowWidth(1000);
|
||||
await composer.openInlinePicker();
|
||||
await composer.setWindowWidth(880);
|
||||
await composer.expectToolbarWidthBetween(300, 395);
|
||||
await composer.expectPickerClosed();
|
||||
});
|
||||
});
|
||||
|
||||
test("the toolbar settles on one density without flipping as the window widens", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withToggleAndWatcherComposerItems(page, async (composer) => {
|
||||
await composer.setWindowWidth(880);
|
||||
await composer.expectToolbarWidthBetween(300, 395);
|
||||
await composer.startRecordingDensity();
|
||||
for (let width = 880; width <= 1000; width += 8) await composer.setWindowWidth(width);
|
||||
await composer.expectAtMostOneDensityChange();
|
||||
});
|
||||
});
|
||||
|
||||
test("a toolbar with room never paints condensed or clipped while the plugin loads", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await recordToolbarFramesFromLoad(page);
|
||||
await withToggleAndWatcherComposerItems(page, async (composer) => {
|
||||
await composer.expectToggleInlineAtFullDensity();
|
||||
await expectToolbarNeverCondensedOrClipped(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ import { test } from "../support/fixtures";
|
||||
import {
|
||||
CHECKLIST,
|
||||
withComposerToolsExample,
|
||||
withPluginFocusingAComposer,
|
||||
withPluginFocusingASubagent,
|
||||
withPluginFocusingAnAgentWithContextUsage,
|
||||
withPluginHoldingNewWorkspaceComposer,
|
||||
withPluginWritingToAnotherWorkspace,
|
||||
} from "../support/helpers/plugin-composers-by-reference";
|
||||
|
||||
@@ -39,6 +43,17 @@ test("the panel follows which agent is focused", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test("an Action in the compact controls sheet closes the sheet and focuses the composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.useCompactLayout();
|
||||
await author.insertChecklistFromControlsSheet();
|
||||
await author.expectComposerReadyToType(CHECKLIST);
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin writes to an agent's composer while its workspace is not on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -51,3 +66,158 @@ test("a plugin writes to an agent's composer while its workspace is not on scree
|
||||
await author.expectComposerText("A note from a plugin");
|
||||
});
|
||||
});
|
||||
|
||||
test("a pill that inserts text and focuses leaves the cursor in the composer, ready to type", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingAComposer(page, async (author) => {
|
||||
await author.openTargetAgent();
|
||||
await author.moveFocusOutOfComposer();
|
||||
await author.pressPill();
|
||||
await author.expectComposerFocused();
|
||||
await author.typeAtCursor(" world");
|
||||
await author.expectComposerText("HELLO world");
|
||||
});
|
||||
});
|
||||
|
||||
test("focus reaches an agent in another tab of the workspace", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingAComposer(page, async (author) => {
|
||||
await author.openTargetAgent();
|
||||
await author.openOtherAgent();
|
||||
await author.focusFromCommandCenter();
|
||||
await author.expectOnTargetAgent();
|
||||
await author.expectComposerFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus reaches an agent in another workspace, with the selection asked for", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingAComposer(page, async (author) => {
|
||||
await author.openTargetAgent();
|
||||
await author.fillComposer("typed before");
|
||||
await author.goToAnotherWorkspace();
|
||||
await author.selectPromptFromCommandCenter();
|
||||
await author.expectOnTargetAgent();
|
||||
await author.expectComposerFocused();
|
||||
await author.typeAtCursor("replaced");
|
||||
await author.expectComposerText("replaced");
|
||||
});
|
||||
});
|
||||
|
||||
test("focus closes the compact Explorer panel covering the composer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingAComposer(page, async (author) => {
|
||||
await author.useCompactLayout();
|
||||
await author.openTargetAgent();
|
||||
await author.coverComposerWithExplorerPanel();
|
||||
await author.pressPanelFocusButton();
|
||||
await author.expectExplorerPanelClosed();
|
||||
await author.expectComposerFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus rejects on a New workspace composer the user has left", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
|
||||
await author.openNewWorkspace();
|
||||
await author.expectHeldComposerFocusResult("focused");
|
||||
await author.openSettings();
|
||||
await author.expectHeldComposerFocusResult("The composer is not on screen.");
|
||||
});
|
||||
});
|
||||
|
||||
test("focus closes a picker the composer opened over itself", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
|
||||
await author.openNewWorkspace();
|
||||
await author.openThinkingPicker();
|
||||
await author.expectHeldComposerFocusResult("focused");
|
||||
await author.expectHeldComposerReadyToType();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus closes an image preview the composer opened over itself", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
|
||||
await author.openNewWorkspace();
|
||||
await author.previewAttachedImage();
|
||||
await author.expectHeldComposerFocusResult("focused");
|
||||
await author.expectHeldComposerReadyToType();
|
||||
});
|
||||
});
|
||||
|
||||
test("overlapping focus calls end at the later selection while a preview closes", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
|
||||
await author.openNewWorkspace();
|
||||
await author.typeInHeldComposer("HELLO");
|
||||
await author.previewAttachedImage();
|
||||
await author.expectOverlappingFocusesKeepTheLatestSelection();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus closes the context usage sheet on a phone", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingAnAgentWithContextUsage(page, async (author) => {
|
||||
await author.openContextUsageOnPhone();
|
||||
await author.focusAgent();
|
||||
await author.expectReadyToType();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus closes a page the composer's Subagents track opened over it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingASubagent(page, async (author) => {
|
||||
await author.openParentAgent();
|
||||
await author.openSubagentPageInParentComposer();
|
||||
await author.focusParent();
|
||||
await author.expectParentReadyToType();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus reaches a New workspace composer opened from a compact Explorer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginHoldingNewWorkspaceComposer(page, async (author) => {
|
||||
await author.leaveCompactExplorerForNewWorkspace();
|
||||
await author.expectHeldComposerFocusResult("focused");
|
||||
});
|
||||
});
|
||||
|
||||
test("focus opens a compact subagent as a tab with its composer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingASubagent(page, async (author) => {
|
||||
await author.openParentAgent();
|
||||
await author.openExplorer();
|
||||
await author.focusSubagent();
|
||||
await author.expectSubagentTabReadyToType();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus closes a file sheet still opening over an Explorer composer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingASubagent(page, async (author) => {
|
||||
await author.keepParentInExplorerOnCompactScreen();
|
||||
await author.focusParent();
|
||||
await author.expectParentReadyToType();
|
||||
await author.focusParentAsAFileSheetOpens();
|
||||
await author.expectParentReadyToType();
|
||||
});
|
||||
});
|
||||
|
||||
test("focus reaches an agent kept in the Explorer on a compact screen", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingASubagent(page, async (author) => {
|
||||
await author.keepParentInExplorerOnCompactScreen();
|
||||
await author.focusParent();
|
||||
await author.expectParentReadyToType();
|
||||
await author.coverExplorerComposerWithFileSheet();
|
||||
await author.focusParent();
|
||||
await author.expectParentReadyToType();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { contrastRatio } from "../../src/utils/color";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
const PLUGIN_ID = "plugin-theme-e2e";
|
||||
|
||||
@@ -40,6 +44,16 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
ring: "#9ca0b0",
|
||||
},
|
||||
});
|
||||
for (const [id, name, overrides] of [
|
||||
["solarized-dark", "Solarized Dark", {}],
|
||||
["solarized-dark-custom", "Solarized Dark Custom", { sidebarSelected: "#334455", foregroundExtraMuted: "#ddeeff" }],
|
||||
]) {
|
||||
plugin.addTheme({ id, name, appearance: "dark", colors: {
|
||||
background: "#002b36", foreground: "#839496", raised: "#073642",
|
||||
control: "#073642", border: "#073642", accent: "#268bd2",
|
||||
mutedForeground: "#586e75", ring: "#93a1a1", ...overrides,
|
||||
}});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
@@ -47,6 +61,88 @@ const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
// contributed palette reached the semantic tokens rather than just the swatch.
|
||||
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
|
||||
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
|
||||
const LATTE_RING = "rgb(156, 160, 176)";
|
||||
|
||||
async function selectTheme(page: Page, name: string) {
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
const theme = page.getByRole("radio", { name, exact: true });
|
||||
await expect(theme).toBeVisible({ timeout: 30_000 });
|
||||
await theme.click();
|
||||
await expect(theme).toBeChecked();
|
||||
}
|
||||
|
||||
async function expectTimestampContrast(row: Locator) {
|
||||
const timestamp = row.getByTestId("sidebar-workspace-timestamp");
|
||||
await expect(timestamp).toBeVisible();
|
||||
const hex = (rgb: string) =>
|
||||
`#${rgb
|
||||
.match(/\d+/g)!
|
||||
.slice(0, 3)
|
||||
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
const foreground = await timestamp.evaluate((element) => getComputedStyle(element).color);
|
||||
expect(contrastRatio(hex(background), hex(foreground))).toBeGreaterThanOrEqual(2.5);
|
||||
}
|
||||
|
||||
test("keeps plugin workspace timestamps readable on state surfaces and honors overrides", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-state-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
const selected = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-selected-",
|
||||
title: "Selected theme workspace",
|
||||
});
|
||||
const hovered = await seedMockAgentWorkspace({
|
||||
repoPrefix: "theme-hovered-",
|
||||
title: "Hovered theme workspace",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await selectTheme(page, "Solarized Dark");
|
||||
await openAgentRoute(page, selected);
|
||||
const selectedRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${selected.workspaceId}`,
|
||||
);
|
||||
const hoveredRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${hovered.workspaceId}`,
|
||||
);
|
||||
await expect(selectedRow).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(33, 58, 66)");
|
||||
await expectTimestampContrast(selectedRow);
|
||||
await hoveredRow.hover();
|
||||
await expect(hoveredRow).toHaveCSS("background-color", "rgb(19, 50, 60)");
|
||||
await expectTimestampContrast(hoveredRow);
|
||||
|
||||
await selectTheme(page, "Solarized Dark Custom");
|
||||
await openAgentRoute(page, selected);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(selectedRow).toHaveCSS("background-color", "rgb(51, 68, 85)");
|
||||
await expect(selectedRow.getByTestId("sidebar-workspace-timestamp")).toHaveCSS(
|
||||
"color",
|
||||
"rgb(221, 238, 255)",
|
||||
);
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close();
|
||||
await selected.cleanup();
|
||||
await hovered.cleanup();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("applies a contributed theme and falls back when its plugin is gone", async ({
|
||||
page,
|
||||
@@ -83,6 +179,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||
});
|
||||
|
||||
await test.step("keyboard focus draws the outline in the theme's ring color", async () => {
|
||||
await page.getByRole("radio", { name: "Catppuccin Latte", exact: true }).click();
|
||||
await page.mouse.move(0, 0);
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(":focus-visible").first()).toHaveCSS("outline-color", LATTE_RING);
|
||||
});
|
||||
|
||||
await mochaItem.click();
|
||||
await expect(mochaItem).toBeChecked();
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openMobileAgentSidebar,
|
||||
openSidebarDisplayPage,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "row-badges-e2e";
|
||||
const TITLE = "Badge host workspace";
|
||||
|
||||
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
function Review() {
|
||||
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
|
||||
}
|
||||
|
||||
function Docs() {
|
||||
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
|
||||
}
|
||||
|
||||
function IconOnly() {
|
||||
return <WorkspaceRowBadge icon="Star" />;
|
||||
}
|
||||
|
||||
function EmptyLabel() {
|
||||
return <WorkspaceRowBadge icon="Circle" label="" />;
|
||||
}
|
||||
|
||||
function Quiet() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Broken() {
|
||||
throw new Error("badge failed");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
|
||||
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
|
||||
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
|
||||
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
|
||||
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
|
||||
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
|
||||
const LABELS = { review: "In review", docs: "Docs" } as const;
|
||||
|
||||
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
|
||||
return badge(page, itemId).locator("xpath=../..");
|
||||
}
|
||||
|
||||
function badge(page: Page, itemId: keyof typeof LABELS) {
|
||||
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
|
||||
}
|
||||
|
||||
async function toggleShowItem(page: Page, label: string, touch: boolean) {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
await page.getByRole("menuitem", { name: label, exact: true }).click();
|
||||
if (touch) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
} else {
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
}
|
||||
}
|
||||
|
||||
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await expect(badge(page, "review")).toContainText("In review");
|
||||
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
|
||||
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
|
||||
// An icon-only badge is named by its item's title.
|
||||
await expect(
|
||||
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(TITLE).first()).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("wide.png") });
|
||||
});
|
||||
|
||||
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-show");
|
||||
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
|
||||
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("show-menu.png") });
|
||||
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
// The first badge is gone, so the next one leads the line without a "·".
|
||||
await expect(badgeLine(page, "docs")).toHaveText("Docs");
|
||||
await toggleShowItem(page, "Review state", false);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the badge draws on a compact layout", async () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("compact.png") });
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toHaveCount(0);
|
||||
await toggleShowItem(page, "Review state", true);
|
||||
await expect(badge(page, "review")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,21 +1,22 @@
|
||||
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
findAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openAddProvider,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openProviderCatalog(page: Page) {
|
||||
async function openProviderCatalog(page: Page, viewport?: { width: number; height: number }) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
if (viewport) await page.setViewportSize(viewport);
|
||||
await openAddProvider(page);
|
||||
}
|
||||
|
||||
async function expectProviderArtwork(
|
||||
@@ -25,11 +26,11 @@ async function expectProviderArtwork(
|
||||
name: string,
|
||||
) {
|
||||
await findAcpCatalogProvider(page, name);
|
||||
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
|
||||
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
|
||||
// Catalog rows have no semantic role of their own; scope artwork to the named provider's row.
|
||||
const icon = page.getByTestId(`provider-catalog-row-${id}`).locator("svg").first();
|
||||
await expect(icon).toBeVisible();
|
||||
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
|
||||
await expect.soft(icon).toHaveAttribute("width", "24");
|
||||
// Artwork draws at the provider list's icon size, so the catalog and the list share a rail.
|
||||
await expect.soft(icon).toHaveAttribute("width", "16");
|
||||
const expectedPaths = await page.evaluate((xml) => {
|
||||
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
|
||||
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
|
||||
@@ -49,3 +50,20 @@ test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async
|
||||
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
|
||||
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
|
||||
});
|
||||
|
||||
/** The space between a row's icon and its label, as drawn. */
|
||||
async function gapAfterIcon(row: Locator, label: string): Promise<number> {
|
||||
const icon = await row.locator("svg").first().boundingBox();
|
||||
const text = await row.getByText(label, { exact: true }).boundingBox();
|
||||
if (!icon || !text) throw new Error(`${label} has no icon or label on screen`);
|
||||
return text.x - (icon.x + icon.width);
|
||||
}
|
||||
|
||||
test("catalog rows keep the provider list's icon rail on a phone", async ({ page }) => {
|
||||
await openProviderCatalog(page, { width: 390, height: 844 });
|
||||
await findAcpCatalogProvider(page, "Kiro CLI");
|
||||
|
||||
const listGap = await gapAfterIcon(page.getByTestId("provider-row-claude"), "Claude");
|
||||
const catalogGap = await gapAfterIcon(page.getByTestId("provider-catalog-row-kiro"), "Kiro CLI");
|
||||
expect(catalogGap).toBe(listGap);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openCustomModels, removeCustomModel } from "../support/helpers/custom-models";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
// A pressed control shows its own progress while the daemon answers, the way Button's `loading`
|
||||
// does: a spinner in place of its icon, no further presses and, on a labelled button, a
|
||||
// present-participle label.
|
||||
|
||||
const MODEL_ID = "in-flight-model";
|
||||
|
||||
test.describe("provider page work in flight", () => {
|
||||
test("says Adding... on the Add button while a custom model is saved", async ({ page }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openCustomModels(page);
|
||||
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
const sheet = page.getByTestId("add-custom-model-sheet");
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(MODEL_ID);
|
||||
gate.holdNextServerMessage("set_daemon_config_response");
|
||||
await sheet.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await gate.waitForHeldServerMessage("set_daemon_config_response");
|
||||
|
||||
const adding = sheet.getByRole("button", { name: "Adding...", exact: true });
|
||||
await expect(adding.getByRole("progressbar")).toBeVisible();
|
||||
await expect(adding).toBeDisabled();
|
||||
|
||||
gate.releaseHeldServerMessage("set_daemon_config_response");
|
||||
await expect(sheet).toBeHidden({ timeout: 30_000 });
|
||||
await removeCustomModel(page, MODEL_ID);
|
||||
});
|
||||
|
||||
test("shows the diagnostic report running again on its re-run button", async ({ page }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openCustomModels(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic report", exact: true }).click();
|
||||
const sheet = page.getByTestId("provider-diagnostic-sheet");
|
||||
const rerun = sheet.getByRole("button", { name: "Refresh diagnostic", exact: true });
|
||||
await expect(rerun).toBeEnabled({ timeout: 30_000 });
|
||||
// Copy has a report to copy once the first run is in.
|
||||
await expect(sheet.getByRole("button", { name: "Copy diagnostic" })).toBeEnabled();
|
||||
|
||||
gate.holdNextServerMessage("provider_diagnostic_response");
|
||||
await rerun.click();
|
||||
await gate.waitForHeldServerMessage("provider_diagnostic_response");
|
||||
|
||||
const running = sheet.getByRole("button", { name: "Refreshing diagnostic", exact: true });
|
||||
await expect(running.getByRole("progressbar")).toBeVisible();
|
||||
await expect(running).toBeDisabled();
|
||||
|
||||
gate.releaseHeldServerMessage("provider_diagnostic_response");
|
||||
await expect(rerun).toBeEnabled({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,15 @@ import type { Dialog } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
expectProviderNotOnHost,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
leaveProviderDetail,
|
||||
openAddProvider,
|
||||
openProviderDetail,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
@@ -62,13 +66,39 @@ async function clickRemoveProviderAndAcceptWarning(page: Page): Promise<Dialog>
|
||||
expect(dialog.message()).toContain("This deletes the provider entry from config.json.");
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`).click();
|
||||
await page.getByTestId("provider-remove").click();
|
||||
if (!warning) {
|
||||
throw new Error("Expected a provider removal confirmation dialog, but none was shown.");
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
const MISSING_NOTICE = '[data-testid="provider-detail-missing"]';
|
||||
|
||||
/** Records whether the page ever says its provider isn't on the host, however briefly. */
|
||||
async function watchForMissingNotice(page: Page): Promise<void> {
|
||||
await page.evaluate((selector) => {
|
||||
const flags = window as unknown as { sawMissingNotice?: boolean };
|
||||
flags.sawMissingNotice = false;
|
||||
new MutationObserver(() => {
|
||||
if (document.querySelector(selector)) flags.sawMissingNotice = true;
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
}, MISSING_NOTICE);
|
||||
}
|
||||
|
||||
async function expectMissingNoticeNeverShown(page: Page): Promise<void> {
|
||||
const saw = await page.evaluate(
|
||||
() => (window as unknown as { sawMissingNotice?: boolean }).sawMissingNotice,
|
||||
);
|
||||
expect(saw).toBe(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The daemon publishes the provider list without the removed provider before it answers the config
|
||||
* patch. Holding the answer keeps the page in that window.
|
||||
*/
|
||||
const CONFIG_RESPONSE = "set_daemon_config_response";
|
||||
|
||||
test.describe("provider removal", () => {
|
||||
test("removes a custom provider from Settings", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -79,20 +109,39 @@ test.describe("provider removal", () => {
|
||||
try {
|
||||
await removeCustomProvider(client);
|
||||
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
|
||||
await expect(page.getByTestId("provider-actions-claude")).toHaveCount(0);
|
||||
await openAddProviderArea(page);
|
||||
// A built-in provider has no Danger zone.
|
||||
await openProviderDetail(page, "Claude");
|
||||
await expect(page.getByTestId("provider-remove")).toHaveCount(0);
|
||||
await leaveProviderDetail(page);
|
||||
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderSource(client, "custom");
|
||||
|
||||
await page.getByTestId(`provider-actions-${CUSTOM_PROVIDER.id}`).click();
|
||||
await expect(page.getByTestId(`provider-remove-${CUSTOM_PROVIDER.id}`)).toBeVisible();
|
||||
await openProviderDetail(page, CUSTOM_PROVIDER.name);
|
||||
await expect(page.getByTestId("provider-remove")).toBeVisible();
|
||||
await watchForMissingNotice(page);
|
||||
gate.holdNextServerMessage(CONFIG_RESPONSE);
|
||||
await clickRemoveProviderAndAcceptWarning(page);
|
||||
await gate.waitForHeldServerMessage(CONFIG_RESPONSE);
|
||||
await expectProviderSource(client, undefined);
|
||||
// Until the removal is answered, the page keeps the provider and the pressed button says so.
|
||||
const removeButton = page.getByTestId("provider-remove");
|
||||
await expect(removeButton).toHaveText("Removing...");
|
||||
await expect(removeButton.getByRole("progressbar")).toBeVisible();
|
||||
await expect(removeButton).toBeDisabled();
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(CUSTOM_PROVIDER.name);
|
||||
gate.releaseHeldServerMessage(CONFIG_RESPONSE);
|
||||
// Removing leaves the provider's page for the list, without first calling it missing.
|
||||
await expect(page.getByTestId("provider-list")).toBeVisible();
|
||||
await expectMissingNoticeNeverShown(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
@@ -100,7 +149,33 @@ test.describe("provider removal", () => {
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expectProviderSource(client, undefined);
|
||||
} finally {
|
||||
await removeCustomProvider(client).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("says a provider removed elsewhere while its page is open isn't on this host", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
|
||||
clientIdPrefix: "provider-removal-e2e",
|
||||
});
|
||||
|
||||
try {
|
||||
await removeCustomProvider(client);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProvider(page);
|
||||
await installAcpCatalogProvider(page, CUSTOM_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, CUSTOM_PROVIDER.name);
|
||||
await openProviderDetail(page, CUSTOM_PROVIDER.name);
|
||||
|
||||
await removeCustomProvider(client);
|
||||
await expectProviderNotOnHost(page);
|
||||
} finally {
|
||||
await removeCustomProvider(client).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
@@ -130,7 +205,9 @@ test.describe("provider removal", () => {
|
||||
exact: true,
|
||||
});
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
|
||||
await openProviderDetail(page, PLUGIN_PROVIDER.name);
|
||||
await expect(page.getByTestId("provider-remove")).toHaveCount(0);
|
||||
await leaveProviderDetail(page);
|
||||
|
||||
// The provider survives a removeProviders patch pointed at it: the
|
||||
// plugin, not config.json, owns the definition.
|
||||
|
||||
@@ -97,7 +97,7 @@ async function hasFocusWithin(locator: Locator): Promise<boolean> {
|
||||
async function expectProviderSettingsVisible(page: Page) {
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Diagnostic", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Diagnostic report" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function exerciseProviderSettingsStack(page: Page) {
|
||||
@@ -109,7 +109,7 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
await expect(page.getByPlaceholder("e.g. openai/gpt-5")).not.toBeVisible({ timeout: 10_000 });
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Diagnostic report" }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
@@ -118,7 +118,7 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectProviderSettingsVisible(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
await expect(page.getByText("Checked just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
expectProviderNotOnHost,
|
||||
expectProviderPageLoaded,
|
||||
gotoProviderPage,
|
||||
openSettingsHost,
|
||||
openHostSection,
|
||||
expectHostLabelDisplayed,
|
||||
@@ -262,6 +265,20 @@ test.describe("Settings host page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("provider page from its URL", () => {
|
||||
test("loads a provider's page on a cold visit", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await gotoProviderPage(page, getServerId(), "claude");
|
||||
await expectProviderPageLoaded(page, "Claude");
|
||||
});
|
||||
|
||||
test("says a provider the host doesn't have isn't on this host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await gotoProviderPage(page, getServerId(), "not-a-provider");
|
||||
await expectProviderNotOnHost(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("restart recovery defaults off and persists opt-in and custom prompt", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
@@ -6,20 +7,31 @@ import {
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
closeCompactSettings,
|
||||
captureCompactSidebarReturn,
|
||||
openSettingsFromCompactSidebar,
|
||||
captureCompactSidebarContext,
|
||||
expectCompactSidebarOpen,
|
||||
closeCompactSidebar,
|
||||
scrollSettingsSidebarToEnd,
|
||||
revealSettingsSidebarSection,
|
||||
expectSettingsSidebarScrollSettled,
|
||||
scrollSettingsSidebarUp,
|
||||
changeSettingsInterfaceFontSize,
|
||||
selectSettingsHostSectionsRapidly,
|
||||
expectSettingsSidebarScrollOffset,
|
||||
scrollSettingsDetailDown,
|
||||
expectSettingsDetailScrollOffset,
|
||||
openSettingsSection,
|
||||
changeSettingsSyntaxTheme,
|
||||
revealSettingsSyntaxTheme,
|
||||
expectSettingsSyntaxColorChanged,
|
||||
changeSettingsTypography,
|
||||
normalizeSettingsFontSizes,
|
||||
changeSettingsInterfaceFontSizeKeepingSidebarPosition,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
selectHostConnectionType,
|
||||
@@ -84,13 +96,32 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position through interface font changes", async ({ page }) => {
|
||||
test("updates appearance without losing the detail scroll or form edits", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
const offset = await scrollSettingsSidebarToEnd(page);
|
||||
await changeSettingsInterfaceFontSize(page, 20);
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
const { offset, keywordColor } = await revealSettingsSyntaxTheme(page);
|
||||
await changeSettingsSyntaxTheme(page, "Dracula");
|
||||
await expectSettingsDetailScrollOffset(page, offset);
|
||||
await expectSettingsSyntaxColorChanged(page, keywordColor);
|
||||
await changeSettingsTypography(page);
|
||||
});
|
||||
|
||||
test("shows normalized font sizes after committing out-of-range and empty values", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await normalizeSettingsFontSizes(page);
|
||||
});
|
||||
|
||||
test("keeps the sidebar context through interface font changes", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await scrollSettingsSidebarToEnd(page);
|
||||
const offset = await changeSettingsInterfaceFontSizeKeepingSidebarPosition(page, 20);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
@@ -350,6 +381,87 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("returning from Settings preserves the open sidebar from the first paint", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-sidebar-continuity-" });
|
||||
for (let i = 0; i < 12; i++) await withWorkspace({ prefix: "settings-sidebar-scroll-" });
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSidebar(page);
|
||||
await expectCompactSidebarOpen(page);
|
||||
const expectContextPreserved = await captureCompactSidebarContext(page);
|
||||
const openingFrames = await captureCompactSidebarReturn(page, () =>
|
||||
openSettingsFromCompactSidebar(page),
|
||||
);
|
||||
await writeFile(
|
||||
testInfo.outputPath("sidebar-opening-frames.json"),
|
||||
JSON.stringify(openingFrames, null, 2),
|
||||
);
|
||||
expect(openingFrames.some((frame) => frame.settingsVisible)).toBe(true);
|
||||
expect(openingFrames.every((frame) => frame.sidebarVisible || frame.settingsVisible)).toBe(
|
||||
true,
|
||||
);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await goBackInSettings(page);
|
||||
const frames = await captureCompactSidebarReturn(page, () => closeCompactSettings(page));
|
||||
await writeFile(
|
||||
testInfo.outputPath("sidebar-return-frames.json"),
|
||||
JSON.stringify(frames, null, 2),
|
||||
);
|
||||
await testInfo.attach("sidebar-return-frames", {
|
||||
body: JSON.stringify(frames, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
const workspaceFrames = frames.filter((frame) => !frame.pathname.startsWith("/settings"));
|
||||
expect(workspaceFrames.length).toBeGreaterThan(0);
|
||||
expect(workspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
|
||||
const firstX = workspaceFrames[0]!.closeX;
|
||||
expect(
|
||||
workspaceFrames.every((frame) => Math.abs((frame.closeX ?? Infinity) - firstX!) < 1),
|
||||
).toBe(true);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await expectContextPreserved();
|
||||
await openSettingsFromCompactSidebar(page);
|
||||
const backFrames = await captureCompactSidebarReturn(page, async () => {
|
||||
await page.goBack();
|
||||
});
|
||||
await writeFile(
|
||||
testInfo.outputPath("browser-back-frames.json"),
|
||||
JSON.stringify(backFrames, null, 2),
|
||||
);
|
||||
await testInfo.attach("browser-back-frames", {
|
||||
body: JSON.stringify(backFrames, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
const backWorkspaceFrames = backFrames.filter(
|
||||
(frame) => !frame.pathname.startsWith("/settings"),
|
||||
);
|
||||
expect(backWorkspaceFrames.length).toBeGreaterThan(0);
|
||||
expect(backWorkspaceFrames.every((frame) => frame.sidebarVisible)).toBe(true);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await closeCompactSidebar(page);
|
||||
});
|
||||
|
||||
test("widening during Settings discards the mobile sidebar return", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-sidebar-resize-" });
|
||||
await openWorkspace(page, workspace);
|
||||
await openCompactSettings(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await page.setViewportSize({ width: 1000, height: 844 });
|
||||
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
await closeCompactSettings(page);
|
||||
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-tab-switcher-",
|
||||
@@ -377,6 +489,7 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
|
||||
await closeCompactSettings(page);
|
||||
await closeCompactSidebar(page);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
await switcher.click();
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { openWhatsNew, release, serveChangelog } from "../support/helpers/changelog";
|
||||
import {
|
||||
allChangesToggle,
|
||||
hideAllChanges,
|
||||
openWhatsNew,
|
||||
release,
|
||||
serveChangelog,
|
||||
showAllChanges,
|
||||
} from "../support/helpers/changelog";
|
||||
|
||||
const DISCORD_DESTINATION =
|
||||
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
|
||||
@@ -73,26 +80,30 @@ test("opens troubleshooting and support destinations", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("renders the changelog in the app and links the website", async ({ page }) => {
|
||||
// A callout, a section name the app has never seen, and a fenced sample whose
|
||||
// contents look like a release heading.
|
||||
// Highlights, a section name the app has never seen, a callout, and a fenced
|
||||
// sample whose contents look like a release heading.
|
||||
await serveChangelog(page, [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 9.1.0 - 2026-03-04",
|
||||
"",
|
||||
"Headline release note.",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> Read this before upgrading.",
|
||||
"- **Background tasks:** Paseo now shows the tasks an agent is running.",
|
||||
"",
|
||||
"### Sparkles",
|
||||
"",
|
||||
"- Added a brand new thing",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> Read this before upgrading.",
|
||||
"",
|
||||
"```md",
|
||||
"## 0.0.0 - 1999-01-01",
|
||||
"```",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a crash",
|
||||
"",
|
||||
"## 9.0.0 - 2026-02-01",
|
||||
"",
|
||||
"### Fixed",
|
||||
@@ -104,14 +115,32 @@ test("renders the changelog in the app and links the website", async ({ page })
|
||||
|
||||
const sheet = await openWhatsNew(page);
|
||||
const latest = release(sheet, "9.1.0");
|
||||
const older = release(sheet, "9.0.0");
|
||||
|
||||
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Headline release note.")).toBeVisible();
|
||||
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
|
||||
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
|
||||
await expect(release(sheet, "9.0.0")).toBeVisible();
|
||||
await expect(release(sheet, "0.0.0")).toHaveCount(0);
|
||||
await test.step("leads a release with its highlights and tucks the rest away", async () => {
|
||||
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Paseo now shows the tasks an agent is running.")).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toHaveCount(0);
|
||||
await expect(latest.getByText("Fixed a crash")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("shows every section on request and tucks them away again", async () => {
|
||||
await showAllChanges(latest);
|
||||
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
|
||||
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
|
||||
await expect(latest.getByText("Fixed a crash")).toBeVisible();
|
||||
await expect(release(sheet, "0.0.0")).toHaveCount(0);
|
||||
|
||||
await hideAllChanges(latest);
|
||||
await expect(latest.getByText("Added a brand new thing")).toHaveCount(0);
|
||||
await expect(latest.getByText("Paseo now shows the tasks an agent is running.")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("shows a release without highlights whole", async () => {
|
||||
await expect(older.getByText("Fixed an older thing")).toBeVisible();
|
||||
await expect(allChangesToggle(older)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await expectExternalPage(page, "changelog-open-website", CHANGELOG_DESTINATION);
|
||||
await closeSheet(page, "changelog-sheet");
|
||||
|
||||
@@ -146,7 +146,9 @@ async function installPinRpcGate(
|
||||
}
|
||||
|
||||
test.describe("Pin workspace shortcut", () => {
|
||||
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
|
||||
test("pins and unpins across sidebar collapse and keeps project creation reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pin-project-creation-",
|
||||
title: "Pinned workspace",
|
||||
@@ -169,11 +171,27 @@ test.describe("Pin workspace shortcut", () => {
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("keeps the pinned project available to workspace creation", async () => {
|
||||
await test.step("unpins the active workspace while the Pinned section is collapsed", async () => {
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(3);
|
||||
|
||||
await page.getByTestId("sidebar-pinned-section-header").click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(4);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("keeps the pinned project available to workspace creation", async () => {
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(5);
|
||||
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||
});
|
||||
@@ -203,27 +221,6 @@ test.describe("Pin workspace shortcut", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("unpins the active workspace while the Pinned section is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-unpin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("sidebar-pinned-section-header").click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
@@ -12,9 +13,13 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string
|
||||
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
||||
}
|
||||
|
||||
async function openRenameModal(page: Page, workspaceId: string) {
|
||||
function workspaceRenameInputTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-rename-input-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
@@ -25,40 +30,87 @@ async function openRenameModal(page: Page, workspaceId: string) {
|
||||
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
}
|
||||
|
||||
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
return input;
|
||||
/** Lets the app render a server message the gate just forwarded. */
|
||||
async function waitForTwoFrames(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
});
|
||||
}
|
||||
|
||||
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
|
||||
/** Sets the title from a second daemon client and waits until this page has rendered it. */
|
||||
async function renameFromAnotherClient(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
workspace: Awaited<ReturnType<typeof seedWorkspace>>,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, title);
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await waitForTwoFrames(page);
|
||||
}
|
||||
|
||||
interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
async function rowBox(page: Page, workspaceId: string): Promise<Box> {
|
||||
const box = await page.getByTestId(workspaceRowTestId(workspaceId)).boundingBox();
|
||||
if (!box) throw new Error(`sidebar row ${workspaceId} has no layout box`);
|
||||
return box;
|
||||
}
|
||||
|
||||
// In Model B the workspace title is its identity: renaming sets a custom title
|
||||
// layered over the derived branch/directory name, and reconciliation never
|
||||
// touches it. The sidebar row shows the title verbatim — no branch mutation.
|
||||
test.describe("Sidebar workspace rename", () => {
|
||||
test("renaming via kebab sets a custom title that survives reload", async ({ page }) => {
|
||||
test("renaming in place sets a custom title without moving the row, and survives reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
|
||||
|
||||
try {
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const restingBox = await rowBox(page, workspace.workspaceId);
|
||||
|
||||
const input = await openRenameModal(page, workspace.workspaceId);
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
await expect(input).toBeFocused();
|
||||
await expect(input).toBeEditable();
|
||||
await expect(input).toHaveValue("main");
|
||||
await expect(
|
||||
page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input")),
|
||||
).toHaveCount(0);
|
||||
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
|
||||
|
||||
const customTitle = "Payments Refactor";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||
// The whole title is selected, so typing replaces it.
|
||||
await page.keyboard.type(customTitle);
|
||||
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
// The title is shown exactly as typed — not slugified into a branch name.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||
customTitle,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
expect(await rowBox(page, workspace.workspaceId)).toEqual(restingBox);
|
||||
|
||||
// The custom title is backing metadata on the workspace: a full reload
|
||||
// re-resolves the descriptor from persistence and must not lose it. This
|
||||
@@ -73,6 +125,259 @@ test.describe("Sidebar workspace rename", () => {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape discards an in-place rename and clicking away saves it", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-blur-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await page.keyboard.type("Discarded title");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("main");
|
||||
await expect(row).not.toContainText("Discarded title");
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
// Clicking inside the field places the caret; it does not press the row underneath.
|
||||
await input.click();
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Saved on blur");
|
||||
// Click empty space in the main pane, away from any control.
|
||||
await page.mouse.click(900, 700);
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(row).toContainText("Saved on blur");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the submitted title stays on screen until the renamed workspace arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-hold-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await page.keyboard.type("Held title");
|
||||
// The daemon answers the rename before it pushes the renamed workspace.
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
|
||||
await expect(input).toHaveValue("Held title");
|
||||
await expect(input).not.toBeEditable();
|
||||
await expect(row).not.toContainText("main");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Held title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an untouched in-place rename keeps a title set elsewhere while it was open", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await expect(input).toHaveValue("main");
|
||||
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
await page.mouse.click(900, 700);
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("External title");
|
||||
expect(gate.getClientRequestCount("workspace.title.set.request")).toBe(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("saving after a rename elsewhere holds the submitted title until it arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-save-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
await input.fill("Submitted title");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
await expect(row).not.toContainText("External title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Submitted title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("submitting the title another client just set closes the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-same-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
// The daemon accepts it, but the title does not change, so no renamed workspace follows.
|
||||
await input.fill("External title");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("External title");
|
||||
|
||||
// The row is a row again: its menu opens and renames.
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a rename elsewhere delivered while saving does not close the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-pending-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Submitted title");
|
||||
|
||||
// Another client's rename lands just before ours. The page receives, in daemon order: that
|
||||
// rename, our response, our renamed workspace. Each is held until the previous is rendered.
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "External title");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.holdNextServerMessage("workspace.title.set.response");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace.title.set.response");
|
||||
|
||||
// Releases the oldest held update: the other client's. Ours stays held behind it.
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace.title.set.response");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
await expect(row).not.toContainText("External title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Submitted title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a later rename that replaces the saved title closes the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-superseded-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Submitted title");
|
||||
|
||||
// The daemon builds each update from its latest record, so a rename that lands after ours
|
||||
// is accepted can be published in its place. Our update stays held: the page never sees it.
|
||||
gate.holdNextServerMessage("workspace.title.set.response");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace.title.set.response");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.releaseHeldServerMessage("workspace.title.set.response");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "Later title");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Later title");
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("below the lg breakpoint renaming opens the modal", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 800, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
|
||||
const input = page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "input"));
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId))).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await input.fill("Modal Title");
|
||||
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||
"Modal Title",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "../support/helpers/usage-login";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
closeUsage,
|
||||
expectPinnedUsage,
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
@@ -79,12 +80,17 @@ function twoHoursAgo(): string {
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
async function qaScreenshot(page: Page, name: string) {
|
||||
async function qaScreenshot(page: Page, name: string, target: "page" | "summary" = "page") {
|
||||
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`) });
|
||||
const file = path.join(directory, `${name}.png`);
|
||||
if (target === "summary") {
|
||||
await usageItem(page).screenshot({ path: file });
|
||||
return;
|
||||
}
|
||||
await page.screenshot({ path: file });
|
||||
}
|
||||
|
||||
function hostFilter(page: Page) {
|
||||
@@ -628,6 +634,15 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await qaScreenshot(page, `usage-error-detail-${compact ? "compact" : "desktop"}`);
|
||||
await closeUsage(page);
|
||||
await qaScreenshot(page, `usage-error-summary-${compact ? "compact" : "desktop"}`, "summary");
|
||||
await openUsageFromItem(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
@@ -639,6 +654,8 @@ for (const compact of [false, true]) {
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await expectPinnedUsage(page, ["42% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
@@ -647,6 +664,9 @@ for (const compact of [false, true]) {
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await expectPinnedUsage(page, ["43% Weekly"]);
|
||||
await expect(usageItem(page).getByTestId("sidebar-usage-error")).toHaveCount(0);
|
||||
await expect(usageItem(page)).not.toHaveAccessibleName(/Unable to refresh usage/);
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
openCompactSidebar,
|
||||
openUsageFromIcon,
|
||||
openUsageFromItem,
|
||||
openUsageFromItemWithPressFeedback,
|
||||
pinRow,
|
||||
scrollUsageToWindow,
|
||||
setSummaryInSidebar,
|
||||
@@ -197,7 +198,7 @@ test.describe("Usage item", () => {
|
||||
});
|
||||
|
||||
await test.step("the summary opens the same sheet", async () => {
|
||||
await openUsageFromItem(page);
|
||||
await openUsageFromItemWithPressFeedback(page);
|
||||
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// A long user message paints capped, with its fade and Show more, in the frame it mounts in. These
|
||||
// tests sample every painted frame from before the chat opens and require each long message to
|
||||
// show one state, the settled one, for its whole life on screen.
|
||||
|
||||
interface PreviewFrame {
|
||||
height: number;
|
||||
fade: boolean;
|
||||
control: string | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__previewFrames?: Record<string, PreviewFrame[]>;
|
||||
}
|
||||
}
|
||||
|
||||
function longPrompt(name: string) {
|
||||
return Array.from({ length: 40 }, (_, line) => `${name} line ${line + 1}`).join("\n");
|
||||
}
|
||||
|
||||
async function recordPreviewFrames(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const frames: Record<string, PreviewFrame[]> = {};
|
||||
window.__previewFrames = frames;
|
||||
const sample = () => {
|
||||
for (const row of document.querySelectorAll('[data-testid="user-message"]')) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (rect.height === 0) continue;
|
||||
const text = row.querySelector('[data-message-text="true"]');
|
||||
if (!text) continue;
|
||||
const name = (text.textContent ?? "").split(" line ")[0];
|
||||
// The preview is the ancestor that carries the height cap; the fade paints inside it.
|
||||
let preview = text.parentElement;
|
||||
while (preview && preview !== row && getComputedStyle(preview).maxHeight === "none") {
|
||||
preview = preview.parentElement;
|
||||
}
|
||||
const control = [...row.querySelectorAll("*")].find(
|
||||
(node) => node.children.length === 0 && /^Show (more|less)$/.test(node.textContent ?? ""),
|
||||
);
|
||||
const frame = {
|
||||
height: Math.round(rect.height),
|
||||
fade: preview !== null && preview !== row && preview.querySelector("svg") !== null,
|
||||
control: control?.textContent ?? null,
|
||||
};
|
||||
const seen = (frames[name] ??= []);
|
||||
if (JSON.stringify(seen.at(-1)) !== JSON.stringify(frame)) seen.push(frame);
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPaintedCollapsedFromFirstFrame(page: Page, name: string) {
|
||||
const message = page.getByTestId("user-message").filter({ hasText: `${name} line 1` });
|
||||
await expect(message.getByRole("button", { name: "Show more" })).toBeVisible();
|
||||
// Let any late measurement land before reading the frames.
|
||||
await page.waitForTimeout(500);
|
||||
const frames = await page.evaluate((key) => window.__previewFrames?.[key] ?? [], name);
|
||||
expect(frames).toEqual([{ height: expect.any(Number), fade: true, control: "Show more" }]);
|
||||
}
|
||||
|
||||
test.describe("long user message preview", () => {
|
||||
test("opening a chat paints its long message collapsed from the first frame", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-open-",
|
||||
title: "Preview open",
|
||||
initialPrompt: longPrompt("Opened prompt"),
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await recordPreviewFrames(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
|
||||
|
||||
await page.reload();
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a long message arriving in an open chat paints collapsed from the first frame", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-arrive-",
|
||||
title: "Preview arrive",
|
||||
initialPrompt: "short",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await recordPreviewFrames(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Arrived prompt"));
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Arrived prompt");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a long message that arrived while its tab was hidden paints collapsed on return", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-hidden-",
|
||||
title: "Preview first",
|
||||
initialPrompt: "short",
|
||||
});
|
||||
try {
|
||||
await agent.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId,
|
||||
title: "Preview second",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await recordPreviewFrames(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
await page.getByRole("button", { name: "Preview second", exact: true }).click();
|
||||
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Hidden prompt"));
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await page.getByRole("button", { name: "Preview first", exact: true }).click();
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Hidden prompt");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,7 @@ async function expectForkFailureWithoutOverlappingStatus(page: Page) {
|
||||
async function observeToastReplacement(page: Page) {
|
||||
const observation = await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "Reconnecting to host" })
|
||||
.filter({ hasText: "Reconnecting" })
|
||||
.evaluateHandle((toast) => {
|
||||
const frames: Array<{ connected: boolean; opacity: number; transform: string }> = [];
|
||||
let frame = 0;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
// The browser owns Cmd+Shift+brackets for its own tabs, so web keeps
|
||||
// Alt+Shift+brackets on every OS. Desktop bindings live in the desktop suite.
|
||||
test("web switches to the previous and next tab with Alt+Shift+[ and ]", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-web-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const terminalTabs = page
|
||||
.locator('[data-testid^="workspace-tab-terminal_"]')
|
||||
.filter({ visible: true });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalTabs).toHaveCount(2, { timeout: 30_000 });
|
||||
const first = terminalTabs.nth(0);
|
||||
const second = terminalTabs.nth(1);
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketLeft");
|
||||
await expect(first).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.keyboard.press("Alt+Shift+BracketRight");
|
||||
await expect(second).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -50,73 +50,64 @@ async function readClipboard(page: Page): Promise<string> {
|
||||
return page.evaluate(() => navigator.clipboard.readText());
|
||||
}
|
||||
|
||||
test.describe("Workspace terminal tab rename", () => {
|
||||
test("right-click copy terminal id writes the terminal id to the clipboard", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
function terminalTab(page: Page, terminalId: string) {
|
||||
return page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
}
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-copy-id-" });
|
||||
let terminalId: string | null = null;
|
||||
async function copyTerminalId(page: Page, terminalId: string): Promise<void> {
|
||||
const tab = terminalTab(page, terminalId);
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
await tab.click({ button: "right" });
|
||||
const copy = page.getByRole("menuitem", { name: /^Copy terminal id/ });
|
||||
await expect(copy).toBeVisible({ timeout: 10_000 });
|
||||
await copy.click();
|
||||
}
|
||||
|
||||
try {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
terminalId = await waitForCreatedTerminalId(workspace);
|
||||
async function renameTerminalTab(page: Page, terminalId: string, title: string): Promise<void> {
|
||||
const tab = terminalTab(page, terminalId);
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
await tab.click({ button: "right" });
|
||||
const rename = page.getByRole("menuitem", { name: "Rename", exact: true });
|
||||
await expect(rename).toBeVisible({ timeout: 10_000 });
|
||||
await rename.click();
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill(title);
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
const copyTerminalId = page.getByRole("menuitem", {
|
||||
name: /^Copy terminal id/,
|
||||
test("terminal context menu copies its ID and persists a renamed title", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
|
||||
let terminalId: string | null = null;
|
||||
|
||||
try {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
const createdTerminalId = await waitForCreatedTerminalId(workspace);
|
||||
terminalId = createdTerminalId;
|
||||
|
||||
await test.step("copy the terminal ID from its tab", async () => {
|
||||
await copyTerminalId(page, createdTerminalId);
|
||||
await expect.poll(() => readClipboard(page)).toBe(createdTerminalId);
|
||||
});
|
||||
|
||||
await test.step("rename the same terminal and read back its saved title", async () => {
|
||||
await renameTerminalTab(page, createdTerminalId, "My Renamed Terminal");
|
||||
await expect(terminalTab(page, createdTerminalId)).toContainText("My Renamed Terminal", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
|
||||
await copyTerminalId.click();
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toBe(terminalId);
|
||||
} finally {
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
test("right-click rename persists the terminal title and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
|
||||
let terminalId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
terminalId = await waitForCreatedTerminalId(workspace);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await input.fill("My Renamed Terminal");
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
|
||||
await expect
|
||||
.poll(() => fetchTerminalTitle(workspace, terminalId!))
|
||||
.poll(() => fetchTerminalTitle(workspace, createdTerminalId))
|
||||
.toBe("My Renamed Terminal");
|
||||
} finally {
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await cleanupTerminal(workspace, terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
readReactRenders,
|
||||
recordReactRenders,
|
||||
resetReactRenders,
|
||||
} from "../support/helpers/react-renders";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
async function createMockAgent(workspace: SeededWorkspace, title: string): Promise<string> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
test.describe("Updates to agents in other workspaces", () => {
|
||||
let open: SeededWorkspace;
|
||||
let other: SeededWorkspace;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await open?.cleanup();
|
||||
await other?.cleanup();
|
||||
});
|
||||
|
||||
test("do not re-render the open workspace screen", async ({ page }) => {
|
||||
open = await seedWorkspace({ repoPrefix: "unrelated-update-open-", title: "Open" });
|
||||
other = await seedWorkspace({ repoPrefix: "unrelated-update-other-", title: "Other" });
|
||||
const openAgentId = await createMockAgent(open, "Open agent");
|
||||
const otherAgentId = await createMockAgent(other, "Other agent");
|
||||
|
||||
await recordReactRenders(page);
|
||||
await openAgentRoute(page, { workspaceId: open.workspaceId, agentId: openAgentId });
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${openAgentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForTimeout(2_000);
|
||||
await resetReactRenders(page);
|
||||
|
||||
await other.client.sendAgentMessage(otherAgentId, "update");
|
||||
await other.client.waitForFinish(otherAgentId, 60_000);
|
||||
// The favicon follows every agent on the host, so it renders when the other agent runs and
|
||||
// settles. That confirms the updates reached this page.
|
||||
await expect
|
||||
.poll(async () => (await readReactRenders(page)).FaviconStatusSync ?? 0)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const renders = await readReactRenders(page);
|
||||
expect(renders.WorkspaceScreenContent ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
restoreArchivedWorkspace,
|
||||
@@ -300,9 +299,7 @@ test.describe("Worktree restore", () => {
|
||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("restores the workspace before explicitly unarchiving each selected agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("restores the workspace and all agents closed by its archive", async ({ page }) => {
|
||||
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
|
||||
agentCount: 2,
|
||||
keepAgentsArchived: true,
|
||||
@@ -330,13 +327,11 @@ test.describe("Worktree restore", () => {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull();
|
||||
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, firstAgent.id)).toBeNull();
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, secondAgent.id)).toBeNull();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -346,13 +341,8 @@ test.describe("Worktree restore", () => {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
});
|
||||
|
||||
test("restores a branchless workspace so its archived agent can be used again", async ({
|
||||
@@ -410,12 +400,7 @@ test.describe("Worktree restore", () => {
|
||||
await expect
|
||||
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
|
||||
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toHaveCount(0);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
||||
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
|
||||
@@ -506,7 +491,11 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
|
||||
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
|
||||
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
|
||||
const screenshotDirectory = path.join(
|
||||
process.cwd(),
|
||||
".artifacts",
|
||||
"paseo-branchless-restore-evidence",
|
||||
);
|
||||
mkdirSync(screenshotDirectory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ env APP_ID=sh.paseo.debug
|
||||
|
||||
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
|
||||
# unselected PINNED workspace row. Ordinary workspace rows are not draggable.
|
||||
# Hold past drag arming (180ms), then release.
|
||||
# Hold past press feedback (130ms) and drag arming (180ms), then release.
|
||||
open "${APP_ID}"
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"sidebar-close\"" 10000
|
||||
@@ -12,7 +12,7 @@ press "id=\"sidebar-close\""
|
||||
wait "label=\"Open menu\"" 5000
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"${WORKSPACE_ROW}\"" 10000
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 250
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 400
|
||||
press "id=\"${WORKSPACE_ROW}\""
|
||||
# A released drag must not swallow the next tap: navigation closes the sidebar.
|
||||
wait "label=\"Open menu\"" 5000
|
||||
|
||||
@@ -85,6 +85,20 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
|
||||
--artifacts-dir /tmp/paseo-model-tablet-qa
|
||||
```
|
||||
|
||||
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
|
||||
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
|
||||
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
|
||||
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
|
||||
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
|
||||
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
|
||||
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
|
||||
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
|
||||
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
|
||||
```
|
||||
|
||||
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
|
||||
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
|
||||
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
|
||||
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env PROVIDER_ID=codex
|
||||
|
||||
open "${APP_ID}" "${FORM_URL}"
|
||||
wait "label=\"Workspace project\"" 20000
|
||||
press "id=\"combined-model-selector\""
|
||||
wait "id=\"combobox-desktop-container\"" 10000
|
||||
# A remembered model opens its provider's list. Return to the provider page first.
|
||||
press "label=\"Back\""
|
||||
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
|
||||
press "id=\"model-provider-${PROVIDER_ID}\""
|
||||
wait "id=\"compact-model-list\"" 3000
|
||||
# The row's profile action sits inside Android's independent scroll viewport.
|
||||
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
|
||||
wait "text=\"Add agent profile\"" 5000
|
||||
# Closing the form keeps the remembered model: the tap did not also select the row.
|
||||
back
|
||||
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
|
||||
close
|
||||
@@ -0,0 +1,83 @@
|
||||
// Record a sidebar toast through timeout on the 2560×1600 Android tablet.
|
||||
// Pass a vertical sample line through the card's plain interior, away from text,
|
||||
// icons, and Android's clipboard preview, plus the visible-to-dismissed interval:
|
||||
// node .../toast-dismissal.mjs recording.mp4 sample-x start-seconds end-seconds
|
||||
// This checks actual native frames; a browser layout test misses this regression.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , recording, sampleX, start, end] = process.argv;
|
||||
assert(recording && sampleX && start && end, "Pass recording, sample x, start and end seconds");
|
||||
const { frames } = JSON.parse(
|
||||
execFileSync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v",
|
||||
"-show_entries",
|
||||
"frame=best_effort_timestamp_time",
|
||||
"-of",
|
||||
"json",
|
||||
recording,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
const selectedFrames = frames
|
||||
.map((frame, index) => ({ time: Number(frame.best_effort_timestamp_time), index }))
|
||||
.filter(({ time }) => time >= Number(start) && time <= Number(end));
|
||||
assert(selectedFrames.length > 2, "Capture at least three frames in the supplied interval");
|
||||
const firstFrame = selectedFrames[0].index;
|
||||
const endFrame = selectedFrames.at(-1).index + 1;
|
||||
const bytesPerFrame = 800;
|
||||
// Bound both output and buffer to the requested frames, even for a long recording.
|
||||
const pixels = execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-v",
|
||||
"fatal",
|
||||
"-i",
|
||||
recording,
|
||||
"-vf",
|
||||
`trim=start_frame=${firstFrame}:end_frame=${endFrame},crop=2:400:${Number(sampleX)}:1100,format=gray`,
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
],
|
||||
{ maxBuffer: selectedFrames.length * bytesPerFrame + 1024 },
|
||||
);
|
||||
assert.equal(
|
||||
pixels.length,
|
||||
selectedFrames.length * bytesPerFrame,
|
||||
"Every selected frame must be decoded",
|
||||
);
|
||||
const positions = [];
|
||||
let disappeared = false;
|
||||
for (const [index, { time }] of selectedFrames.entries()) {
|
||||
const column = pixels.subarray(index * bytesPerFrame, (index + 1) * bytesPerFrame);
|
||||
const runs = [];
|
||||
let run = [];
|
||||
for (let y = 0; y < 400; y++) {
|
||||
if (column[y * 2] > 250) run.push(y);
|
||||
else {
|
||||
if (run.length > 40) runs.push(run);
|
||||
run = [];
|
||||
}
|
||||
}
|
||||
if (run.length > 40) runs.push(run);
|
||||
runs.sort((a, b) => b.length - a.length);
|
||||
if (runs.length) {
|
||||
const card = runs[0];
|
||||
positions.push({ time, centre: 1100 + (card[0] + card.at(-1)) / 2 });
|
||||
} else if (positions.length) disappeared = true;
|
||||
}
|
||||
assert(positions.length > 2, "Capture at least three visible card frames");
|
||||
assert(disappeared, "Recording must include the card disappearing");
|
||||
const centres = positions.map(({ centre }) => centre);
|
||||
const shift = Math.max(...centres) - Math.min(...centres);
|
||||
console.log(JSON.stringify({ positions, maximumShiftPixels: shift }, null, 2));
|
||||
assert(shift <= 2, `Toast moved ${shift}px during dismissal`);
|
||||
@@ -505,10 +505,14 @@ export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
const action = pickerViewport(page).getByTestId(
|
||||
`model-create-profile-${input.provider}-${input.modelId}`,
|
||||
);
|
||||
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
|
||||
await expect(action).toHaveCSS("pointer-events", "none");
|
||||
await modelRow(page, input).hover();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
await expect(action).toHaveCSS("pointer-events", "auto");
|
||||
await action.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
|
||||
@@ -27,3 +27,18 @@ export async function openWhatsNew(page: Page): Promise<Locator> {
|
||||
export function release(sheet: Locator, version: string): Locator {
|
||||
return sheet.getByTestId(`changelog-release-${version}`);
|
||||
}
|
||||
|
||||
/** The one control a release with highlights has for its sections, in either state. */
|
||||
export function allChangesToggle(entry: Locator): Locator {
|
||||
return entry.getByRole("button", { name: /^(Show|Hide) all changes$/ });
|
||||
}
|
||||
|
||||
export async function showAllChanges(entry: Locator): Promise<void> {
|
||||
await entry.getByRole("button", { name: "Show all changes" }).click();
|
||||
await expect(entry.getByRole("button", { name: "Hide all changes" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function hideAllChanges(entry: Locator): Promise<void> {
|
||||
await entry.getByRole("button", { name: "Hide all changes" }).click();
|
||||
await expect(entry.getByRole("button", { name: "Show all changes" })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** The tones a composer toolbar control paints: its glyph's stroke and its label's text color. */
|
||||
export interface ComposerControlTone {
|
||||
/** The visible glyph's stroke, or null when the control paints no glyph. */
|
||||
glyph: string | null;
|
||||
/** The visible label's color, or null when the control is icon-only. */
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export function composerControl(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the colors the control actually paints. The toolbar glyph and its label are separate
|
||||
* nodes, so this is the only way to tell whether they match.
|
||||
*/
|
||||
export async function readComposerControlTone(control: Locator): Promise<ComposerControlTone> {
|
||||
return control.evaluate((element) => {
|
||||
const visible = (node: Element) => node.getClientRects().length > 0;
|
||||
const glyph = Array.from(element.querySelectorAll("svg")).find(visible);
|
||||
const label = Array.from(element.querySelectorAll("*"))
|
||||
.filter(visible)
|
||||
.find((node) =>
|
||||
[...node.childNodes].some(
|
||||
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim(),
|
||||
),
|
||||
);
|
||||
return {
|
||||
glyph: glyph ? getComputedStyle(glyph).stroke : null,
|
||||
label: label ? getComputedStyle(label).color : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function expectGlyphMatchesLabel(tone: ComposerControlTone, name: string): void {
|
||||
expect(tone.label, `${name} paints a label`).not.toBeNull();
|
||||
expect(tone.glyph, `${name}'s glyph matches its label`).toBe(tone.label);
|
||||
}
|
||||
|
||||
export function expectIconOnlyGlyph(tone: ComposerControlTone, name: string): void {
|
||||
expect(tone.label, `${name} is icon-only`).toBeNull();
|
||||
expect(tone.glyph, `${name} paints a glyph`).not.toBeNull();
|
||||
}
|
||||
|
||||
/** The color the browser paints in a text input's placeholder. */
|
||||
export async function readComposerPlaceholderTone(input: Locator): Promise<string | null> {
|
||||
return input.evaluate((element) => getComputedStyle(element, "::placeholder").color);
|
||||
}
|
||||
|
||||
/** The color the browser paints for a text node. */
|
||||
export async function readComposerTextTone(text: Locator): Promise<string | null> {
|
||||
return text.evaluate((element) => getComputedStyle(element).color);
|
||||
}
|
||||
@@ -49,6 +49,83 @@ export async function moveOntoContextWindowDetails(page: Page, card: Locator): P
|
||||
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
|
||||
}
|
||||
|
||||
/** The hover card as one painted frame showed it, or unmounted. */
|
||||
export interface ContextWindowFrame {
|
||||
mounted: boolean;
|
||||
bottom: number | null;
|
||||
loading: boolean;
|
||||
usageCards: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the hover card as it was painted, every frame, until stopped, so a state that lasted one
|
||||
* frame is seen. Stop it only after everything has settled.
|
||||
*/
|
||||
export async function recordContextWindowFrames(
|
||||
page: Page,
|
||||
): Promise<{ stop: () => Promise<ContextWindowFrame[]> }> {
|
||||
await page.evaluate(() => {
|
||||
const recorder = window as unknown as {
|
||||
__contextWindowFrames: ContextWindowFrame[];
|
||||
__contextWindowRecording: boolean;
|
||||
};
|
||||
recorder.__contextWindowFrames = [];
|
||||
recorder.__contextWindowRecording = true;
|
||||
const findCard = () =>
|
||||
document.querySelector<HTMLElement>('[data-testid="context-window-details"]');
|
||||
const read = (card: HTMLElement | null): ContextWindowFrame => ({
|
||||
mounted: card !== null,
|
||||
bottom: card?.getBoundingClientRect().bottom ?? null,
|
||||
loading: card?.innerText.includes("Loading usage...") ?? false,
|
||||
usageCards: card?.querySelectorAll('[data-testid^="usage-report-"]').length ?? 0,
|
||||
});
|
||||
// A card that just resized has not been painted yet: its frame is read from an observer,
|
||||
// which runs before that paint.
|
||||
let watched: HTMLElement | null = null;
|
||||
let paintedHeight: number | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
const watch = (card: HTMLElement) => {
|
||||
observer = new ResizeObserver(() => {
|
||||
paintedHeight = card.offsetHeight;
|
||||
recorder.__contextWindowFrames.push(read(card));
|
||||
});
|
||||
observer.observe(card);
|
||||
};
|
||||
const channel = new MessageChannel();
|
||||
// A message posted from an animation frame runs after that frame's paint.
|
||||
channel.port1.addEventListener("message", () => {
|
||||
const card = findCard();
|
||||
if (card !== watched) {
|
||||
observer?.disconnect();
|
||||
watched = card;
|
||||
paintedHeight = null;
|
||||
if (card) watch(card);
|
||||
}
|
||||
if (!card || card.offsetHeight === paintedHeight) {
|
||||
recorder.__contextWindowFrames.push(read(card));
|
||||
}
|
||||
});
|
||||
channel.port1.start();
|
||||
const sample = () => {
|
||||
if (!recorder.__contextWindowRecording) return;
|
||||
channel.port2.postMessage(null);
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
return {
|
||||
stop: () =>
|
||||
page.evaluate(() => {
|
||||
const recorder = window as unknown as {
|
||||
__contextWindowFrames: ContextWindowFrame[];
|
||||
__contextWindowRecording: boolean;
|
||||
};
|
||||
recorder.__contextWindowRecording = false;
|
||||
return recorder.__contextWindowFrames;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
@@ -139,6 +216,8 @@ export interface AgentUsageScript {
|
||||
agentRequests(): string[];
|
||||
/** Each scoped agent Refresh, by agent ID. */
|
||||
refreshedAgents(): string[];
|
||||
/** Reloads the agent's session, which starts it under a new usage scope, as a resume does. */
|
||||
resumeAgent(agent: MockAgentSession): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,6 +248,8 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
usage
|
||||
.listRequests()
|
||||
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
|
||||
resumeAgent: (agent) =>
|
||||
usage.untilUsageScopeChanges(agent.agentId, () => agent.client.refreshAgent(agent.agentId)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,8 @@ export async function createCreationScenario(page: Page) {
|
||||
const requests = await observeCreationRequests(page);
|
||||
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
|
||||
let workspaceId = project.workspaceId;
|
||||
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
|
||||
const evictionWorkspaceIds = new Set<string>();
|
||||
return {
|
||||
cleanup: project.cleanup,
|
||||
async openWorkspaceForm(isolation: "local" | "worktree") {
|
||||
@@ -232,7 +234,9 @@ export async function createCreationScenario(page: Page) {
|
||||
await requests.settled();
|
||||
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
workspaceId = workspaces[0]!.id;
|
||||
@@ -276,6 +280,7 @@ export async function createCreationScenario(page: Page) {
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
|
||||
evictionWorkspaceIds.add(result.workspace.id);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await searchModels(page, id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
@@ -33,8 +33,20 @@ export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await searchModels(page, id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the model list and scrolls the sheet down to the custom models, since other sections push
|
||||
* them below the fold at phone width. Only the section heading is scrolled into view: scrolling the
|
||||
* row itself would also slide its card sideways and hide a Remove button pushed past the right edge.
|
||||
*/
|
||||
async function searchModels(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await page
|
||||
.getByText("Custom models", { exact: true })
|
||||
.evaluate((heading) => heading.scrollIntoView({ block: "start" }));
|
||||
}
|
||||
|
||||
@@ -46,3 +46,12 @@ export async function downloadFileFromExplorer(page: Page, fileName: string): Pr
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
export async function downloadFileFromPreview(page: Page): Promise<Download> {
|
||||
await expect(page.getByText("Binary preview unavailable", { exact: true })).toBeVisible();
|
||||
const button = page.getByRole("button", { name: "Download", exact: true });
|
||||
await expect(button).toBeVisible();
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await button.click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,17 @@ export function setupModal(page: Page): Locator {
|
||||
return page.getByTestId("onboarding-modal").getByRole("dialog");
|
||||
}
|
||||
|
||||
/** A sheet opened from a setup step, which should sit exactly on the setup card. */
|
||||
export function setupSheet(page: Page, testID: "provider-settings-sheet" | "add-provider-sheet") {
|
||||
return page.getByTestId(testID).getByRole("dialog");
|
||||
}
|
||||
|
||||
export async function expectSheetCoversSetupCard(page: Page, sheet: Locator): Promise<void> {
|
||||
await expect(sheet).toBeVisible();
|
||||
const card = await setupModal(page).boundingBox();
|
||||
await expect.poll(() => sheet.boundingBox()).toEqual(card);
|
||||
}
|
||||
|
||||
export async function expectSetupStep(page: Page, title: string): Promise<void> {
|
||||
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
|
||||
const PLUGIN_ID = "composer-controls-example";
|
||||
@@ -57,3 +60,236 @@ export async function withComposerControlsExample(
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
const ONE_TOGGLE_PLUGIN_ID = "composer-toggle-and-watcher-e2e";
|
||||
const ONE_TOGGLE_NAME = "Sandbox flag";
|
||||
const ONE_TOGGLE_PLUGIN_SOURCE = `import { useComposer } from "@getpaseo/plugin/client";
|
||||
import { Toggle } from "@getpaseo/plugin/client/ui";
|
||||
import { useState } from "react";
|
||||
|
||||
function PromptWatcher() {
|
||||
useComposer();
|
||||
return null;
|
||||
}
|
||||
|
||||
function SandboxFlag() {
|
||||
const [value, setValue] = useState(false);
|
||||
return (
|
||||
<Toggle
|
||||
title="${ONE_TOGGLE_NAME}"
|
||||
icon="Lock"
|
||||
value={value}
|
||||
onValueChange={({ value: next }) => setValue(next)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addComposerItem({ id: "prompt-watcher", Component: PromptWatcher });
|
||||
client.addComposerItem({ id: "sandbox-flag", Component: SandboxFlag });
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
const PICKER_NAME = "Review state";
|
||||
const PICKER_PLUGIN_SOURCE = `import { Select } from "@getpaseo/plugin/client/ui";
|
||||
import { useState } from "react";
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "ready", label: "Ready" },
|
||||
];
|
||||
|
||||
function ReviewState() {
|
||||
const [value, setValue] = useState("draft");
|
||||
return (
|
||||
<Select
|
||||
title="${PICKER_NAME}"
|
||||
icon="CircleDot"
|
||||
value={value}
|
||||
options={OPTIONS}
|
||||
onValueChange={({ value: next }) => setValue(next)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addComposerItem({ id: "review-state", Component: ReviewState });
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
function toolbar(page: Page) {
|
||||
return page.getByTestId("agent-controls-toolbar").filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
interface ToolbarFrame {
|
||||
overflowButton: boolean;
|
||||
clipped: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__pluginToolbarFrames?: ToolbarFrame[];
|
||||
}
|
||||
}
|
||||
|
||||
/** From the first frame on, records whether the toolbar condensed or clipped. Call before loading. */
|
||||
export async function recordToolbarFramesFromLoad(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const frames: ToolbarFrame[] = [];
|
||||
window.__pluginToolbarFrames = frames;
|
||||
const tick = () => {
|
||||
const row = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-controls-toolbar"]'),
|
||||
).find((node) => node.getClientRects().length > 0);
|
||||
if (row) {
|
||||
frames.push({
|
||||
overflowButton: row.querySelector('[data-testid="agent-controls-features"]') !== null,
|
||||
clipped: row.scrollWidth > row.clientWidth,
|
||||
});
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectToolbarNeverCondensedOrClipped(page: Page): Promise<void> {
|
||||
const frames = await page.evaluate(() => window.__pluginToolbarFrames ?? []);
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(frames.filter((frame) => frame.overflowButton || frame.clipped)).toEqual([]);
|
||||
}
|
||||
|
||||
function agentFeaturesButton(page: Page) {
|
||||
return page.getByRole("button", { name: "Open agent features", exact: true });
|
||||
}
|
||||
|
||||
interface ToolbarPluginComposer {
|
||||
setWindowWidth(width: number): Promise<void>;
|
||||
expectToolbarWidthBetween(min: number, max: number): Promise<void>;
|
||||
expectToggleInlineAtFullDensity(): Promise<void>;
|
||||
expectToggleInOverflow(): Promise<void>;
|
||||
closeOverflowSheet(): Promise<void>;
|
||||
startRecordingDensity(): Promise<void>;
|
||||
expectAtMostOneDensityChange(): Promise<void>;
|
||||
openInlinePicker(): Promise<void>;
|
||||
expectPickerClosed(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Runs `run` in an agent's composer with one icon toggle and one item that renders nothing. */
|
||||
export function withToggleAndWatcherComposerItems(
|
||||
page: Page,
|
||||
run: (composer: ToolbarPluginComposer) => Promise<void>,
|
||||
) {
|
||||
return withToolbarPlugin(page, ONE_TOGGLE_PLUGIN_SOURCE, run);
|
||||
}
|
||||
|
||||
/** Runs `run` in an agent's composer with one plugin `Select` in the toolbar. */
|
||||
export function withPickerComposerItem(
|
||||
page: Page,
|
||||
run: (composer: ToolbarPluginComposer) => Promise<void>,
|
||||
) {
|
||||
return withToolbarPlugin(page, PICKER_PLUGIN_SOURCE, run);
|
||||
}
|
||||
|
||||
async function withToolbarPlugin(
|
||||
page: Page,
|
||||
pluginSource: string,
|
||||
run: (composer: ToolbarPluginComposer) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-measured-items-" });
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Measured items agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-measured-items-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: ONE_TOGGLE_PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await gotoAppShell(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await expect(toolbar(page)).toBeVisible({ timeout: 30_000 });
|
||||
await run({
|
||||
setWindowWidth: (width) =>
|
||||
test.step(`set the window to ${width} wide`, () =>
|
||||
page.setViewportSize({ width, height: 800 })),
|
||||
expectToolbarWidthBetween: (min, max) =>
|
||||
test.step(`the toolbar is between ${min} and ${max} wide`, async () => {
|
||||
await expect
|
||||
.poll(async () => (await toolbar(page).boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(min);
|
||||
expect((await toolbar(page).boundingBox())?.width ?? 0).toBeLessThanOrEqual(max);
|
||||
}),
|
||||
expectToggleInlineAtFullDensity: () =>
|
||||
test.step("the toggle is inline and the toolbar keeps its labels", async () => {
|
||||
const toggle = toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true });
|
||||
await expect(toggle).toBeVisible();
|
||||
await expect(agentFeaturesButton(page)).toHaveCount(0);
|
||||
await expect(toolbar(page).getByTestId("agent-thinking-selector")).toHaveText("Low");
|
||||
const row = await toolbar(page).boundingBox();
|
||||
const box = await toggle.boundingBox();
|
||||
expect(box && row && box.x + box.width <= row.x + row.width).toBe(true);
|
||||
}),
|
||||
expectToggleInOverflow: () =>
|
||||
test.step("the toggle has moved to the overflow sheet", async () => {
|
||||
await expect(
|
||||
toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }),
|
||||
).toHaveCount(0);
|
||||
await agentFeaturesButton(page).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }),
|
||||
).toBeVisible();
|
||||
}),
|
||||
closeOverflowSheet: () =>
|
||||
test.step("close the overflow sheet", async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("agent-features-sheet")).toHaveCount(0);
|
||||
}),
|
||||
startRecordingDensity: () =>
|
||||
test.step("record which density each frame paints", () =>
|
||||
page.evaluate(() => {
|
||||
const frames: boolean[] = [];
|
||||
const paint = () => {
|
||||
frames.push(
|
||||
document.querySelector('[data-testid="agent-controls-features"]') !== null,
|
||||
);
|
||||
window.requestAnimationFrame(paint);
|
||||
};
|
||||
window.requestAnimationFrame(paint);
|
||||
Object.assign(window, { __densityFrames: frames });
|
||||
})),
|
||||
expectAtMostOneDensityChange: () =>
|
||||
test.step("the density changed once at most", async () => {
|
||||
const changes = await page.evaluate(() => {
|
||||
const frames = (window as unknown as { __densityFrames: boolean[] }).__densityFrames;
|
||||
return frames.filter((frame, index) => index > 0 && frame !== frames[index - 1]).length;
|
||||
});
|
||||
expect(changes).toBeLessThanOrEqual(1);
|
||||
}),
|
||||
openInlinePicker: () =>
|
||||
test.step("open the plugin picker from the toolbar", async () => {
|
||||
await toolbar(page).getByRole("button", { name: PICKER_NAME, exact: true }).click();
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible();
|
||||
}),
|
||||
expectPickerClosed: () =>
|
||||
test.step("the plugin picker has closed", () =>
|
||||
expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0)),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(ONE_TOGGLE_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,37 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { attachComposerTestImage, openImageLightbox } from "./composer";
|
||||
import {
|
||||
contextWindowDetails,
|
||||
pressContextWindowMeter,
|
||||
seedAgentWithContextWindow,
|
||||
} from "./context-window";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import {
|
||||
expectNoSheetOpen,
|
||||
expectOpenTabCount,
|
||||
promptMentioning,
|
||||
sheet,
|
||||
tapSheetBackdrop,
|
||||
} from "./sheet";
|
||||
import { openSubagentFromCompactTrack, seedSubagent } from "./subagents";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
|
||||
import {
|
||||
createAgentTabFromMenu,
|
||||
dragTabIntoExplorer,
|
||||
ensureExplorerSidebar,
|
||||
} from "./workspace-tabs";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
|
||||
const FILE_LINK = "linked.ts:3";
|
||||
|
||||
type Workspace = Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
|
||||
@@ -50,6 +71,9 @@ export async function withComposerToolsExample(
|
||||
focusTheAgentTab(): Promise<void>;
|
||||
expectPanelFocusedAgent(focused: boolean): Promise<void>;
|
||||
expectPanelShowsReview(needsReview: boolean): Promise<void>;
|
||||
useCompactLayout(): Promise<void>;
|
||||
insertChecklistFromControlsSheet(): Promise<void>;
|
||||
expectComposerReadyToType(text: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-tools-", title: "Composer tools" });
|
||||
@@ -115,6 +139,23 @@ export async function withComposerToolsExample(
|
||||
expect(
|
||||
page.getByText(`Needs review: ${needsReview ? "yes" : "no"}`, { exact: true }),
|
||||
).toBeVisible()),
|
||||
useCompactLayout: () =>
|
||||
test.step("narrow the window to a phone", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(prompt(page).filter({ visible: true })).toBeVisible();
|
||||
}),
|
||||
insertChecklistFromControlsSheet: () =>
|
||||
test.step("press the Action in the composer's controls sheet", async () => {
|
||||
await page.getByRole("button", { name: /^Select model/ }).click();
|
||||
await page.getByText("Insert checklist", { exact: true }).click();
|
||||
}),
|
||||
expectComposerReadyToType: (text) =>
|
||||
test.step("the sheet is closed and the cursor is in the composer", async () => {
|
||||
await expectNoSheetOpen(page);
|
||||
const input = prompt(page).filter({ visible: true });
|
||||
await expect(input).toHaveValue(text);
|
||||
await expect(input).toBeFocused();
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("composer-controls-example");
|
||||
@@ -205,4 +246,516 @@ export async function withPluginWritingToAnotherWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin that reaches one agent's composer from a pill and from Command Center items, and asks
|
||||
* for focus, wherever the user is.
|
||||
*/
|
||||
export async function withPluginFocusingAComposer(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openOtherAgent(): Promise<void>;
|
||||
openTargetAgent(): Promise<void>;
|
||||
goToAnotherWorkspace(): Promise<void>;
|
||||
moveFocusOutOfComposer(): Promise<void>;
|
||||
pressPill(): Promise<void>;
|
||||
focusFromCommandCenter(): Promise<void>;
|
||||
selectPromptFromCommandCenter(): Promise<void>;
|
||||
useCompactLayout(): Promise<void>;
|
||||
coverComposerWithExplorerPanel(): Promise<void>;
|
||||
pressPanelFocusButton(): Promise<void>;
|
||||
expectExplorerPanelClosed(): Promise<void>;
|
||||
fillComposer(text: string): Promise<void>;
|
||||
expectOnTargetAgent(): Promise<void>;
|
||||
expectComposerFocused(): Promise<void>;
|
||||
typeAtCursor(text: string): Promise<void>;
|
||||
expectComposerText(text: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-focus-", title: "Composer focus" });
|
||||
const elsewhere = await seedWorkspace({ repoPrefix: "focus-away-", title: "Focus away" });
|
||||
const target = await createMockAgent(workspace, "Target agent");
|
||||
const other = await createMockAgent(workspace, "Other agent");
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-focus-"));
|
||||
const targetId = JSON.stringify(target.id);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "composer-focus", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { Pressable, Text } from "react-native";
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addComposerPill({
|
||||
id: "hello",
|
||||
workspaceId: ${JSON.stringify(workspace.workspaceId)},
|
||||
agentId: ${targetId},
|
||||
button: {
|
||||
title: "Add HELLO to the composer",
|
||||
icon: "Hand",
|
||||
label: "Hello",
|
||||
behavior: {
|
||||
kind: "action",
|
||||
async onPress() {
|
||||
for (const composer of client.composers({ agentId: ${targetId} })) {
|
||||
composer.insertText("HELLO");
|
||||
await composer.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
client.addWorkspacePanel({
|
||||
id: "tools",
|
||||
title: "Focus tools",
|
||||
icon: "ListChecks",
|
||||
locations: ["explorer"],
|
||||
Component() {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Focus the target agent"
|
||||
onPress={() => client.composers({ agentId: ${targetId} })[0]?.focus()}
|
||||
>
|
||||
<Text>Focus the target agent</Text>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
});
|
||||
client.addCommandCenterItem({
|
||||
id: "focus",
|
||||
title: "Composer focus: focus the target",
|
||||
icon: "Flag",
|
||||
context: "global",
|
||||
onSelect({ composers }) {
|
||||
return composers({ agentId: ${targetId} })[0].focus();
|
||||
},
|
||||
});
|
||||
client.addCommandCenterItem({
|
||||
id: "select",
|
||||
title: "Composer focus: select the prompt",
|
||||
icon: "Flag",
|
||||
context: "global",
|
||||
onSelect({ composers }) {
|
||||
const composer = composers({ agentId: ${targetId} })[0];
|
||||
return composer.focus({ selection: { start: 0, end: composer.prompt.length } });
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`);
|
||||
const workspaceRow = (id: string) =>
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`);
|
||||
const panelFocusButton = () =>
|
||||
page.getByRole("button", { name: "Focus the target agent", exact: true });
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await run({
|
||||
openOtherAgent: () =>
|
||||
test.step("open another agent, so the target's tab is not showing", async () => {
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: other.id });
|
||||
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
|
||||
}),
|
||||
openTargetAgent: () =>
|
||||
test.step("open the target agent", async () => {
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id });
|
||||
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
|
||||
}),
|
||||
goToAnotherWorkspace: () =>
|
||||
test.step("go to another workspace", async () => {
|
||||
await workspaceRow(elsewhere.workspaceId).click();
|
||||
await expect(targetTab().first()).toBeHidden();
|
||||
}),
|
||||
moveFocusOutOfComposer: () =>
|
||||
test.step("the composer does not have focus", async () => {
|
||||
await prompt(page).blur();
|
||||
await expect(prompt(page)).not.toBeFocused();
|
||||
}),
|
||||
pressPill: () =>
|
||||
test.step("press the pill", () => page.getByRole("button", { name: "Hello" }).click()),
|
||||
focusFromCommandCenter: () =>
|
||||
test.step("run the plugin's focus command", () =>
|
||||
runCommand(page, "Composer focus: focus the target")),
|
||||
selectPromptFromCommandCenter: () =>
|
||||
test.step("run the plugin's select command", () =>
|
||||
runCommand(page, "Composer focus: select the prompt")),
|
||||
useCompactLayout: () =>
|
||||
test.step("use a phone-sized window", () => page.setViewportSize(COMPACT)),
|
||||
coverComposerWithExplorerPanel: () =>
|
||||
test.step("open the plugin's panel in the Explorer, covering the composer", async () => {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu")
|
||||
.filter({ visible: true })
|
||||
.getByRole("menuitem", { name: "Focus tools", exact: true })
|
||||
.click();
|
||||
await expect(panelFocusButton()).toBeVisible();
|
||||
}),
|
||||
pressPanelFocusButton: () =>
|
||||
test.step("press the panel's button", () => panelFocusButton().click()),
|
||||
expectExplorerPanelClosed: () =>
|
||||
test.step("the Explorer no longer covers the composer", () =>
|
||||
expect(panelFocusButton()).toBeHidden({ timeout: 30_000 })),
|
||||
fillComposer: (text) =>
|
||||
test.step(`write "${text}" in the composer`, () => prompt(page).fill(text)),
|
||||
expectOnTargetAgent: () =>
|
||||
test.step("the target agent's tab is showing", async () => {
|
||||
await expect(targetTab().filter({ visible: true }).first()).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
expectComposerFocused: () =>
|
||||
test.step("the composer input has focus", () =>
|
||||
expect(prompt(page)).toBeFocused({ timeout: 30_000 })),
|
||||
typeAtCursor: (text) => test.step(`type "${text}"`, () => page.keyboard.type(text)),
|
||||
expectComposerText: (text) =>
|
||||
test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("composer-focus");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await elsewhere.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export { CHECKLIST };
|
||||
|
||||
type HeldComposerGlobal = typeof globalThis & {
|
||||
heldComposer?: {
|
||||
focus(options?: { selection?: { start: number; end: number } }): Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function withPluginHoldingNewWorkspaceComposer(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openNewWorkspace(): Promise<void>;
|
||||
leaveCompactExplorerForNewWorkspace(): Promise<void>;
|
||||
openSettings(): Promise<void>;
|
||||
openThinkingPicker(): Promise<void>;
|
||||
previewAttachedImage(): Promise<void>;
|
||||
typeInHeldComposer(text: string): Promise<void>;
|
||||
expectOverlappingFocusesKeepTheLatestSelection(): Promise<void>;
|
||||
expectHeldComposerFocusResult(result: string): Promise<void>;
|
||||
expectHeldComposerReadyToType(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "held-composer-", title: "Held composer" });
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-held-composer-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "held-composer", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { useComposer } from "@getpaseo/plugin/client";
|
||||
|
||||
function Holder() {
|
||||
globalThis.heldComposer = useComposer();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addComposerItem({ id: "holder", Component: Holder });
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await run({
|
||||
openNewWorkspace: () =>
|
||||
test.step("open the New workspace composer", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
|
||||
}),
|
||||
leaveCompactExplorerForNewWorkspace: () =>
|
||||
test.step("leave a compact Explorer for New workspace with the shortcut", async () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(await ensureExplorerSidebar(page)).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
(globalThis as HeldComposerGlobal).heldComposer = undefined;
|
||||
});
|
||||
await page.keyboard.press("ControlOrMeta+n");
|
||||
await expect(page).toHaveURL(/\/new(?:\?.*)?$/);
|
||||
// A compact composer mounts plugin items inside its controls sheet.
|
||||
await page.getByRole("button", { name: /^Select model/ }).click();
|
||||
await page.waitForFunction(() => (globalThis as HeldComposerGlobal).heldComposer);
|
||||
await tapSheetBackdrop(page);
|
||||
await expectNoSheetOpen(page);
|
||||
}),
|
||||
openSettings: () =>
|
||||
test.step("push Settings over the New workspace screen", async () => {
|
||||
await page.getByTestId("sidebar-settings").filter({ visible: true }).click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}),
|
||||
openThinkingPicker: () =>
|
||||
test.step("open the thinking picker in the New workspace composer", async () => {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select thinking option/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(page.getByText("High", { exact: true })).toBeVisible();
|
||||
}),
|
||||
previewAttachedImage: () =>
|
||||
test.step("attach an image to the New workspace composer and preview it", async () => {
|
||||
await attachComposerTestImage(page);
|
||||
await openImageLightbox(page);
|
||||
}),
|
||||
typeInHeldComposer: (text) =>
|
||||
test.step(`type ${text} in the New workspace composer`, async () => {
|
||||
await prompt(page).filter({ visible: true }).fill(text);
|
||||
}),
|
||||
expectOverlappingFocusesKeepTheLatestSelection: () =>
|
||||
test.step("two overlapping focus() calls leave the later selection", async () => {
|
||||
const selection = await page.evaluate(async () => {
|
||||
const composer = (globalThis as HeldComposerGlobal).heldComposer!;
|
||||
await Promise.all([
|
||||
composer.focus({ selection: { start: 0, end: 1 } }),
|
||||
composer.focus({ selection: { start: 2, end: 4 } }),
|
||||
]);
|
||||
const input = document.activeElement as HTMLTextAreaElement;
|
||||
return [input.selectionStart, input.selectionEnd];
|
||||
});
|
||||
expect(selection).toEqual([2, 4]);
|
||||
}),
|
||||
expectHeldComposerReadyToType: () =>
|
||||
test.step("nothing covers the composer and the cursor is in it", async () => {
|
||||
await expect(page.getByText("High", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).toHaveCount(0);
|
||||
await expect(prompt(page).filter({ visible: true })).toBeFocused();
|
||||
}),
|
||||
expectHeldComposerFocusResult: (result) =>
|
||||
test.step(`the held composer's focus() reports ${result}`, async () => {
|
||||
const actual = await page.evaluate(() =>
|
||||
(globalThis as HeldComposerGlobal).heldComposer?.focus().then(
|
||||
() => "focused",
|
||||
(error: Error) => error.message,
|
||||
),
|
||||
);
|
||||
expect(actual).toBe(result);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("held-composer");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
type FocusAgentGlobal = typeof globalThis & {
|
||||
focusAgent?: (agentId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function withPluginFocusingASubagent(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openParentAgent(): Promise<void>;
|
||||
openExplorer(): Promise<void>;
|
||||
focusSubagent(): Promise<void>;
|
||||
expectSubagentTabReadyToType(): Promise<void>;
|
||||
keepParentInExplorerOnCompactScreen(): Promise<void>;
|
||||
focusParent(): Promise<void>;
|
||||
expectParentReadyToType(): Promise<void>;
|
||||
coverExplorerComposerWithFileSheet(): Promise<void>;
|
||||
focusParentAsAFileSheetOpens(): Promise<void>;
|
||||
openSubagentPageInParentComposer(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "focus-subagent-",
|
||||
title: "Focus parent",
|
||||
initialPrompt: promptMentioning(FILE_LINK),
|
||||
});
|
||||
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
|
||||
const child = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Focus child" },
|
||||
);
|
||||
const plugin = await installFocusingPlugin(page);
|
||||
const { focusAgent } = plugin;
|
||||
try {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await gotoAppShell(page);
|
||||
await run({
|
||||
keepParentInExplorerOnCompactScreen: () =>
|
||||
test.step("move the parent into the Explorer, then narrow the window", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
|
||||
// The seeded turn must finish first, so the drag isn't racing its updates to the tab.
|
||||
await expect(page.getByText(FILE_LINK, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
|
||||
await expect(
|
||||
explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`),
|
||||
).toBeVisible();
|
||||
await page.setViewportSize(COMPACT);
|
||||
// The phone layout hides the Explorer; focus must find it there, not in the wide layout.
|
||||
await expect(explorer).toBeHidden();
|
||||
}),
|
||||
focusParent: () =>
|
||||
test.step("focus the agent kept in the Explorer", () => focusAgent(workspace.agentId)),
|
||||
expectParentReadyToType: () =>
|
||||
test.step("no sheet is open and the cursor is in the composer", async () => {
|
||||
await expectNoSheetOpen(page);
|
||||
await expect(prompt(page).filter({ visible: true })).toBeFocused();
|
||||
}),
|
||||
openSubagentPageInParentComposer: () =>
|
||||
test.step("open the subagent's page from the parent's Subagents track", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
await expect(sheet(page, child.title)).toBeVisible();
|
||||
}),
|
||||
coverExplorerComposerWithFileSheet: () =>
|
||||
test.step("open a file's sheet over the Explorer's composer", async () => {
|
||||
await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
|
||||
await expect(sheet(page, "linked.ts")).toBeVisible();
|
||||
}),
|
||||
focusParentAsAFileSheetOpens: () =>
|
||||
test.step("focus the agent while a file's sheet is opening over it", async () => {
|
||||
// The click looks the file up first. Focus in the commit that mounts the sheet, before
|
||||
// its opening animation has started.
|
||||
await page.evaluate((id) => {
|
||||
const global = globalThis as FocusAgentGlobal & {
|
||||
focusedAsSheetOpens?: Promise<string>;
|
||||
};
|
||||
global.focusedAsSheetOpens = new Promise((resolve) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!document.querySelector('[aria-label="Bottom sheet backdrop"]')) return;
|
||||
observer.disconnect();
|
||||
void global.focusAgent?.(id).then(
|
||||
() => resolve("focused"),
|
||||
(error: Error) => resolve(error.message),
|
||||
);
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
}, workspace.agentId);
|
||||
await page.getByText(FILE_LINK, { exact: true }).filter({ visible: true }).click();
|
||||
const outcome = await page.evaluate(
|
||||
() => (globalThis as { focusedAsSheetOpens?: Promise<string> }).focusedAsSheetOpens,
|
||||
);
|
||||
expect(outcome).toBe("focused");
|
||||
}),
|
||||
openParentAgent: () =>
|
||||
test.step("open the parent agent on a phone", async () => {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectOpenTabCount(page, 1);
|
||||
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
|
||||
}),
|
||||
openExplorer: () =>
|
||||
test.step("open the Explorer over the parent's composer", async () => {
|
||||
await expect(await ensureExplorerSidebar(page)).toBeVisible();
|
||||
}),
|
||||
focusSubagent: () =>
|
||||
test.step("focus the subagent, which has no tab", () => focusAgent(child.id)),
|
||||
expectSubagentTabReadyToType: () =>
|
||||
test.step("the subagent is a tab with the cursor in its composer", async () => {
|
||||
await expectNoSheetOpen(page);
|
||||
await expectOpenTabCount(page, 2);
|
||||
await expect(prompt(page)).toBeFocused();
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await plugin.remove();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin that focuses any agent's composer, as a callback holding the agent's id would. */
|
||||
async function installFocusingPlugin(page: Page) {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-focus-agent-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "focus-agent", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`export default function contribute(client) {
|
||||
globalThis.focusAgent = (agentId) => client.composers({ agentId })[0].focus();
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
return {
|
||||
focusAgent: async (agentId: string) => {
|
||||
const result = await page.evaluate(
|
||||
(id) =>
|
||||
(globalThis as FocusAgentGlobal).focusAgent?.(id).then(
|
||||
() => "focused",
|
||||
(error: Error) => error.message,
|
||||
),
|
||||
agentId,
|
||||
);
|
||||
expect(result).toBe("focused");
|
||||
},
|
||||
remove: async () => {
|
||||
await client.removePlugin("focus-agent");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function withPluginFocusingAnAgentWithContextUsage(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openContextUsageOnPhone(): Promise<void>;
|
||||
focusAgent(): Promise<void>;
|
||||
expectReadyToType(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const agent = await seedAgentWithContextWindow();
|
||||
const plugin = await installFocusingPlugin(page);
|
||||
try {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await gotoAppShell(page);
|
||||
await run({
|
||||
openContextUsageOnPhone: () =>
|
||||
test.step("open the agent's context usage on a phone", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
|
||||
await pressContextWindowMeter(page);
|
||||
}),
|
||||
focusAgent: () => test.step("focus the agent", () => plugin.focusAgent(agent.agentId)),
|
||||
expectReadyToType: () =>
|
||||
test.step("the context usage is closed and the cursor is in the composer", async () => {
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
await expect(prompt(page).filter({ visible: true })).toBeFocused();
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await plugin.remove();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +106,12 @@ export async function verifyProviderSettings({
|
||||
await test.step("provider settings render the registered icon", async () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: "Direct provider example provider details", exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
// The row's button lies under its content, so the icon is in the row, beside the button.
|
||||
const opener = page.getByRole("button", {
|
||||
name: "Direct provider example provider details",
|
||||
exact: true,
|
||||
});
|
||||
await expectProviderIcon(page.getByTestId(/^provider-row-/).filter({ has: opener }), iconPaths);
|
||||
await capture(page, testInfo, "provider-settings");
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function measureRelayRestartDuringStream(input: {
|
||||
agentTitle: string;
|
||||
}): Promise<RelayDeploymentMeasurements> {
|
||||
const { page, relay, agentTitle } = input;
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
|
||||
const beforeOutage = await latestAssistantText(page);
|
||||
await waitForAssistantTextToGrow(page, beforeOutage);
|
||||
|
||||
|
||||
@@ -106,6 +106,33 @@ export async function changeSettingsInterfaceFontSize(page: Page, size: number):
|
||||
await input.fill(String(size));
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(String(size));
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Settings" }).getByText("Appearance", { exact: true }),
|
||||
).toHaveCSS("font-size", `${size}px`);
|
||||
}
|
||||
|
||||
export async function changeSettingsInterfaceFontSizeKeepingSidebarPosition(
|
||||
page: Page,
|
||||
size: number,
|
||||
): Promise<number> {
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
const anchor = navigation.getByRole("button", { name: "About", exact: true });
|
||||
const before = await anchor.evaluate((node) => ({
|
||||
top: node.getBoundingClientRect().top,
|
||||
height: node.getBoundingClientRect().height,
|
||||
}));
|
||||
await changeSettingsInterfaceFontSize(page, size);
|
||||
// Larger fonts reflow rows. Preserve the visible destination, rather than its old pixel offset.
|
||||
await expect
|
||||
.poll(() =>
|
||||
anchor.evaluate((node, top) => Math.abs(node.getBoundingClientRect().top - top), before.top),
|
||||
)
|
||||
.toBeLessThan(before.height);
|
||||
const offset = await page
|
||||
.getByTestId("settings-sidebar-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
|
||||
@@ -480,8 +507,8 @@ export async function serveJson(page: Page, url: string, body: unknown): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAddProviderArea(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-add-provider-card").scrollIntoViewIfNeeded();
|
||||
export async function openAddProvider(page: Page): Promise<void> {
|
||||
await page.getByTestId("provider-list-add").click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -493,17 +520,59 @@ export async function findAcpCatalogProvider(page: Page, providerName: string):
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click();
|
||||
// The sheet closes once the add is done. Adding probes the new provider, which can outlast an
|
||||
// assertion's default wait.
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Adding opens the new provider's page; its row waits in the list behind it. */
|
||||
export async function expectProviderInstalledInSettings(
|
||||
page: Page,
|
||||
providerName: string,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName);
|
||||
await page.getByTestId("provider-detail-back").click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${providerName} provider details`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openProviderDetail(page: Page, providerName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `${providerName} provider details`, exact: true }).click();
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName);
|
||||
}
|
||||
|
||||
/** Load a provider's Settings page from its URL, as a reload or a shared link does. */
|
||||
export async function gotoProviderPage(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(
|
||||
`/settings/hosts/${encodeURIComponent(serverId)}/providers/${encodeURIComponent(providerId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectProviderPageLoaded(page: Page, providerName: string): Promise<void> {
|
||||
await expect(page.getByTestId("provider-detail-title")).toHaveText(providerName, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Provider status")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectProviderNotOnHost(page: Page): Promise<void> {
|
||||
await expect(page.getByText("This provider isn't on this host")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function leaveProviderDetail(page: Page): Promise<void> {
|
||||
await page.getByTestId("provider-detail-back").click();
|
||||
await expect(page.getByTestId("provider-list")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostNoDaemonLifecycleRow(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
}
|
||||
@@ -613,3 +682,192 @@ export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
|
||||
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function revealSettingsSyntaxTheme(page: Page) {
|
||||
await page.getByLabel(/Highlight theme:/).scrollIntoViewIfNeeded();
|
||||
const offset = await page
|
||||
.getByTestId("settings-detail-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
const keywordColor = await page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first()
|
||||
.evaluate((node) => getComputedStyle(node).color);
|
||||
return { offset, keywordColor };
|
||||
}
|
||||
|
||||
export async function changeSettingsSyntaxTheme(page: Page, theme: string): Promise<void> {
|
||||
await page.getByLabel(/Highlight theme:/).click();
|
||||
await page.getByRole("menuitem", { name: theme, exact: true }).click();
|
||||
await expect(page.getByLabel(`Highlight theme: ${theme}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsSyntaxColorChanged(
|
||||
page: Page,
|
||||
previousColor: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
|
||||
.getByText("export", { exact: true })
|
||||
.first(),
|
||||
).not.toHaveCSS("color", previousColor);
|
||||
}
|
||||
|
||||
export async function changeSettingsTypography(page: Page): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Interface font size", exact: true });
|
||||
await input.fill("18");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue("18");
|
||||
await expect(input).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Code size", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const navigation = page.getByRole("navigation", { name: "Settings" });
|
||||
await expect(navigation.getByText("Appearance", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(navigation.getByText("Providers", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
await expect(page.getByText("Dracula", { exact: true })).toHaveCSS("font-size", "18px");
|
||||
const familyInput = page.getByRole("textbox", { name: "Code font family", exact: true });
|
||||
await familyInput.fill("Courier New, Courier, monospace");
|
||||
await familyInput.press("Enter");
|
||||
const codeInput = page.getByRole("textbox", { name: "Code font size", exact: true });
|
||||
await codeInput.fill("16");
|
||||
await codeInput.press("Enter");
|
||||
await expect(codeInput).toHaveValue("16");
|
||||
const preview = page.getByRole("img", {
|
||||
name: "Live preview of content typography, syntax theme, and code font",
|
||||
});
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS("font-size", "16px");
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"line-height",
|
||||
"24px",
|
||||
);
|
||||
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
|
||||
"font-family",
|
||||
/Courier/,
|
||||
);
|
||||
const contentInput = page.getByRole("textbox", { name: "Content font size", exact: true });
|
||||
await contentInput.fill("20");
|
||||
await contentInput.press("Enter");
|
||||
await expect(preview.getByText("Readable content and code preview", { exact: true })).toHaveCSS(
|
||||
"font-size",
|
||||
"20px",
|
||||
);
|
||||
}
|
||||
|
||||
export async function normalizeSettingsFontSizes(page: Page): Promise<void> {
|
||||
for (const [name, minimum, maximum] of [
|
||||
["Interface font size", "10", "21"],
|
||||
["Content font size", "10", "21"],
|
||||
["Code font size", "9", "22"],
|
||||
] as const) {
|
||||
const input = page.getByRole("textbox", { name, exact: true });
|
||||
await input.fill("999");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
// Clamp again when the saved value is already at the maximum.
|
||||
await input.fill("999");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(maximum);
|
||||
await input.fill("1");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
await input.fill("1");
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(minimum);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample actual paints, including the first workspace frame after Settings. */
|
||||
export async function captureCompactSidebarReturn(page: Page, returnAction: () => Promise<void>) {
|
||||
await page.evaluate(() => {
|
||||
const capture = {
|
||||
frames: [] as {
|
||||
time: number;
|
||||
pathname: string;
|
||||
sidebarVisible: boolean;
|
||||
settingsVisible: boolean;
|
||||
closeX: number | null;
|
||||
}[],
|
||||
stopped: false,
|
||||
};
|
||||
Object.assign(window, { __sidebarFrameCapture: capture });
|
||||
const start = performance.now();
|
||||
function sample() {
|
||||
if (capture.stopped) return;
|
||||
const close = document.querySelector('[data-testid="sidebar-close"]');
|
||||
capture.frames.push({
|
||||
time: performance.now() - start,
|
||||
pathname: location.pathname,
|
||||
settingsVisible:
|
||||
document
|
||||
.querySelector('[data-testid="settings-sidebar"]')
|
||||
?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false,
|
||||
sidebarVisible:
|
||||
(close?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) ?? false) &&
|
||||
close!.getBoundingClientRect().x >= 0 &&
|
||||
close!.getBoundingClientRect().right <= innerWidth,
|
||||
closeX: close?.getBoundingClientRect().x ?? null,
|
||||
});
|
||||
requestAnimationFrame(sample);
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await returnAction();
|
||||
return page.evaluate(async () => {
|
||||
const capture = (
|
||||
window as typeof window & {
|
||||
__sidebarFrameCapture: {
|
||||
frames: {
|
||||
time: number;
|
||||
pathname: string;
|
||||
sidebarVisible: boolean;
|
||||
settingsVisible: boolean;
|
||||
closeX: number | null;
|
||||
}[];
|
||||
stopped: boolean;
|
||||
};
|
||||
}
|
||||
).__sidebarFrameCapture;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1500));
|
||||
capture.stopped = true;
|
||||
return capture.frames;
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureCompactSidebarContext(page: Page) {
|
||||
const sidebar = await page.getByTestId("sidebar-project-workspace-list-scroll").elementHandle();
|
||||
if (!sidebar) throw new Error("Open sidebar has no workspace list");
|
||||
await expect
|
||||
.poll(() => sidebar.evaluate((el) => el.scrollHeight - el.clientHeight))
|
||||
.toBeGreaterThan(0);
|
||||
const offset = await sidebar.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
return el.scrollTop;
|
||||
});
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return async () => {
|
||||
expect(await sidebar.evaluate((el) => el.isConnected)).toBe(true);
|
||||
expect(await sidebar.evaluate((el) => el.scrollTop)).toBe(offset);
|
||||
await sidebar.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectCompactSidebarOpen(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Close sidebar", exact: true })).toBeInViewport();
|
||||
}
|
||||
|
||||
export async function closeCompactSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeInViewport();
|
||||
}
|
||||
|
||||
export async function openSettingsFromCompactSidebar(page: Page) {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectCompactSettingsList(page);
|
||||
}
|
||||
|
||||
@@ -112,3 +112,15 @@ export async function expectDeclinedDiscardPrompt(
|
||||
await close();
|
||||
expect(await prompt).toContain("discard the draft");
|
||||
}
|
||||
|
||||
/** A seeded prompt the mock agent echoes, so the chat shows `target` as a tappable file link. */
|
||||
export function promptMentioning(target: string): string {
|
||||
return [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -31,10 +31,13 @@ async function expectContextMenuAtPointer(
|
||||
menuTestID: string,
|
||||
anchor: ContextMenuAnchor,
|
||||
): Promise<void> {
|
||||
const bounds = await page.getByTestId(menuTestID).boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
|
||||
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
|
||||
// The visible measuring surface is replaced when layout places the menu at its anchor.
|
||||
await expect(async () => {
|
||||
const bounds = await page.getByTestId(menuTestID).boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure ${menuTestID}`);
|
||||
expect(Math.abs(bounds.x - anchor.x)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(bounds.y - (anchor.y + 4))).toBeLessThanOrEqual(2);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function openWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import type { LongTimelineAgent } from "./timeline-pagination";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
@@ -13,6 +14,12 @@ const SUBSCRIPTION_REQUEST = "agent.timeline.set_subscription.request";
|
||||
const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response";
|
||||
const TIMELINE_REQUEST = "fetch_agent_timeline_request";
|
||||
const TIMELINE_RESPONSE = "fetch_agent_timeline_response";
|
||||
/**
|
||||
* `FRESHNESS_CHECK_QUIET_MS` is 500: an opening check unanswered that long reports Updating messages by
|
||||
* design, however long the daemon takes. The app starts that wait just before it sends the question
|
||||
* this helper times, so the bound stops short of 500.
|
||||
*/
|
||||
const QUIET_WINDOW_FLOOR_MS = 300;
|
||||
|
||||
export interface TimelineRequestCounts {
|
||||
agentId?: string;
|
||||
@@ -77,48 +84,99 @@ export async function waitForResumeAnswer(
|
||||
]);
|
||||
}
|
||||
|
||||
interface UpdatingToastTiming {
|
||||
askedAt: number | null;
|
||||
shownAt: number | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__updatingToastTiming?: UpdatingToastTiming;
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs in the page: times the freshness question and the toast's first commit on its own clock. */
|
||||
function recordUpdatingToastTiming(): void {
|
||||
const noteToast = (timing: UpdatingToastTiming) => {
|
||||
if (document.querySelector('[data-testid="agent-updating-toast"]') === null) return;
|
||||
timing.shownAt ??= performance.now();
|
||||
};
|
||||
const existing = window.__updatingToastTiming;
|
||||
if (existing) {
|
||||
existing.askedAt = null;
|
||||
existing.shownAt = null;
|
||||
noteToast(existing);
|
||||
return;
|
||||
}
|
||||
const timing: UpdatingToastTiming = { askedAt: null, shownAt: null };
|
||||
window.__updatingToastTiming = timing;
|
||||
const send = WebSocket.prototype.send;
|
||||
WebSocket.prototype.send = function sendRecordingQuestion(data) {
|
||||
if (
|
||||
typeof data === "string" &&
|
||||
data.includes("agent.timeline.set_subscription.request") &&
|
||||
data.includes('"agentIds":["')
|
||||
) {
|
||||
timing.askedAt ??= performance.now();
|
||||
}
|
||||
return send.call(this, data);
|
||||
};
|
||||
new MutationObserver(() => noteToast(timing)).observe(document, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
noteToast(timing);
|
||||
}
|
||||
|
||||
/** A toast may only follow an unanswered question; nothing about the daemon's speed changes that. */
|
||||
async function expectToastWaitedForTheDaemon(page: Page): Promise<void> {
|
||||
await waitForNextPaint(page);
|
||||
const showedTooSoon = await page.evaluate((floorMs) => {
|
||||
const { askedAt, shownAt } = window.__updatingToastTiming as UpdatingToastTiming;
|
||||
if (shownAt === null) return false;
|
||||
return askedAt === null || shownAt - askedAt < floorMs;
|
||||
}, QUIET_WINDOW_FLOOR_MS);
|
||||
expect(showedTooSoon, "the Updating messages toast did not wait for the daemon").toBe(false);
|
||||
}
|
||||
|
||||
/** Records every appearance of the toast, including one that lasts a single commit. */
|
||||
export async function watchForUpdatingToast(page: Page): Promise<() => Promise<void>> {
|
||||
const watch = await page.evaluateHandle(() => {
|
||||
let shown = false;
|
||||
const check = () => {
|
||||
shown ||= document.querySelector('[data-testid="agent-updating-toast"]') !== null;
|
||||
};
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
check();
|
||||
return {
|
||||
stop() {
|
||||
observer.disconnect();
|
||||
return shown;
|
||||
},
|
||||
};
|
||||
});
|
||||
return async () => {
|
||||
await waitForNextPaint(page);
|
||||
const shown = await watch.evaluate((recorder) => recorder.stop());
|
||||
await watch.dispose();
|
||||
expect(shown, "the Updating messages toast appeared").toBe(false);
|
||||
};
|
||||
await page.evaluate(recordUpdatingToastTiming);
|
||||
return () => expectToastWaitedForTheDaemon(page);
|
||||
}
|
||||
|
||||
/** Reopens the app on this chat, recording the toast from the first paint of the new page. */
|
||||
export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise<void>> {
|
||||
await page.addInitScript(() => {
|
||||
const recorder = window as Window & { updatingToastShown?: boolean };
|
||||
new MutationObserver(() => {
|
||||
recorder.updatingToastShown ||=
|
||||
document.querySelector('[data-testid="agent-updating-toast"]') !== null;
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
await page.addInitScript(recordUpdatingToastTiming);
|
||||
await page.reload();
|
||||
return async () => {
|
||||
await waitForNextPaint(page);
|
||||
const shown = await page.evaluate(
|
||||
() => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true,
|
||||
);
|
||||
expect(shown, "the Updating messages toast appeared").toBe(false);
|
||||
};
|
||||
return () => expectToastWaitedForTheDaemon(page);
|
||||
}
|
||||
|
||||
/** Opens persisted history with the daemon's freshness answer held. */
|
||||
export async function reopenWithFreshnessCheckUnanswered(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<void> {
|
||||
// A cold open needs a persisted authoritative cursor to ask a freshness question.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
return cache?.hosts
|
||||
?.flatMap((host) => host.timelines)
|
||||
.find((timeline) => timeline.agentId === agent.agentId)?.range?.endSeq;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
gate.holdNextClientRequest(SUBSCRIPTION_REQUEST);
|
||||
await page.reload();
|
||||
await expect(page.getByText(agent.newestPrompt, { exact: true })).toBeVisible();
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function answerFreshnessCheck(page: Page, gate: DaemonWebSocketGate): Promise<void> {
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function waitForNextPaint(page: Page): Promise<void> {
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface UsageListRequest {
|
||||
export interface UsageReportsFixture {
|
||||
listRequests(): UsageListRequest[];
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
/** Runs `action`, then waits until the app has received the agent's next usage scope. */
|
||||
untilUsageScopeChanges(agentId: string, action: () => Promise<unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
interface UsageReportsFixtureOptions {
|
||||
@@ -97,6 +99,26 @@ function withUsageSupportFeature(
|
||||
});
|
||||
}
|
||||
|
||||
function agentUsageScope(message: WebSocketMessage): { agentId: string; key: string } | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: { kind?: unknown; agent?: { id?: unknown; usageScopeKey?: unknown } };
|
||||
};
|
||||
} | null;
|
||||
const agent = envelope?.message?.payload?.agent;
|
||||
if (
|
||||
envelope?.message?.type !== "agent_update" ||
|
||||
envelope.message.payload?.kind !== "upsert" ||
|
||||
typeof agent?.id !== "string" ||
|
||||
typeof agent.usageScopeKey !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { agentId: agent.id, key: agent.usageScopeKey };
|
||||
}
|
||||
|
||||
function pick<T>(values: T[], index: number): T {
|
||||
const value = values[Math.min(index, values.length - 1)];
|
||||
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
|
||||
@@ -127,6 +149,16 @@ export async function installUsageReportsFixture(
|
||||
): Promise<UsageReportsFixture> {
|
||||
const listRequests: UsageListRequest[] = [];
|
||||
const listCounter = createCounter();
|
||||
const usageScopes = new Map<string, string>();
|
||||
const usageScopeChanges = new Map<string, ReturnType<typeof createCounter>>();
|
||||
const usageScopeCounts = new Map<string, number>();
|
||||
const usageScopeCounter = (agentId: string) => {
|
||||
const existing = usageScopeChanges.get(agentId);
|
||||
if (existing) return existing;
|
||||
const created = createCounter();
|
||||
usageScopeChanges.set(agentId, created);
|
||||
return created;
|
||||
};
|
||||
const usageSupported = options.usageSupported ?? true;
|
||||
const isUsageSupported = () =>
|
||||
typeof usageSupported === "function" ? usageSupported() : usageSupported;
|
||||
@@ -239,6 +271,15 @@ export async function installUsageReportsFixture(
|
||||
options.usageSettingSupported ?? true,
|
||||
)
|
||||
: null;
|
||||
const scope = agentUsageScope(message);
|
||||
if (scope && usageScopes.get(scope.agentId) !== scope.key) {
|
||||
usageScopes.set(scope.agentId, scope.key);
|
||||
usageScopeCounts.set(scope.agentId, (usageScopeCounts.get(scope.agentId) ?? 0) + 1);
|
||||
// The app must hold the new scope before a test acts on it.
|
||||
ws.send(serverInfo ?? message);
|
||||
usageScopeCounter(scope.agentId).increment();
|
||||
return;
|
||||
}
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
@@ -246,5 +287,10 @@ export async function installUsageReportsFixture(
|
||||
return {
|
||||
listRequests: () => [...listRequests],
|
||||
waitForListRequests: (count) => listCounter.waitFor(count),
|
||||
untilUsageScopeChanges: async (agentId, action) => {
|
||||
const next = (usageScopeCounts.get(agentId) ?? 0) + 1;
|
||||
await action();
|
||||
await usageScopeCounter(agentId).waitFor(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +200,18 @@ export async function openUsageFromItem(page: Page): Promise<void> {
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openUsageFromItemWithPressFeedback(page: Page): Promise<void> {
|
||||
const summary = page.getByRole("button", { name: /^Usage:/ });
|
||||
await summary.hover();
|
||||
const hoveredFill = await summary.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await expect(summary).not.toHaveCSS("background-color", hoveredFill);
|
||||
await page.mouse.up();
|
||||
await expect(usageModal(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
|
||||
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||
const body = await usageModal(page).boundingBox();
|
||||
|
||||
@@ -61,6 +61,23 @@ function explorerSidebar(page: Page) {
|
||||
return visibleTestId(page, "workspace-explorer-sidebar").first();
|
||||
}
|
||||
|
||||
export async function dragTabIntoExplorer(page: Page, tabId: string): Promise<void> {
|
||||
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
|
||||
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();
|
||||
}
|
||||
|
||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
if ((await tab.getAttribute("aria-selected")) !== "true") {
|
||||
// The close action overlays the chip's trailing edge on hover. Click the
|
||||
|
||||
@@ -109,18 +109,18 @@ export async function expectReconnectingToastVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting to host" });
|
||||
const toast = page.getByRole("alert").filter({ hasText: "Reconnecting" });
|
||||
await expect(toast).toBeVisible({
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
await expect(toast).toHaveText("Reconnecting to host");
|
||||
await expect(toast).toHaveText("Reconnecting");
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastGone(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting to host" })).toHaveCount(0, {
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Reconnecting" })).toHaveCount(0, {
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ android {
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation 'com.facebook.react:react-android'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test:runner:1.6.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity android:name="android.app.Activity" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.view.KeyEvent
|
||||
import android.widget.EditText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Exercises the real Android window and editable that receive physical keys. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TerminalArrowTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
|
||||
@After fun finish() {
|
||||
instrumentation.runOnMainSync {
|
||||
activity?.let {
|
||||
HardwareKeyboard.release(it.window)
|
||||
it.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun physicalArrowsDoNotMoveTheTerminalInputCaret() {
|
||||
withInput(terminal = true) { input, host ->
|
||||
for (code in listOf(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN)) {
|
||||
for (modifiers in listOf(0, KeyEvent.META_SHIFT_ON, KeyEvent.META_ALT_ON, KeyEvent.META_CTRL_ON)) {
|
||||
input.setSelection(3)
|
||||
for (repeat in 0..2) {
|
||||
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, code, repeat, modifiers)))
|
||||
assertEquals("Physical arrows must leave the hidden input caret at the end", 3, input.selectionStart)
|
||||
}
|
||||
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, code, 0, modifiers)))
|
||||
}
|
||||
}
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
|
||||
assertEquals("abcx", input.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun physicalArrowsStillEditOtherTextFields() {
|
||||
withInput(terminal = false) { input, host ->
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_LEFT))
|
||||
assertEquals(2, input.selectionStart)
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
|
||||
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
|
||||
assertEquals("abxc", input.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun withInput(terminal: Boolean, check: (EditText, Activity) -> Unit) {
|
||||
val host = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = host
|
||||
lateinit var input: EditText
|
||||
instrumentation.runOnMainSync {
|
||||
input = EditText(host)
|
||||
if (terminal) input.setTag(com.facebook.react.R.id.view_tag_native_id, "paseo-terminal-input")
|
||||
host.setContentView(input)
|
||||
input.setText("abc")
|
||||
input.requestFocus()
|
||||
input.setSelection(3)
|
||||
HardwareKeyboard.observe(host.window)
|
||||
}
|
||||
instrumentation.waitForIdleSync()
|
||||
instrumentation.runOnMainSync {
|
||||
assertSame("The editable must own window focus before keys are dispatched", input, host.window.currentFocus)
|
||||
check(input, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -10,18 +10,24 @@ import android.view.KeyEvent
|
||||
import android.view.Window
|
||||
import android.widget.EditText
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import com.facebook.react.R
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.interfaces.ExtraWindowEventListener
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
// Matched against the hidden terminal input's `nativeID`
|
||||
// (TERMINAL_INPUT_NATIVE_ID in terminal-input.native.tsx).
|
||||
private const val TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input"
|
||||
|
||||
internal object HardwareKeyboard {
|
||||
var module: PaseoHardwareKeyboardModule? = null
|
||||
@Volatile var overlayKeys: Set<String> = emptySet()
|
||||
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
|
||||
val dispatcher = WindowKeyDispatcher<KeyEvent>()
|
||||
private val consumedPresses = mutableSetOf<Int>()
|
||||
private val terminalArrowCodes = setOf("ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight")
|
||||
|
||||
fun observe(window: Window) {
|
||||
if (windows.containsKey(window)) return
|
||||
@@ -38,7 +44,7 @@ internal object HardwareKeyboard {
|
||||
consumedPresses.clear()
|
||||
}
|
||||
|
||||
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
|
||||
fun dispatch(event: KeyEvent, composing: Boolean, terminalFocused: Boolean): Boolean {
|
||||
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
val code = code(event.keyCode)
|
||||
@@ -64,8 +70,12 @@ internal object HardwareKeyboard {
|
||||
))
|
||||
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
|
||||
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
|
||||
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey
|
||||
// Without this the hidden input consumes the arrows as caret movement and
|
||||
// its next text change stops being a plain append. Modifiers stay
|
||||
// forwarded: Alt/Shift+arrow are terminal editing keys.
|
||||
val terminalOwnsKey = terminalFocused && !composing && code in terminalArrowCodes
|
||||
if (overlayOwnsKey || terminalOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey || terminalOwnsKey
|
||||
}
|
||||
|
||||
private fun code(keyCode: Int): String = when (keyCode) {
|
||||
@@ -107,9 +117,15 @@ internal object HardwareKeyboard {
|
||||
|
||||
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val editable = (window.currentFocus as? EditText)?.text
|
||||
val focus = window.currentFocus
|
||||
val editable = (focus as? EditText)?.text
|
||||
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
|
||||
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
|
||||
val terminalFocused = focus?.getTag(R.id.view_tag_native_id) == TERMINAL_INPUT_NATIVE_ID
|
||||
return HardwareKeyboard.dispatcher.dispatch(
|
||||
event,
|
||||
{ HardwareKeyboard.dispatch(event, composing, terminalFocused) },
|
||||
{ previous.dispatchKeyEvent(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user