mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a2236dac3 | ||
|
|
ecd0dd083e | ||
|
|
10c443a32b | ||
|
|
457d8d6ec5 | ||
|
|
80879eaf7c | ||
|
|
79b9e7447b | ||
|
|
c91948048b | ||
|
|
dc85c2bfbc | ||
|
|
5ed1f8d108 | ||
|
|
63ea3e5561 | ||
|
|
4c9c59ea2b | ||
|
|
4178497d3d | ||
|
|
d46ee6558c | ||
|
|
abcccce846 | ||
|
|
48a1497fc0 | ||
|
|
2e1747a145 | ||
|
|
4181f8d738 | ||
|
|
11d7267e33 | ||
|
|
82687ec95b | ||
|
|
9442b5a1b4 | ||
|
|
6cfe903127 | ||
|
|
f9779a14a5 | ||
|
|
ab64bebe14 | ||
|
|
e0f545fed4 | ||
|
|
0fa7f840b4 | ||
|
|
e260f93cc7 | ||
|
|
9a5b4aefa4 | ||
|
|
339a308365 | ||
|
|
b2b82e5cb0 | ||
|
|
180f07587a | ||
|
|
1fff127f45 | ||
|
|
fdee02bdb6 | ||
|
|
28c188513e | ||
|
|
8cc8e12007 | ||
|
|
def249db99 | ||
|
|
e4b6acb36d | ||
|
|
148a394981 | ||
|
|
a9d48c6729 | ||
|
|
7a4d260820 | ||
|
|
01888dc5de | ||
|
|
27872a8e86 | ||
|
|
1b0da5fce5 | ||
|
|
02ad59908f |
@@ -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
|
||||
|
||||
+135
@@ -1,5 +1,140 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
|
||||
- **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))
|
||||
- **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))
|
||||
- **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))
|
||||
- **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))
|
||||
- **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))
|
||||
- **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))
|
||||
- **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))
|
||||
|
||||
### 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))
|
||||
|
||||
### 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 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 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 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 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))
|
||||
- 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 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,140 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.0-beta.1 - 2026-11-10
|
||||
|
||||
- **后台任务**: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))
|
||||
- **更快的 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))
|
||||
- **返回时更少重连**:返回应用时会保留仍可用的连接,包括正在传输大型响应的连接,只在必要时重连一次。主机保存了多个连接时,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))
|
||||
- **移动端键盘快捷键**:连接键盘后,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))
|
||||
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、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))
|
||||
- **细节打磨**:一轮对话结束时聊天保持不动,返回没有变化的聊天时直接显示原样,无需刷新。工具行从第一帧起就显示最终标题和图标,上下文窗口浮层打开时用量已加载完成,侧边栏悬停卡片一次只打开一个。 ([#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))
|
||||
- **撤销工作区归档**:在应用中归档工作区时,现在会显示“Workspace 已归档”提示,持续六秒。点击“撤销”或按 Cmd/Ctrl+Z 可恢复该工作区及其标签页和 Agent。在桌面端,侧边栏的工作区行在悬停时还会显示归档按钮。 ([#8904](https://github.com/getpaseo/paseo/pull/8904), [#9104](https://github.com/getpaseo/paseo/pull/9104))
|
||||
|
||||
### 新增
|
||||
|
||||
- 在“更改”中为 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))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 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))
|
||||
- 修复紧跟在段落之后、中间没有空行的 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))
|
||||
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#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))
|
||||
- 修复 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))
|
||||
- 修复向进程已退出的 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))
|
||||
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#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))
|
||||
- 修复已发布的 `@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
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -54,6 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression |
|
||||
| [docs/timeline-sync.md](docs/timeline-sync.md) | Owned agent history, rewind retirements, live stream vs authoritative fetch, paged catch-up, client replica |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost |
|
||||
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
||||
|
||||
@@ -39,8 +39,8 @@ An unarchived agent may be `closed` without being deleted or archived. Closing r
|
||||
processes and subscriptions while retaining its Paseo identity, persistence handle, timeline,
|
||||
workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting
|
||||
the agent runs through `ensureAgentLoaded()`, which resumes the durable provider session under the
|
||||
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
|
||||
primed.
|
||||
same Paseo agent ID. Resume does not read provider history when the agent already has a timeline;
|
||||
see [owned history](timeline-sync.md#owned-history).
|
||||
|
||||
Reload releases the old runtime before resuming its durable session: an idle provider process can
|
||||
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
|
||||
@@ -239,7 +239,8 @@ lifecycle is separate from public workspace visibility and is not a public creat
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
|
||||
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
|
||||
children.
|
||||
children. The timeline and the provider children stay, so an archived chat reads them without its
|
||||
provider; deleting the agent removes them.
|
||||
|
||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.
|
||||
|
||||
@@ -332,6 +333,8 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
||||
|
||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||
|
||||
Only the parent's current runtime makes a provider child running. A child's stored status is the last report about it, and that report can come from provider history or from a runtime an earlier daemon ran, so a stored `running` child does not make the workspace running or keep the parent's runtime alive. It counts again once the live session reports it running. Closing a runtime cancels the children that runtime reported running; children it never reported keep their stored status.
|
||||
|
||||
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
|
||||
`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Agent stream disclosures
|
||||
|
||||
Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group,
|
||||
a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the
|
||||
first painted frame. No animation.
|
||||
a tool call's inline details, a todo card, a long user message's show more, a plan card) keeps the
|
||||
tapped trigger at the same screen y in the first painted frame. No animation.
|
||||
|
||||
## The contract
|
||||
|
||||
@@ -14,12 +14,20 @@ const handleToggle = () => hold(() => setExpanded((value) => !value));
|
||||
```
|
||||
|
||||
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
|
||||
`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row,
|
||||
`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||
`holdRow(rowId, apply, triggerEdge)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a
|
||||
timeline row, `apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||
A toggle that calls `setState` directly skips the hold: on Android the expanded content paints
|
||||
over the next row for a frame before the list learns the new size.
|
||||
|
||||
`triggerEdge` names the edge of the row the trigger sits on, and the strategy keeps that edge in
|
||||
place. Header triggers sit on the top edge, the default. A long user message's show more sits under
|
||||
the text it opens, so `MessagePreview` passes `{ triggerEdge: "bottom" }`: the text grows and shrinks
|
||||
above the trigger, and the rows below stay put.
|
||||
|
||||
Following output:
|
||||
|
||||
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
|
||||
- A hold detaches the bottom anchor controller (`holdForDisclosure`), except a bottom-edge hold on
|
||||
native while following: there the end pin already keeps the row's bottom in place.
|
||||
- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll
|
||||
owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves
|
||||
the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes
|
||||
@@ -33,13 +41,16 @@ ends at its end and the trigger moves by the difference.
|
||||
|
||||
## Web
|
||||
|
||||
`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores
|
||||
the row's top after layout, before paint (`strategy-web.tsx`).
|
||||
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
|
||||
reconcile restores that edge after layout, before paint (`strategy-web.tsx`). The pin records the
|
||||
scroll offset it was taken at, because the browser clamps `scrollTop` when a collapse shrinks the
|
||||
content past the offset, before the reconcile reads it. Restoring from the clamped offset would
|
||||
apply the shrink twice.
|
||||
|
||||
## Native
|
||||
|
||||
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
|
||||
not hold. Summary lines and todo cards expand inline.
|
||||
not hold. Summary lines, todo cards, plan cards and show more expand inline.
|
||||
|
||||
The timeline is a LegendList in forward order: one `data` array of history rows followed by the
|
||||
live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size
|
||||
@@ -51,14 +62,14 @@ toggle; its applied commit took up to 3.5 s of JS on a long turn.
|
||||
|
||||
One owner keeps the scroll offset at any moment:
|
||||
|
||||
| State | Owner |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||
| Following the end | The end pin (`native-end-pin.tsx`). |
|
||||
| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. |
|
||||
| User scroll | The finger. The controller detaches at drag start. |
|
||||
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||
| State | Owner |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||
| Following the end | The end pin (`native-end-pin.tsx`), also for a bottom-edge hold. |
|
||||
| Disclosure hold | LegendList's `maintainVisibleContentPosition` on the hold's anchor row, or the end pin for the last row's bottom. |
|
||||
| User scroll | The finger. The controller detaches at drag start. |
|
||||
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||
|
||||
The end pin is a zero-height view at the bottom of the content and the content's first native
|
||||
child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The
|
||||
@@ -78,15 +89,38 @@ jumps by the keyboard height after the keyboard settles.
|
||||
|
||||
### The hold
|
||||
|
||||
`holdRow(rowId, apply)` runs in the toggle's event:
|
||||
`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event:
|
||||
|
||||
1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and
|
||||
LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row
|
||||
(`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible
|
||||
one down, and a size change with no data change keeps the first row in view whatever
|
||||
1. Pick the anchor and detach the controller (`holdForDisclosure`). LegendList restores row
|
||||
tops only, so a top-edge hold anchors the toggled row and a bottom-edge hold anchors the row
|
||||
below it, whose top is the toggled row's bottom (`disclosureAnchor`). The pin goes away
|
||||
and LegendList's `maintainVisibleContentPosition` takes over, restoring only the anchor row
|
||||
(`shouldRestorePosition`). A bottom-edge hold while following skips this step: the end pin
|
||||
keeps the content end, and the row's bottom with it, in the frame the new height mounts.
|
||||
|
||||
A bottom-edge hold on the last row, such as a prompt the agent has not answered yet, has no
|
||||
row below it: its bottom is the content end (the running indicator is the list footer, not a
|
||||
row). The hold mounts the end pin while detached, and `apply` runs a frame later. The pin
|
||||
anchors only once it is mounted, and a pin that mounts in the same commit as the new height
|
||||
anchors nothing. The pin's commit mounts before that frame's JS callbacks run, so `apply`'s
|
||||
commit mounts with the pin already in place. The pin keeps the distance from the end, and each
|
||||
content size change clamps it to the scroll room left, so the hold re-sticks if the list was
|
||||
at its end when tapped or a collapse used up the room. The release tracks that distance
|
||||
instead of reading the offset: the new content size reaches JS before the pin's scroll does.
|
||||
A reply landing below the row ends the pin in the commit that mounts the reply, so the reply
|
||||
grows below the row and the trigger stays where it was. A reply that lands before `apply` runs
|
||||
moves the hold to the reply's row.
|
||||
|
||||
Upstream LegendList anchors only rows from the first fully visible one down to the last fully
|
||||
visible one, and a size change with no data change keeps the first row in view whatever
|
||||
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
|
||||
instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off
|
||||
at the top as in view and applies `shouldRestorePosition` to size changes too.
|
||||
instead, and a bottom-edge anchor just under the viewport would not hold at all. The LegendList
|
||||
patch counts the rows cut off at the top, and the row just past the bottom, as in view and
|
||||
applies `shouldRestorePosition` to size changes too. It also drops LegendList's Android clamp
|
||||
prediction: the scroll view clamps after the adjustment
|
||||
([scroll view corrections](#scroll-view-corrections)), so the prediction moved a collapse near
|
||||
the end to the end.
|
||||
|
||||
2. Run `apply`.
|
||||
3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so
|
||||
LegendList's container does not re-render and would learn the new height from `onLayout` a task
|
||||
@@ -130,7 +164,7 @@ With every turn collapsed, flinging from a cold open into history did not blank
|
||||
|
||||
### LegendList patch
|
||||
|
||||
`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists
|
||||
`patches/@legendapp+list+3.7.1.patch` carries twelve changes for the timeline. Its header lists
|
||||
them by purpose, and
|
||||
[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream.
|
||||
Drop a hunk when the installed LegendList ships its fix.
|
||||
|
||||
@@ -94,6 +94,7 @@ workspace for a directory clears it. The store lives in memory and resets on dae
|
||||
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
|
||||
| `server/workspace-labels/` | Host-local label catalog, assignment mutations, and explicit subscriptions |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/timeline/` | Owned agent history, provider children, rewind retirements, behind a backend |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
|
||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||
|
||||
@@ -96,6 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `agent.metadata.update.request`; `null` in a patch removes a key |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
@@ -179,6 +180,14 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
|
||||
|
||||
---
|
||||
|
||||
## Runtime-only Agent Timelines
|
||||
|
||||
Agent timelines are not written to `$PASEO_HOME`. `AgentTimeline` owns each agent's history behind
|
||||
a backend, and the only backend is in memory, so a daemon restart loses it and the next resume
|
||||
imports provider history. See [owned history](timeline-sync.md#owned-history).
|
||||
|
||||
---
|
||||
|
||||
## 2. Daemon Configuration
|
||||
|
||||
**Path:** `$PASEO_HOME/config.json`
|
||||
@@ -339,6 +348,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 |
|
||||
@@ -482,6 +499,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `workspace.metadata.update.request`. |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
||||
|
||||
|
||||
+3
-3
@@ -70,9 +70,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. 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -144,7 +144,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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+164
-4
@@ -118,7 +118,7 @@ appear in the plugin catalog. Editing one in development requires a daemon resta
|
||||
Directory, Git, and npm installs cannot use a built-in ID.
|
||||
|
||||
A provider registration with `requiresConfiguration: true` participates only while its ID exists
|
||||
under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }`
|
||||
under `agents.providers`. Hoplite and fx use this for catalog opt-in. Add writes `{ "enabled": true }`
|
||||
without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting
|
||||
`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent
|
||||
provider does not enter executable resolution or catalog discovery.
|
||||
@@ -474,6 +474,10 @@ path shape: a plugin can place a worktree several directories below the default
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
||||
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
The same holds for a hook that runs inside an agent's lifecycle lane: an `agent.open.before` or
|
||||
`agent.create.before` hook that awaits a write to the agent being opened (`setMetadata`, `setLabels`,
|
||||
an option, model, thinking or mode change) waits on the open, which waits on the hook. The hooks
|
||||
reference tells plugin authors to start such a write without awaiting it.
|
||||
|
||||
## Contribute a provider
|
||||
|
||||
@@ -529,13 +533,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
|
||||
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
|
||||
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
||||
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
||||
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
|
||||
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
|
||||
It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on
|
||||
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||
Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
|
||||
(`providerOptions`) in `session.config`. A running-agent change arrives as
|
||||
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
|
||||
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
|
||||
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
|
||||
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
|
||||
its value into an option and is sent both shapes in `session.configure`; delete it with
|
||||
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
@@ -661,6 +673,154 @@ Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
|
||||
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
|
||||
|
||||
## Contribute composer items
|
||||
|
||||
`addComposerItem` mounts a plugin component in the agent, draft and New workspace composers. The
|
||||
public contract is the [composer items reference](../public-docs/plugins/reference.md#composer-items).
|
||||
|
||||
A plugin reads one `ComposerModel` (`packages/app/src/composer/model.ts`): state, subscribe and
|
||||
commands. `toPluginComposer` (`packages/app/src/plugins/composer-items/`) turns its state into the
|
||||
`PluginComposer` an item reads, and the host never learns which kind of composer it got. Two
|
||||
implementations provide it:
|
||||
|
||||
- A running agent's `agentComposer(serverId, agentId)` (`composer/agent-composer/`). It exists
|
||||
without a mounted tab: the prompt and attachments are the agent's draft in the draft store, the
|
||||
settings are the session store's agent, and `model` and `thinking` resolve through the provider
|
||||
catalog with `resolveAgentModelSelection`, the picker's own function, so a read equals what the
|
||||
picker shows. The model loads the catalog itself and resolves with no models until it lands, as the picker does,
|
||||
so a read never differs from what the picker shows. While anything watches the model it holds a
|
||||
`QueryObserver` on the agent's catalog: that is what lets reconnect repair, which refetches only
|
||||
active queries, refresh a catalog nobody has a picker open for, and retry one that failed.
|
||||
`PluginComposersSource.subscribe(selector, listener)` is what holds it for `useComposers`, so
|
||||
the subscription must go through the model's own `subscribe`. Watching again after the catalog
|
||||
was invalidated or failed refetches it, and a read with no watcher starts one deduplicated
|
||||
refresh and shows the cached catalog until it lands. The model keeps one pair of store
|
||||
subscriptions however many components watch it. It is shared for as long as its agent is in the
|
||||
session and not archived, so a command a plugin saved keeps reaching the composer a later tab
|
||||
mounts; it is dropped when the agent is gone or archived and nothing watches it or has one
|
||||
mounted, checked on session changes and when the last holder lets go. The mounted
|
||||
`AgentControls` plugs its input in with `attachInput` so `insertText` lands at the cursor.
|
||||
Without a live input the model writes the stored prompt with `replaceDraftText`, which also asks
|
||||
a mounted input to show it. A workspace the user left stays mounted, display none. Web
|
||||
drops the input ref there; native keeps the input, with a stale cursor and selection.
|
||||
`ComposerInput.isLive()` is how the model tells: it needs the ref and a retained panel that is
|
||||
showing (`useRetainedPanelActive`). Closing a root agent's tab archives
|
||||
it, so a running agent with no mounted composer at all only exists as a subagent whose tab was
|
||||
closed. The daemon does not check `setModel` and `setThinking` against a catalog; the provider
|
||||
decides and its refusal rejects the call.
|
||||
- A draft or New workspace screen's `composerModelFromHalves`, an adapter over the two contexts
|
||||
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.
|
||||
|
||||
`useComposers` and `composers()` reach an agent's model by reference (`plugins/composer-items/source.ts`):
|
||||
`PluginInstallationProvider` provides the plugin's source to every surface, and
|
||||
`createPluginCapabilities` puts `composers` on every command context. `{ focused: true }` is the
|
||||
agent Command Center registration hands to agent items, published through `plugins/focused-agent.ts`
|
||||
(null outside a workspace route). The source reads that module, not the navigation or layout
|
||||
stores, so the plugin runtime does not import them. The remembered workspace selection is
|
||||
navigation restore state and is not the focused agent. The query-cache subscriptions react to
|
||||
`updated` events only: a `useQuery` in another component's render announces `added`, and a
|
||||
subscriber that updates then triggers React's "update a component while rendering a different one"
|
||||
warning.
|
||||
|
||||
Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all
|
||||
render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`),
|
||||
a toolbar icon or a row in the overflow sheet. Model and mode stay specialized but share its trigger,
|
||||
density and overflow. Plugin items count in the density presence in `layout.ts`, so a composer
|
||||
condenses to make room for them. A custom component renders wherever its item is placed, and an
|
||||
item that renders nothing still counts for density; there is no registration for either.
|
||||
|
||||
A command runs against the owner's current state, not the render that built the callback. The
|
||||
draft's form (`openAgentForm`) and options (`openDraftOptions`) are plain stores for that reason,
|
||||
and `draftComposerAgent` rejects a provider the host does not offer. It passes a model id to the form
|
||||
as the picker does, with no catalog check; for a running agent the provider decides. `ComposerControl` owns the pending state and the error toast for callbacks that
|
||||
return a promise.
|
||||
|
||||
The overflow sheet's content portals to the app root, away from the composer. Pass
|
||||
`useComposerSheetBridge()` as the sheet's `contextBridge`, or `useComposer()` returns nothing and
|
||||
the sheet draws no plugin rows.
|
||||
|
||||
## Contribute New workspace items
|
||||
|
||||
`addNewWorkspaceItem` mounts a plugin component on the New workspace screen. The public contract
|
||||
is the [New workspace items reference](../public-docs/plugins/reference.md#new-workspace-items).
|
||||
|
||||
The screen's choices that a plugin may change live in one plain store,
|
||||
`createNewWorkspaceSetup` (`packages/app/src/screens/new-workspace/setup-model.ts`): the base
|
||||
picker selection, the manual isolation, the branch name and the workspace metadata. The pickers and
|
||||
the plugin commands write to it, so a command sees what an earlier command left. Project and host
|
||||
stay in the screen's hooks (`useNewWorkspaceHostSelector` reconciles them against each other), and
|
||||
they are read-only to plugins; a command that switched them would unmount the item that called it.
|
||||
|
||||
The store is built in the render that creates the screen, with what the screen derives from its
|
||||
hooks (the lock, the project, whether the project can make worktrees, whether the host supports
|
||||
metadata), so a command is valid from a child's first effect, which runs before the screen's own
|
||||
effects. `syncInputs` refreshes those inputs in a layout effect on every commit, and commands read
|
||||
them when they run, never from the render that built them. The pickers and the commands pick
|
||||
through the same `chooseBase` and `chooseIsolation`, so a plugin command has the effects of a
|
||||
pick; the screen injects those effects when it builds the store (syncing the picker's PR attachment,
|
||||
remembering the isolation), so a command from a mount effect does the same as a click. Closing the
|
||||
picker stays in the picker's own handler. Add a capability by extending the store;
|
||||
do not read screen state from a plugin component.
|
||||
|
||||
The item host (`PluginNewWorkspaceItems`) keys the items by `(serverId, project id)`, so a change of
|
||||
project or host remounts them. That is what keeps an item's own state and a `Combobox`'s cached
|
||||
results from outliving their project: the `Combobox` cannot tell that an author's inline `search`
|
||||
now means another project, because its identity changes every render. The store clears `branch`
|
||||
and `metadata` on the same change in `syncInputs`, and a remounted item's mount effect sets them
|
||||
again.
|
||||
|
||||
An item's commands come from `setup.commandsFor({ serverId, projectId })`, bound to the target the
|
||||
item was mounted for. Once `syncInputs` reports another target they reject with a "moved" error, so
|
||||
a lookup that finishes after a project switch cannot write into the new project's choices. The
|
||||
reset only clears what was already written; this check covers what is written later. Rejecting, not
|
||||
ignoring, matches the lock: a command's promise resolves only when the change took effect.
|
||||
|
||||
Known limit: an item that issues a command from `useLayoutEffect` on mount after a project or host
|
||||
switch runs before the screen's `syncInputs` layout effect, so its commands (bound to the new
|
||||
target) still see the previous target and reject as moved. This applies to a project switch as well as a host switch, since items
|
||||
remount per `(serverId, project id)`. Passive effects are not affected.
|
||||
|
||||
`ensureWorkspace` sends the store's `branch` as `source.branchName` whenever the worktree cuts a
|
||||
new branch (`startsNewBranch`, which mirrors the daemon: anything but a checkout, including no base
|
||||
at all on a detached HEAD), and its `metadata` on `workspace.create` when it is not empty. The
|
||||
server uses `branchName ?? worktreeSlug` as the new branch. `baseRef.name` is the exact ref
|
||||
creation uses; `branchNameFromRef` is for labels only.
|
||||
|
||||
Items render through `ComposerControl` with `surface="field"`: a `FieldTrigger` (a badge on
|
||||
desktop, a full-width field when compact) and the same pending state, error toast and list as the
|
||||
composer's controls. `ComposerControlLockProvider` carries the screen's lock to them. Plugin
|
||||
`Combobox` is a `select` item with `remoteSearch`: the app `Combobox` does not filter locally, and
|
||||
`PluginCombobox` (`plugins/react-native/composer-controls.tsx`) runs `search` through
|
||||
`useFetchQuery` with `dataShape: "list"`, which keeps the previous results while a newer query runs.
|
||||
`plugins/item-host.tsx` is the host shared by composer and New workspace items.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
|
||||
+68
-3
@@ -30,6 +30,71 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
|
||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||
for native keys and examples.
|
||||
|
||||
### Options flow
|
||||
|
||||
`options` on the agent snapshot is the only option state. Controls describe how to draw an
|
||||
option and read `options`; they hold no value of their own. Any option can be read and set with
|
||||
or without a control.
|
||||
|
||||
- **Create and resume.** The provider opens with the options it is given and emits nothing about
|
||||
them while opening. `openOptionsPatch` carries every option the session ended up with that
|
||||
differs from what it was given, including a setting nobody asked for (a native default such as
|
||||
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
|
||||
daemon stores it before the agent is published. A reopened agent resumes that state, so a
|
||||
native default the provider later changes does not follow. `options` on the snapshot is what
|
||||
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
|
||||
Editing a provider default in `config.json` affects agents the next time they open.
|
||||
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
|
||||
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
|
||||
there is none; the provider returns the setting to its own default if it can, or throws to
|
||||
reject. A key the running session cannot apply (one the provider reads only when it opens) is
|
||||
rejected too, so a stored option always describes what runs. Claude reads most options when it
|
||||
starts its query, and replacing a started query would end its background work, so after the
|
||||
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
|
||||
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
|
||||
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
|
||||
it ended up with when they differ from the request. The daemon stores the patch and that
|
||||
result in one step after the call resolves. Throwing rejects the whole patch and none of it is
|
||||
stored. Emit nothing from inside the call. If the provider's own state moved before it refused,
|
||||
it reports the moved values once as `options_changed` after the call has ended, because the
|
||||
stored options must match what runs.
|
||||
- **One change at a time per agent.** `AgentManager` runs every option, model, thinking and mode
|
||||
change inside the agent's lifecycle lane, the same queue that opens, reopens, closes and
|
||||
archives it. `applyOptions`, `setModel`, `setThinkingOption` and `setMode` are never in flight
|
||||
together on one session, and none of them overlaps an open or reopen. Providers do no
|
||||
sequencing of their own: no queues, no "apply over the options as they are now" handling. A
|
||||
change that arrives while another runs waits for it and then reads the agent's stored options
|
||||
as that one left them.
|
||||
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
|
||||
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
|
||||
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
|
||||
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
|
||||
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
|
||||
behind the provider id, following `derivedFromProviderId`, so a custom provider with
|
||||
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
|
||||
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
|
||||
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
|
||||
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
|
||||
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
|
||||
and provider-agnostic; it stays at the protocol and legacy edges.
|
||||
- **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the
|
||||
agent snapshot for a running or closed agent. A draft and a profile form send everything they
|
||||
would create with (seed, the provider's last-used control values once the provider has said it
|
||||
still draws them, and the user's edits) in `provider.controls.list.request` and draw only what
|
||||
comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once,
|
||||
by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's
|
||||
layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile
|
||||
replaces provider and options in one step, while choosing a provider by hand starts over. A
|
||||
profile saves its stored options plus the form's edits, so options no control draws survive an
|
||||
edit. A daemon without `server_info.features.providerOptionControls` sends `features` and
|
||||
`featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that
|
||||
turns those into `controls` and `options` and back, so nothing in the app branches on it. A
|
||||
create that carries options first lists the controls for its own provider, model, mode and
|
||||
thinking level, since which options are controls differs by model, and sends those as
|
||||
`featureValues`; callers do not prime anything.
|
||||
- **Provider-initiated changes.** A setting the provider changes on its own is an
|
||||
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
|
||||
|
||||
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
||||
closed for Hub unattended execution until it can approve one exact injected MCP
|
||||
server and tool identity without approving native tools.
|
||||
@@ -115,7 +180,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
|
||||
|
||||
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state. It returns `{ controls, options }`: the options a session opened from the draft would run with, so a value the provider resolves natively (an ACP select nobody set) shows in the draft the same way it would on the agent.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
@@ -478,7 +543,7 @@ interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly features?: AgentFeature[];
|
||||
readonly controls?: AgentOptionControl[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
@@ -500,7 +565,7 @@ interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
applyOptions?(options: ProviderOptions): Promise<void>;
|
||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||
} | null;
|
||||
|
||||
+55
-4
@@ -594,7 +594,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 +613,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 +656,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 +666,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, biggest first. 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.
|
||||
|
||||
## 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 +748,7 @@ Rules:
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
Highlights come first, ordered by user impact. Entries within each section (Added, Improved, Fixed) follow the same 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.
|
||||
|
||||
+102
-19
@@ -5,15 +5,9 @@ Agent chat delivery has two paths:
|
||||
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
|
||||
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
|
||||
|
||||
The daemon retains projected items in memory. Each source event advances the stream sequence,
|
||||
then replaces the previous tool state or merges into the current text item. Intermediate payloads
|
||||
are never retained for history or catch-up. Provider history is the durable transcript authority
|
||||
and rebuilds the projection when an agent resumes.
|
||||
|
||||
Pagination bounds each read, not total resident history. Active and idle agents retain their
|
||||
projected transcript; archive releases it and later history reads rebuild it from the provider.
|
||||
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
|
||||
must be released after their deltas have been consumed.
|
||||
Pagination bounds each read, not total resident history. Streaming overhead must remain
|
||||
proportional to the retained transcript. Cumulative text snapshots must be released after their
|
||||
deltas have been consumed.
|
||||
|
||||
The invariants are:
|
||||
|
||||
@@ -32,6 +26,44 @@ bounded item is used for runtime timeline rows and live stream events. Provider
|
||||
applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it
|
||||
again to assistant and reasoning rows the projection merges from many chunks.
|
||||
|
||||
## Owned history
|
||||
|
||||
The daemon owns each agent's history in `AgentTimeline`
|
||||
(`packages/server/src/server/agent/timeline/`): one ordered history per agent, with each provider
|
||||
child's descriptor and history in a child scope of the same agent. Each source event advances the
|
||||
stream sequence, then replaces the previous tool state or merges into the current text item.
|
||||
Intermediate payloads are never retained for history or catch-up.
|
||||
|
||||
History is append-only: rewind, import, reload and archive never delete or rewrite a row. Only
|
||||
deleting the agent, or a provider removing one of its own children, deletes history.
|
||||
|
||||
- **Rewind** retires a range of rows. They leave the conversation and stay stored, in the same
|
||||
epoch. See [Rewind retires rows](#rewind-retires-rows).
|
||||
- **Provider history** is imported only into an agent that has no history. After that, resume
|
||||
does not read it. Reload agent and Unarchive append only the rows the provider has after the
|
||||
owned conversation, as ordinary live rows. Any other difference, including an answer Paseo saw only part of or a prompt sent with
|
||||
attachments, changes nothing and is reported as diverged. A provider child Paseo already holds,
|
||||
listed or retired, is never imported again.
|
||||
- **Archive and reload** keep the history and the provider children. An archived chat reads them
|
||||
without its provider. Deleting the agent deletes its history and every child's, retired ones
|
||||
included.
|
||||
|
||||
`epoch` is the stored history's identity, minted once when the history is created.
|
||||
|
||||
`AgentTimeline` serializes every operation of one agent, child scopes included, and keeps its
|
||||
state in an `AgentTimelineBackend`: the sequence, the rows and retirements, prompt identities, the
|
||||
open rewind, and the children. The backend contract suite is `agent-timeline.test.ts`, run over
|
||||
`TIMELINE_CONTRACT_BACKENDS` in `timeline/testing.ts`. The only backend keeps everything in
|
||||
memory, so a daemon restart loses the owned history: the next resume imports provider history into
|
||||
a new epoch, and clients take the destructive reset below. Rewinds retired before the restart come
|
||||
back if the provider history still holds them. Retained history costs memory for every loaded,
|
||||
closed or archived agent until the daemon exits or the agent is deleted.
|
||||
|
||||
An open rewind is stored with the history. Resuming an agent whose history holds one settles it
|
||||
before the agent takes prompts: a recorded provider cut is committed, and an unknown outcome is
|
||||
judged from the resumed provider history. When that history proves nothing, the rewind stays open
|
||||
and the agent refuses prompts with the unknown-outcome error.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
Client heartbeat reports presence:
|
||||
@@ -102,15 +134,47 @@ updated after the checkpoint does not block it. Forking the current context rema
|
||||
|
||||
## Resume behavior
|
||||
|
||||
Opening, reconnecting, and returning from app background establish the current timeline through
|
||||
bounded catch-up. Switching between continuously subscribed open chats needs no fetch.
|
||||
Focus alone does not mutate timeline state; the response is compared with the local
|
||||
authoritative range first.
|
||||
Opening, reconnecting, and returning from app background establish whether the chat is current.
|
||||
Switching between continuously subscribed open chats needs no fetch. Focus alone does not mutate
|
||||
timeline state; the response is compared with the local authoritative range first.
|
||||
|
||||
Cached history remains readable during recovery. The chat shows Reconnecting to host while the host is
|
||||
offline, then Updating messages until authoritative catch-up completes. 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.
|
||||
A chat with a cached cursor asks the daemon first. The `agent.timeline.set_subscription.request`
|
||||
that attaches the chat carries its `{ epoch, seq }`, and the response answers `current` or `behind`
|
||||
per chat. The daemon attaches before it compares, so an entry committed in between arrives live or
|
||||
counts as behind. `current` means the agent is loaded, the epoch matches, and the cursor is at the
|
||||
head; it completes catch-up with no fetch. The response carries the agent row for each `current`
|
||||
chat, because the timeline fetch is also what refreshes that row and the replica cache stores only
|
||||
part of it. `behind`, or no entry, runs the bounded catch-up. The subscribe handler never loads an
|
||||
agent, so an agent the daemon has not loaded answers `behind` and resumes in that fetch. Hosts without `features.timelineFreshnessCheck` get no cursors and always fetch.
|
||||
|
||||
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. 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
|
||||
agent directory and messages from the chat's own catch-up, and either can land first. So the
|
||||
timeline owner keeps an unsettled turn per chat: the turn its messages were captured during. It is
|
||||
set when a check or catch-up becomes owed while the turn is open, or when cached messages are
|
||||
painted that were saved while one was, and it is saved with those messages in the replica. It
|
||||
carries the turn's start time. While it is set and the agent reads idle, the chat keeps showing that
|
||||
running turn, with its loader and elapsed timer unchanged, and there is no separate catching-up
|
||||
footer. The commit that brings the chat current clears it, in the same store write as the rows, so
|
||||
the final messages and the completed footer land together. A `current` answer is that commit with no
|
||||
rows. A check in flight keeps the hold, and `behind` keeps it until the fetched rows land. A failed
|
||||
catch-up clears it too, and the footer takes the true status. Losing the connection is not a failed
|
||||
catch-up: none can run or fail until the host returns, so the running footer stays beside
|
||||
"Reconnecting to host" and the check on reconnect resolves it. Releasing it offline would put a
|
||||
completed footer under messages known to be behind. Surfaces outside the timeline (the sidebar,
|
||||
tabs, the composer's stop and send controls) read the true status and never see this state. Live
|
||||
delivery needs no hold because `applyStreamEvent` commits queued rows before it applies turn
|
||||
liveness.
|
||||
|
||||
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
|
||||
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
|
||||
@@ -127,7 +191,7 @@ or retry backoff. This cannot keep a mobile socket alive after the operating sys
|
||||
bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport.
|
||||
- When the page overlaps or is adjacent to the local end cursor, only projected items newer than
|
||||
that cursor are applied. Already-covered rows are not replayed.
|
||||
- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the
|
||||
- A true middle gap or epoch change atomically replaces stale canonical history with the
|
||||
latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved
|
||||
local submissions; it never retains two discontiguous canonical ranges.
|
||||
|
||||
@@ -140,6 +204,24 @@ history replay. The pending approval UI can hide that tool from presentation, bu
|
||||
must retain its position. Creating a new history card on rejection places it after the prompt that
|
||||
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
|
||||
|
||||
## Rewind retires rows
|
||||
|
||||
A conversation rewind commits a retirement `{ seq, retireFromSeq }`: active rows anchored in
|
||||
`[retireFromSeq, seq)` leave the conversation, and `seq` is the retirement's own position in the
|
||||
sequence. The provider children spawned by retired tool calls, and their descendants, are retired
|
||||
in the same commit. They leave the listing, keep their history, and drop late provider events.
|
||||
|
||||
Clients learn of a retirement live through `agent.timeline.rewind`, and on pages through
|
||||
`retirements`, which lists the retirements whose `seq` lies inside the page's span. The client
|
||||
drops the rows it holds in that range; rows without a timeline position are unechoed local sends
|
||||
and stay. Retired ranges and the retirement's `seq` count as covered, so forward cursors advance
|
||||
through them. Tail, before and prompt-jump pages are served from active rows, so one served before
|
||||
a retirement the client already applied is stale and is refetched (`viewed-timeline-sync.ts`).
|
||||
|
||||
Clients without `timeline_rewind_operations` cannot drop retired rows. For them the session derives
|
||||
a wire epoch that changes with each retirement, so they take the destructive replacement they
|
||||
always took on rewind. The shim is tagged `COMPAT(timelineRewindOperations)`.
|
||||
|
||||
## Provider child history
|
||||
|
||||
Child transcripts use the same projected-page reconciliation as the main conversation. The client
|
||||
@@ -304,7 +386,7 @@ the draft-create handoff is the one boundary that also permits the legacy canoni
|
||||
arrived first. Generic reducers and consumers do not reimplement message identity matching.
|
||||
|
||||
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
|
||||
rows because a provider may never echo them. A known epoch change or rewind replaces history and
|
||||
rows because a provider may never echo them. A known epoch change replaces history and
|
||||
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
|
||||
acknowledged by the provider, and no other local row, crosses that destructive boundary. A cold
|
||||
reset without an existing epoch is destructive because the client has no continuity anchor.
|
||||
@@ -320,6 +402,7 @@ canonical assistant prefix, it stays in the head lane. No row may be returned in
|
||||
|
||||
## Relevant code
|
||||
|
||||
- Server owned history: `packages/server/src/server/agent/timeline/`
|
||||
- Server live stream forwarding: `packages/server/src/server/session.ts`
|
||||
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
|
||||
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
|
||||
|
||||
@@ -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-21qteVXVoL7S/wF0aGxHNEkTX8in6UF6+wWEZi8N3HA=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.4",
|
||||
"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.15.4",
|
||||
"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.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/server": "0.15.4",
|
||||
"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.15.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/relay": "0.15.4",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.4",
|
||||
"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.15.4",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.4",
|
||||
"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.15.4",
|
||||
"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.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@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.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"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.15.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.4",
|
||||
"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.15.4",
|
||||
"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.15.4",
|
||||
"@getpaseo/highlight": "0.15.4",
|
||||
"@getpaseo/plugin": "0.15.4",
|
||||
"@getpaseo/protocol": "0.15.4",
|
||||
"@getpaseo/relay": "0.15.4",
|
||||
"@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.15.4",
|
||||
"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.15.4",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.4",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -28,7 +28,7 @@ const ACP_PROVIDER = {
|
||||
|
||||
interface ProviderCatalogDaemonClient {
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { hopliteProvider?: boolean; plugins?: boolean };
|
||||
features?: { fxProvider?: boolean; hopliteProvider?: boolean; plugins?: boolean };
|
||||
} | null;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -58,6 +58,31 @@ test.describe("ACP provider catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("adds fx from the catalog with only a config entry", async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "fx-catalog-e2e",
|
||||
});
|
||||
try {
|
||||
expect(client.getLastServerInfoMessage()?.features?.fxProvider).toBe(true);
|
||||
await client.patchDaemonConfig({ removeProviders: ["fx"] });
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
|
||||
await findAcpCatalogProvider(page, "fx");
|
||||
await page.screenshot({ path: testInfo.outputPath("fx-catalog.png") });
|
||||
await installAcpCatalogProvider(page, "fx");
|
||||
await expectProviderInstalledInSettings(page, "fx");
|
||||
await page.screenshot({ path: testInfo.outputPath("fx-installed.png") });
|
||||
expect((await client.getDaemonConfig()).config.providers?.fx).toEqual({ enabled: true });
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ removeProviders: ["fx"] }).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
import { expectNearBottom, scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
|
||||
import { holdRewindCompletion } from "../support/helpers/agent-timeline-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { userScrollsTimelineToHistoryStart } from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
closeWorkspaceAgentTab,
|
||||
fetchAgentArchivedAt,
|
||||
primeAdditionalPage,
|
||||
resetSeededPageState,
|
||||
} from "../support/helpers/archive-tab";
|
||||
|
||||
async function completeSubmittedTurn(
|
||||
page: Page,
|
||||
@@ -46,26 +53,102 @@ async function expectTurnCompletesNormally(
|
||||
await expectAgentIdle(page);
|
||||
}
|
||||
|
||||
async function openAgentOnAnotherClient(
|
||||
page: Page,
|
||||
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
|
||||
): Promise<void> {
|
||||
await primeAdditionalPage(page);
|
||||
await resetSeededPageState(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
/** The prompts the chat shows, in order, each exactly once. */
|
||||
async function expectPrompts(page: Page, prompts: string[]): Promise<void> {
|
||||
const messages = page.getByTestId("user-message");
|
||||
await expect(messages).toHaveCount(prompts.length);
|
||||
await expect(messages).toContainText(prompts);
|
||||
}
|
||||
|
||||
async function reloadAgentFromTabMenu(page: Page, agentId: string): Promise<void> {
|
||||
const tabId = `workspace-tab-agent_${agentId}`;
|
||||
await page.getByTestId(tabId).filter({ visible: true }).first().click({ button: "right" });
|
||||
await page.getByTestId(`workspace-tab-context-agent_${agentId}-reload-agent`).click();
|
||||
}
|
||||
|
||||
async function archiveAgentByClosingTab(
|
||||
page: Page,
|
||||
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
|
||||
): Promise<void> {
|
||||
await closeWorkspaceAgentTab(page, agent.agentId);
|
||||
await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull();
|
||||
}
|
||||
|
||||
async function unarchiveAgent(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectToast(page: Page, message: string): Promise<void> {
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(message, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// The mock provider's resumed session holds no history, so it never matches what Paseo shows.
|
||||
const UNSYNCED_RELOAD =
|
||||
"Reloaded agent, but the provider's history doesn't match this chat, so it wasn't synced";
|
||||
|
||||
/** The prompts of the whole conversation, after scrolling up through its older pages. */
|
||||
async function expectConversationPrompts(page: Page, prompts: string[]): Promise<void> {
|
||||
const messages = page.getByTestId("user-message");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
return await messages.count();
|
||||
})
|
||||
.toBe(prompts.length);
|
||||
await expect(messages).toContainText(prompts);
|
||||
}
|
||||
|
||||
// A row React remounts loses this attribute; one that stays mounted keeps it.
|
||||
async function markMounted(row: Locator): Promise<void> {
|
||||
await row.evaluate((element) => element.setAttribute("data-e2e-mounted", "before-rewind"));
|
||||
}
|
||||
|
||||
async function expectStillMounted(row: Locator): Promise<void> {
|
||||
await expect(row).toHaveAttribute("data-e2e-mounted", "before-rewind");
|
||||
}
|
||||
|
||||
test.describe("Agent message rewind", () => {
|
||||
test("rewinds a submitted prompt without replaying history and preserves a human draft", async ({
|
||||
test("rewinds a prompt in place on every open client and keeps a human draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
test.setTimeout(150_000);
|
||||
const retainedPrompt = "Keep this completed turn after rewind.";
|
||||
const rewoundPrompt = "Remove this completed turn after rewind.";
|
||||
const nextPrompt = "Continue after the rewind.";
|
||||
const preservedDraft = "Keep this human draft after rewind.";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "message-rewind-roundtrip-",
|
||||
title: "Message rewind roundtrip",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const otherClient = await page.context().newPage();
|
||||
|
||||
try {
|
||||
const rewindCompletion = await holdRewindCompletion(page, agent.agentId);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await completeSubmittedTurn(page, agent, retainedPrompt);
|
||||
const retainedMessage = await completeSubmittedTurn(page, agent, retainedPrompt);
|
||||
const userMessage = await completeSubmittedTurn(page, agent, rewoundPrompt);
|
||||
|
||||
await test.step("a second client opens the same chat", async () => {
|
||||
await openAgentOnAnotherClient(otherClient, agent);
|
||||
await expectPrompts(otherClient, [retainedPrompt, rewoundPrompt]);
|
||||
});
|
||||
|
||||
await markMounted(retainedMessage);
|
||||
await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
@@ -73,18 +156,91 @@ test.describe("Agent message rewind", () => {
|
||||
await fillComposerDraft(page, preservedDraft);
|
||||
rewindCompletion.clearTimelineStreamCount();
|
||||
|
||||
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
|
||||
await rewindCompletion.waitForDelayedResponse();
|
||||
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
|
||||
expect(rewindCompletion.timelineStreamCount()).toBe(0);
|
||||
rewindCompletion.release();
|
||||
await rewind;
|
||||
await test.step("the rewind stays pending until the daemon answers", async () => {
|
||||
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
|
||||
await rewindCompletion.waitForDelayedResponse();
|
||||
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
|
||||
expect(rewindCompletion.timelineStreamCount()).toBe(0);
|
||||
rewindCompletion.release();
|
||||
await rewind;
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: rewoundPrompt })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectNearBottom(page);
|
||||
await test.step("the rewound turn leaves without reloading the chat", async () => {
|
||||
await expectPrompts(page, [retainedPrompt]);
|
||||
await expectStillMounted(retainedMessage);
|
||||
expect(rewindCompletion.timelineFetchCount()).toBe(0);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectNearBottom(page);
|
||||
await expectPrompts(otherClient, [retainedPrompt]);
|
||||
});
|
||||
|
||||
await test.step("the next prompt appears once on both clients", async () => {
|
||||
await completeSubmittedTurn(otherClient, agent, nextPrompt);
|
||||
await expectPrompts(otherClient, [retainedPrompt, nextPrompt]);
|
||||
await expectPrompts(page, [retainedPrompt, nextPrompt]);
|
||||
});
|
||||
|
||||
await test.step("a reconnecting client loads the rewound conversation", async () => {
|
||||
await otherClient.reload();
|
||||
await expectPrompts(otherClient, [retainedPrompt, nextPrompt]);
|
||||
});
|
||||
} finally {
|
||||
await otherClient.close();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the rewound conversation through Reload agent, archive and unarchive", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
const keptPrompt = "Keep this turn through reload and archive.";
|
||||
const rewoundPrompt = "Rewind this turn before reloading.";
|
||||
const nextPrompt = "Continue after the rewind, then reload.";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "message-rewind-reload-archive-",
|
||||
title: "Message rewind reload archive",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await completeSubmittedTurn(page, agent, keptPrompt);
|
||||
const rewound = await completeSubmittedTurn(page, agent, rewoundPrompt);
|
||||
await rewindConversation(page, rewound, rewoundPrompt);
|
||||
await expectPrompts(page, [keptPrompt]);
|
||||
await completeSubmittedTurn(page, agent, nextPrompt);
|
||||
await expectPrompts(page, [keptPrompt, nextPrompt]);
|
||||
|
||||
await test.step("Reload agent keeps the chat and says the provider's history was not synced", async () => {
|
||||
await reloadAgentFromTabMenu(page, agent.agentId);
|
||||
await expectToast(page, UNSYNCED_RELOAD);
|
||||
await expectPrompts(page, [keptPrompt, nextPrompt]);
|
||||
await expectComposerVisible(page);
|
||||
});
|
||||
|
||||
await test.step("the archived chat shows the rewound conversation", async () => {
|
||||
await archiveAgentByClosingTab(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectConversationPrompts(page, [keptPrompt, nextPrompt]);
|
||||
});
|
||||
|
||||
await test.step("unarchiving keeps the rewound conversation", async () => {
|
||||
await unarchiveAgent(page);
|
||||
await expectToast(page, UNSYNCED_RELOAD);
|
||||
await expectComposerVisible(page);
|
||||
await expectConversationPrompts(page, [keptPrompt, nextPrompt]);
|
||||
});
|
||||
|
||||
await test.step("a reconnecting client loads the same conversation", async () => {
|
||||
await page.reload();
|
||||
await expectComposerVisible(page);
|
||||
await expectConversationPrompts(page, [keptPrompt, nextPrompt]);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
if (!raw) return null;
|
||||
const preferences = JSON.parse(raw) as FormPreferences;
|
||||
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
|
||||
return preferences.providerPreferences?.[providerId]?.options ?? null;
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||
);
|
||||
@@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
provider: featureProviderId,
|
||||
model: featureModelId,
|
||||
modeId: "bypassPermissions",
|
||||
featureValues: { fast_mode: true },
|
||||
options: { fast_mode: true },
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
@@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: {
|
||||
provider: featureProviderId,
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -146,36 +146,6 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
});
|
||||
|
||||
test("opens a remote Markdown image whose URL needs normalization", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Remote image with a mixed-case host",
|
||||
fileName: "remote.png",
|
||||
width: 240,
|
||||
height: 160,
|
||||
});
|
||||
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
|
||||
const url = delivery.url.replace("127.0.0.1", "LOCALHOST");
|
||||
try {
|
||||
delivery.release();
|
||||
const agent = await createSettledMockAgent(workspace, "Remote image lightbox");
|
||||
await emitSettledAssistantImage(workspace.client, agent, image, url);
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
const rendered = page.getByRole("button", { name: image.alt }).first();
|
||||
await expect(rendered).toHaveJSProperty("naturalWidth", image.width);
|
||||
await rendered.click();
|
||||
await expect(page.getByTestId("attachment-lightbox-canvas")).toBeVisible();
|
||||
await page.getByTestId("zoomable-viewport-toolbar").hover();
|
||||
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Close image", exact: true }).click();
|
||||
await expect(page.getByTestId("attachment-lightbox-image")).toHaveCount(0);
|
||||
} finally {
|
||||
await delivery.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reloading a timeline reserves image height without a load-time scroll jump", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
|
||||
@@ -11,12 +11,21 @@ import {
|
||||
seedLongMockAgentTimeline,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
answerFreshnessCheck,
|
||||
commitTimelineTurnsWhileDisconnected,
|
||||
disconnectViewedTimeline,
|
||||
expectOneResumeCheckWithoutTail,
|
||||
expectResumeAnsweredWithoutFetch,
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
hideTabAndReturn,
|
||||
rememberTimelineRequestCounts,
|
||||
releaseHeldCatchUp,
|
||||
restoreUnchangedViewedTimeline,
|
||||
restoreViewedTimelineHoldingCatchUp,
|
||||
restoreViewedTimelineWithHeldResponse,
|
||||
reopenWithFreshnessCheckUnanswered,
|
||||
waitForResumeAnswer,
|
||||
reopenAppWatchingForUpdatingToast,
|
||||
watchForUpdatingToast,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
|
||||
test.describe("Agent timeline resume", () => {
|
||||
@@ -42,7 +51,9 @@ test.describe("Agent timeline resume", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("an unchanged resume leaves a scrolled-up timeline untouched", async ({ page }) => {
|
||||
test("returning to an unchanged chat shows no update and leaves it untouched", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
@@ -51,13 +62,86 @@ test.describe("Agent timeline resume", () => {
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await scrollTimelinePromptIntoView(page, agent.initialTailAnchorPrompt);
|
||||
const presentation = await rememberTimelinePresentation(page, agent.initialTailAnchorPrompt);
|
||||
|
||||
await test.step("back from the background", async () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
const expectNoUpdatingToast = await watchForUpdatingToast(page);
|
||||
|
||||
await hideTabAndReturn(page);
|
||||
await waitForResumeAnswer(gate, requests);
|
||||
|
||||
await expectNoUpdatingToast();
|
||||
await expectTimelinePresentationUnchanged(page, presentation);
|
||||
expectResumeAnsweredWithoutFetch(gate, requests);
|
||||
});
|
||||
|
||||
await test.step("back from a dropped connection", async () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
const expectNoUpdatingToast = await watchForUpdatingToast(page);
|
||||
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
await restoreUnchangedViewedTimeline(page, gate);
|
||||
|
||||
await expectNoUpdatingToast();
|
||||
await expectTimelinePresentationUnchanged(page, presentation);
|
||||
expectResumeAnsweredWithoutFetch(gate, requests);
|
||||
});
|
||||
|
||||
await test.step("back from reopening the app", async () => {
|
||||
const requests = rememberTimelineRequestCounts(gate);
|
||||
|
||||
const expectNoUpdatingToast = await reopenAppWatchingForUpdatingToast(page);
|
||||
await waitForResumeAnswer(gate, requests);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
|
||||
await expectNoUpdatingToast();
|
||||
expectResumeAnsweredWithoutFetch(gate, requests);
|
||||
});
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
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 presentation = await rememberTimelinePresentation(page, agent.newestPrompt);
|
||||
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
const background = await commitTimelineTurnsWhileDisconnected(agent, 1);
|
||||
await restoreViewedTimelineHoldingCatchUp(page, gate);
|
||||
|
||||
await expectTimelinePresentationUnchanged(page, presentation);
|
||||
expectOneResumeCheckWithoutTail(gate, requests);
|
||||
await releaseHeldCatchUp(page, gate);
|
||||
await expectTimelinePromptVisible(page, background.newestPrompt);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
seedLongMockAgentTimeline,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import {
|
||||
commitTimelineTurnsWhileDisconnected,
|
||||
disconnectViewedTimeline,
|
||||
restoreViewedTimelineWithHeldResponse,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
@@ -25,6 +26,7 @@ test.describe("Agent timeline sync retry", () => {
|
||||
|
||||
gate.failTimelineResponses(agent.agentId);
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
await commitTimelineTurnsWhileDisconnected(agent, 1);
|
||||
await restoreViewedTimelineWithHeldResponse(page, gate);
|
||||
|
||||
const callout = page.getByTestId("agent-timeline-sync-error");
|
||||
|
||||
@@ -21,7 +21,10 @@ interface Box {
|
||||
interface TurnEndFrame {
|
||||
at: number;
|
||||
running: boolean;
|
||||
replyLength: number;
|
||||
footer: Box | null;
|
||||
footerText: string;
|
||||
elapsed: string | null;
|
||||
userRow: Box | null;
|
||||
replyVisible: boolean;
|
||||
}
|
||||
@@ -61,10 +64,13 @@ async function recordTurnEndFrames(
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => isVisible(candidate));
|
||||
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.map(boxOf)
|
||||
.filter((box): box is Box => box !== null)
|
||||
.sort((left, right) => left.top - right.top);
|
||||
const footer = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.map((element) => ({ element, box: boxOf(element) }))
|
||||
.filter((row): row is { element: Element; box: Box } => row.box !== null)
|
||||
.sort((left, right) => left.box.top - right.box.top)
|
||||
.at(-1);
|
||||
const elapsed =
|
||||
viewport?.querySelector('[data-testid="turn-working-elapsed"]')?.textContent ?? null;
|
||||
const userRow = Array.from(
|
||||
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
|
||||
).find((candidate) => candidate.textContent?.includes(prompt));
|
||||
@@ -74,10 +80,16 @@ async function recordTurnEndFrames(
|
||||
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
|
||||
(candidate) => candidate.textContent?.includes(reply),
|
||||
);
|
||||
const assistantRows = Array.from(
|
||||
viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? [],
|
||||
);
|
||||
state.frames.push({
|
||||
at: performance.now(),
|
||||
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
|
||||
footer: footers.at(-1) ?? null,
|
||||
replyLength: assistantRows.reduce((sum, row) => sum + (row.textContent?.length ?? 0), 0),
|
||||
footer: footer?.box ?? null,
|
||||
footerText: (footer?.element.textContent ?? "").replace(elapsed ?? "", ""),
|
||||
elapsed,
|
||||
userRow: boxOf(userRow),
|
||||
replyVisible: boxOf(replyRow) !== null,
|
||||
});
|
||||
@@ -147,7 +159,7 @@ function describeFrames(frames: TurnEndFrame[]): string {
|
||||
(frame, index) =>
|
||||
`${index}: ${frame.running ? "running " : "complete"} footer=${
|
||||
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
|
||||
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||
} timer=${frame.elapsed ?? "none"} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -182,6 +194,192 @@ function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | nu
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
interface ReturnedTurn {
|
||||
frames: TurnEndFrame[];
|
||||
reconnectedAt: number;
|
||||
finalReplyLength: number;
|
||||
}
|
||||
|
||||
// The agent finishes while the socket is down, then the catch-up is held so the idle status
|
||||
// reaches the chat first. Painted frames are sampled from the running footer until everything
|
||||
// has settled.
|
||||
async function returnAfterTurnFinished(input: {
|
||||
page: Page;
|
||||
agent: MockAgentWorkspace;
|
||||
prompt: string;
|
||||
leaveWhile: (gate: DaemonWebSocketGate) => Promise<void>;
|
||||
}): Promise<ReturnedTurn> {
|
||||
const { page, agent, prompt, leaveWhile } = input;
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
|
||||
await recordTurnEndFrames(page, { prompt, reply: "" });
|
||||
await leaveWhile(gate);
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
const reconnectedAt = await page.evaluate(() => performance.now());
|
||||
gate.restore();
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await expectAgentIdle(page);
|
||||
|
||||
await recordPaintsFor(page, 2_000);
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expect(page.getByTestId("turn-footer").last()).toBeVisible();
|
||||
await recordPaintsFor(page, 500);
|
||||
const frames = await stopRecording(page);
|
||||
return { frames, reconnectedAt, finalReplyLength: frames.at(-1)?.replyLength ?? 0 };
|
||||
}
|
||||
|
||||
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
|
||||
async function leaveWithOnlyThePromptOnScreen(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
gate: DaemonWebSocketGate,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await submitMessage(page, prompt);
|
||||
await Promise.all([
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
}
|
||||
|
||||
async function leaveWithAPartialReplyOnScreen(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
gate: DaemonWebSocketGate,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
|
||||
function elapsedSeconds(frame: TurnEndFrame): number {
|
||||
const units: Record<string, number> = { h: 3600, m: 60, s: 1 };
|
||||
return [...(frame.elapsed ?? "").matchAll(/(\d+)([hms])/g)].reduce(
|
||||
(total, [, value, unit]) => total + Number(value) * units[unit],
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function expectFooterUnchangedUntilMessagesLand(
|
||||
{ frames, reconnectedAt }: ReturnedTurn,
|
||||
record: string,
|
||||
): void {
|
||||
const held = frames.slice(
|
||||
frames.findIndex((frame) => frame.at >= reconnectedAt),
|
||||
frames.findIndex((frame) => frame.at >= reconnectedAt && !frame.running),
|
||||
);
|
||||
expect(
|
||||
held.length,
|
||||
`the running footer must hold until the messages land\n${record}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
new Set(held.map((frame) => `${frame.footer?.top}+${frame.footer?.height}`)).size,
|
||||
`the held footer must not move or resize\n${record}`,
|
||||
).toBe(1);
|
||||
expect(
|
||||
new Set(held.map((frame) => frame.footerText)).size,
|
||||
`the held footer's content must not change\n${record}`,
|
||||
).toBe(1);
|
||||
expect(
|
||||
held.filter((frame) => frame.elapsed === null).map((frame) => frame.at),
|
||||
`the timer must stay on screen\n${record}`,
|
||||
).toEqual([]);
|
||||
const seconds = held.map(elapsedSeconds);
|
||||
expect(seconds, `the timer must keep counting up\n${record}`).toEqual(
|
||||
seconds.toSorted((left, right) => left - right),
|
||||
);
|
||||
}
|
||||
|
||||
function expectReturnLandsWhole(returned: ReturnedTurn): void {
|
||||
const { frames, finalReplyLength } = returned;
|
||||
const record = `frames:\n${describeFrames(frames)}`;
|
||||
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
|
||||
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
|
||||
const fromRunning = frames.slice(firstRunning);
|
||||
|
||||
expect(
|
||||
fromRunning.filter((frame) => frame.footer === null).map((frame) => frame.at),
|
||||
`the footer must never disappear\n${record}`,
|
||||
).toEqual([]);
|
||||
|
||||
const completed = fromRunning.filter((frame) => !frame.running);
|
||||
expect(completed.length, record).toBeGreaterThan(0);
|
||||
// The first Markdown on a page paints an empty row until the web parser loads. Only text that
|
||||
// is present and short of the final reply is stale.
|
||||
expect(
|
||||
completed
|
||||
.filter((frame) => frame.replyLength > 0 && frame.replyLength !== finalReplyLength)
|
||||
.map((frame) => frame.at),
|
||||
`a completed footer must never sit under stale messages\n${record}`,
|
||||
).toEqual([]);
|
||||
|
||||
expectFooterUnchangedUntilMessagesLand(returned, record);
|
||||
}
|
||||
|
||||
async function withFirstTurnAgent(
|
||||
model: string,
|
||||
run: (agent: MockAgentWorkspace) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-return-",
|
||||
title: "Turn return",
|
||||
model,
|
||||
});
|
||||
try {
|
||||
await run(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Returning to a turn that finished while away", () => {
|
||||
test("keeps the running footer until the messages land when only the prompt was on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await withFirstTurnAgent("e2e-fast-stream", async (agent) => {
|
||||
const prompt = "emit 1 coalesced agent stream updates";
|
||||
const returned = await returnAfterTurnFinished({
|
||||
page,
|
||||
agent,
|
||||
prompt,
|
||||
leaveWhile: (gate) => leaveWithOnlyThePromptOnScreen(page, agent, gate, prompt),
|
||||
});
|
||||
expectReturnLandsWhole(returned);
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the running footer until the messages land under a partial reply", async ({
|
||||
page,
|
||||
}) => {
|
||||
await withFirstTurnAgent("ten-second-stream", async (agent) => {
|
||||
const prompt = "Return partial prompt";
|
||||
const returned = await returnAfterTurnFinished({
|
||||
page,
|
||||
agent,
|
||||
prompt,
|
||||
leaveWhile: (gate) => leaveWithAPartialReplyOnScreen(page, agent, gate, prompt),
|
||||
});
|
||||
expectReturnLandsWhole(returned);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Turn end", () => {
|
||||
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
|
||||
|
||||
@@ -13,25 +13,30 @@ async function setFontFamily(page: Page, name: string, family: string) {
|
||||
await input.press("Tab");
|
||||
}
|
||||
|
||||
for (const name of ["Code font family", "Interface font family"]) {
|
||||
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
|
||||
await openAppearance(page);
|
||||
await setFontFamily(page, name, "'Fira Code', monospace");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
test("keeps settings usable while editing code and interface font families", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openAppearance(page);
|
||||
|
||||
await setFontFamily(page, name, "'foo");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
for (const name of ["Code font family", "Interface font family"]) {
|
||||
await test.step(`reject an unfinished quote in ${name} and restore the default`, async () => {
|
||||
await setFontFamily(page, name, "'Fira Code', monospace");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await setFontFamily(page, name, "");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("");
|
||||
});
|
||||
}
|
||||
await setFontFamily(page, name, "'foo");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath(`rejected-${name}.png`) });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await setFontFamily(page, name, "");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type IsolatedHostDaemon,
|
||||
} from "../support/helpers/isolated-host-daemon";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
import { setTabVisibility } from "../support/helpers/workspace-ui";
|
||||
|
||||
const test = base.extend<{ reconnectHost: IsolatedHostDaemon }>({
|
||||
reconnectHost: async ({ e2eWorker }, provide) => {
|
||||
@@ -37,17 +38,6 @@ async function refocusConnectedTab(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Online", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function setTabVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
|
||||
await page.evaluate((state) => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => state === "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, visibility);
|
||||
}
|
||||
|
||||
async function openHostOverview(page: Page, daemon: IsolatedHostDaemon): Promise<void> {
|
||||
await page.goto("/");
|
||||
await seedSavedSettingsHosts(page, [
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { fillComposerDraft } from "../support/helpers/composer";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
} from "../support/helpers/composer";
|
||||
import {
|
||||
expectComposerToolbarOnTextRails,
|
||||
expectTrailingActionSwap,
|
||||
expectNoCollapsedComposerToolbarFrame,
|
||||
expectNoFrameTruncatesModelBesideThinking,
|
||||
readSettledModelTriggerLabels,
|
||||
readTrailingActionFrames,
|
||||
recordComposerToolbarFrames,
|
||||
recordModelTriggerFramesFromLoad,
|
||||
recordTrailingActionFrames,
|
||||
sweepModelTriggerLabels,
|
||||
} from "../support/helpers/composer-control-density";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
@@ -15,6 +22,9 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const SETTLE_MS = 1_000;
|
||||
const TRAILING_ACTION_SETTLE_MS = 500;
|
||||
const VOICE_MODE = "Enable Voice mode";
|
||||
const SEND = "Send message";
|
||||
|
||||
async function seedSettledMockAgent(workspace: SeededWorkspace, title: string) {
|
||||
const agent = await workspace.client.createAgent({
|
||||
@@ -37,6 +47,52 @@ function visibleAgentTab(page: import("@playwright/test").Page, agentId: string)
|
||||
return page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
test.describe("Composer trailing action", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "wide", width: 1280, height: 800 },
|
||||
{ name: "compact", width: 390, height: 844 },
|
||||
]) {
|
||||
test(`swaps in place between voice mode and Send at ${viewport.name} width`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-trailing-action-",
|
||||
title: "Trailing action",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByRole("button", { name: VOICE_MODE, exact: true })).toBeVisible();
|
||||
const composer = composerLocator(page);
|
||||
await composer.focus();
|
||||
|
||||
await recordTrailingActionFrames(page);
|
||||
await composer.press("x");
|
||||
await expect(composer).toHaveValue("x");
|
||||
await page.waitForTimeout(TRAILING_ACTION_SETTLE_MS);
|
||||
expectTrailingActionSwap(await readTrailingActionFrames(page), {
|
||||
from: VOICE_MODE,
|
||||
to: SEND,
|
||||
});
|
||||
|
||||
await recordTrailingActionFrames(page);
|
||||
await composer.press("Backspace");
|
||||
await expect(composer).toHaveValue("");
|
||||
await page.waitForTimeout(TRAILING_ACTION_SETTLE_MS);
|
||||
expectTrailingActionSwap(await readTrailingActionFrames(page), {
|
||||
from: SEND,
|
||||
to: VOICE_MODE,
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Composer control density across tab switches", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { holdNextPreviewLoad } from "../support/helpers/image-preview";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -38,15 +37,6 @@ function editor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
|
||||
async function expectTallImageFrame(frame: Locator): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await frame.boundingBox();
|
||||
return box ? box.height - box.width : 0;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function hasHorizontalOverflow(element: HTMLElement): boolean {
|
||||
return element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
@@ -739,10 +729,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "shape.svg"),
|
||||
await readFile(path.resolve(__dirname, "../mobile/fixtures/native-preview.svg")),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
@@ -775,7 +761,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = visibleFilePane.locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const imageCanvas = visibleFilePane.getByTestId("image-file-preview-canvas");
|
||||
const imageCanvas = page.getByTestId("image-file-preview-canvas");
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await imageCanvas.hover();
|
||||
const transformedContent = imageCanvas.locator(":scope > div").first();
|
||||
@@ -788,34 +774,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
|
||||
await openWorkspaceFile(page, "shape.svg");
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await expect(image).toBeVisible();
|
||||
await expect(image).toHaveJSProperty("complete", true);
|
||||
await expect(image).toHaveJSProperty("naturalWidth", 240);
|
||||
|
||||
const oldSource = await image.evaluate((element: HTMLImageElement) => ({
|
||||
url: element.currentSrc,
|
||||
width: element.naturalWidth,
|
||||
height: element.naturalHeight,
|
||||
}));
|
||||
const pendingLoad = await holdNextPreviewLoad(visibleFilePane, oldSource.url);
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "shape.svg"),
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="240"><rect width="160" height="240" fill="green"/></svg>',
|
||||
);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(oldSource.url);
|
||||
await pendingLoad.wait();
|
||||
// Native can deliver the old source's queued event to this new listener.
|
||||
await pendingLoad.deliver(oldSource);
|
||||
await expect(imageCanvas).toHaveCount(0);
|
||||
} finally {
|
||||
await pendingLoad.release();
|
||||
}
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await expectTallImageFrame(transformedContent);
|
||||
});
|
||||
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
expectHostInHostPicker,
|
||||
hostConfirmation,
|
||||
openPairingLink,
|
||||
saveLinkedHostBeforeLoad,
|
||||
reopenPairingLinkWithSavedHosts,
|
||||
type LinkedHost,
|
||||
} from "../support/helpers/host-confirmation";
|
||||
|
||||
@@ -20,7 +20,9 @@ const linkedHost: LinkedHost = {
|
||||
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
|
||||
};
|
||||
|
||||
test("a pairing link for a new host asks before saving it", async ({ page }) => {
|
||||
test("a pairing link asks before saving a host and reconnects without asking again", async ({
|
||||
page,
|
||||
}) => {
|
||||
const link = buildPairingLink(linkedHost);
|
||||
|
||||
await test.step("Cancel saves nothing and stays on the page", async () => {
|
||||
@@ -39,13 +41,10 @@ test("a pairing link for a new host asks before saving it", async ({ page }) =>
|
||||
await openSettings(page);
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
|
||||
});
|
||||
});
|
||||
|
||||
test("a pairing link for a saved host connects without asking", async ({ page }) => {
|
||||
await saveLinkedHostBeforeLoad(page, linkedHost);
|
||||
|
||||
await openPairingLink(page, buildPairingLink(linkedHost));
|
||||
|
||||
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
await test.step("reopening the saved host link connects without asking", async () => {
|
||||
await reopenPairingLinkWithSavedHosts(page, link);
|
||||
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { expectComposerModel } from "../support/helpers/agent-profiles";
|
||||
import {
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
@@ -69,6 +70,8 @@ test.describe("New workspace live pin", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, seeded);
|
||||
// The composer appears before its provider catalog has hydrated.
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await submitNewWorkspacePrompt(page, "Pin me while I start");
|
||||
|
||||
await creation.waitForCreateRequest();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from "../support/fixtures";
|
||||
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);
|
||||
await withComposerControlsExample(page, async (composer) => {
|
||||
await composer.narrowTheWindow();
|
||||
await composer.openAgentFeatures();
|
||||
await composer.expectFeatureRow("Needs review");
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
CHECKLIST,
|
||||
withComposerToolsExample,
|
||||
withPluginFocusingAComposer,
|
||||
withPluginFocusingASubagent,
|
||||
withPluginFocusingAnAgentWithContextUsage,
|
||||
withPluginHoldingNewWorkspaceComposer,
|
||||
withPluginWritingToAnotherWorkspace,
|
||||
} from "../support/helpers/plugin-composers-by-reference";
|
||||
|
||||
test("a panel in the Explorer inserts at the cursor of the focused agent's composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.openComposerToolsInExplorer();
|
||||
await author.typeInComposer("start end", 5);
|
||||
await author.insertChecklistFromPanel();
|
||||
await author.expectComposerText(`start${CHECKLIST} end`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a Command Center item changes an agent's metadata through its composer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.openComposerToolsInExplorer();
|
||||
await author.expectPanelShowsReview(false);
|
||||
await author.markForReviewFromCommandCenter();
|
||||
await author.expectPanelShowsReview(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("the panel follows which agent is focused", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.openComposerToolsInExplorer();
|
||||
await author.expectPanelFocusedAgent(true);
|
||||
await author.focusANewAgentTab();
|
||||
await author.expectPanelFocusedAgent(false);
|
||||
await author.focusTheAgentTab();
|
||||
await author.expectPanelFocusedAgent(true);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginWritingToAnotherWorkspace(page, async (author) => {
|
||||
await author.openTargetAgent();
|
||||
await author.goToAnotherWorkspace();
|
||||
await author.insertFromCommandCenter();
|
||||
await author.returnToTargetWorkspace();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { withNewWorkspaceIssuesExample } from "../support/helpers/plugin-new-workspace-items";
|
||||
|
||||
for (const layout of [
|
||||
{ name: "desktop", compact: false },
|
||||
{ name: "compact", compact: true },
|
||||
]) {
|
||||
test(`a plugin's New workspace item sets the branch and metadata on ${layout.name}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(page, { compact: layout.compact }, async (screen) => {
|
||||
await screen.chooseWorktree();
|
||||
await screen.chooseIssue("PAS-102 Crash on resume");
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).toBe("pas-102-crash-on-resume");
|
||||
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-102" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("a branch chosen while Local applies once the user switches to a new worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(page, { compact: false }, async (screen) => {
|
||||
await screen.chooseIssue("PAS-101 Slow sidebar");
|
||||
await screen.chooseWorktree();
|
||||
const created = await screen.createWorkspace({ prompt: "" });
|
||||
expect(created.branch).toBe("pas-101-slow-sidebar");
|
||||
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-101" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin's branch applies when the project has no default base to branch off", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, detachedHead: true },
|
||||
async (screen) => {
|
||||
await screen.chooseWorktree();
|
||||
await screen.chooseIssue("PAS-101 Slow sidebar");
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).toBe("pas-101-slow-sidebar");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("a branch and metadata chosen for one project are not sent for another", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, otherProject: true },
|
||||
async (screen) => {
|
||||
await screen.chooseWorktree();
|
||||
await screen.chooseIssue("PAS-101 Slow sidebar");
|
||||
await screen.switchToOtherProject();
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).not.toBe("pas-101-slow-sidebar");
|
||||
expect(created.metadata ?? {}).toEqual({});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("an item's Issue list does not keep the previous project's results after a project switch", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, otherProject: true, searchesByProject: true },
|
||||
async (screen) => {
|
||||
await screen.openIssueList();
|
||||
await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, true);
|
||||
await screen.closeIssueList();
|
||||
await screen.switchToOtherProject();
|
||||
await screen.openIssueList();
|
||||
await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("an isolation set from a mount effect is remembered like a pick", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, setsOnMount: true },
|
||||
async (screen) => {
|
||||
await screen.expectIsolation("worktree");
|
||||
await screen.reopenWithoutPlugin();
|
||||
await screen.expectIsolation("worktree");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("an item can set the branch and metadata from its mount effect", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, setsOnMount: true },
|
||||
async (screen) => {
|
||||
await screen.expectIsolation("worktree");
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).toBe("set-on-mount");
|
||||
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "ON-MOUNT" });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -24,11 +24,12 @@ for (const width of [1100, 390]) {
|
||||
await expectWholeCompletedCard(page);
|
||||
// Local state is checked during growth; the viewport remounts on the history handoff.
|
||||
const recovery = trackPluginChatRecovery(page);
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
// A chat with a cached cursor answers current and never fetches, so hold the subscribe itself.
|
||||
gate.holdNextClientRequest("agent.timeline.set_subscription.request");
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await gate.waitForHeldClientRequest();
|
||||
await recovery.expectCatalogWithoutRetransmission();
|
||||
gate.releaseHeldTimelineResponses();
|
||||
gate.releaseHeldClientRequest();
|
||||
await expectWholeCompletedCard(page);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
recovery.reset();
|
||||
|
||||
@@ -79,7 +79,9 @@ test.describe("Schedules", () => {
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("edit form hydrates the scheduled model selection", async ({ page }) => {
|
||||
test("hydrates schedule editing and opens a pristine creation form afterward", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Hydrate model ${Date.now()}`;
|
||||
@@ -93,29 +95,45 @@ test.describe("Schedules", () => {
|
||||
|
||||
await row.click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toContainText("Low");
|
||||
await expectSettled(thinkingTrigger);
|
||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
await test.step("edit form hydrates the saved model and controls", async () => {
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toContainText("Low");
|
||||
await expectSettled(thinkingTrigger);
|
||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("closing edit leaves the new schedule form pristine", async () => {
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("schedules-new").click();
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
|
||||
@@ -165,32 +183,4 @@ test.describe("Schedules", () => {
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
});
|
||||
|
||||
test("create opens pristine after closing an edit form", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Pristine create ${Date.now()}`;
|
||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
||||
|
||||
await page.goto(buildSchedulesRoute());
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("schedules-new").click();
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -300,15 +300,11 @@ test("clicking the already focused timeline acknowledges a new completion", asyn
|
||||
async function reconnectWithoutInteracting(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
agentId: string,
|
||||
) {
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
gate.holdTimelineResponses(agentId);
|
||||
gate.restore();
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await expectReconnectingToastGone(page);
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -325,7 +321,7 @@ test("reconnecting a focused chat preserves its unread completion until another
|
||||
);
|
||||
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId);
|
||||
await reconnectWithoutInteracting(page, gate);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await page.getByText("Unread across reconnect.", { exact: true }).click();
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
expectHostWorkspaceSettledActive,
|
||||
holdNextArchiveRequest,
|
||||
outlastUndoWindow,
|
||||
pressArchiveShortcut,
|
||||
pressUndoShortcut,
|
||||
resizeSidebarToMinimum,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
undoButton,
|
||||
@@ -84,7 +87,90 @@ test.describe("Workspace archive undo", () => {
|
||||
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
|
||||
rowKebab(page, worktree.workspaceId).boundingBox(),
|
||||
]);
|
||||
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
|
||||
if (!buttonBox || !kebabBox) throw new Error("Row actions are not laid out");
|
||||
expect(kebabBox.x - (buttonBox.x + buttonBox.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
test("the archived toast sits over the sidebar above its footer, and only Undo presses", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await seedWorktree(page, "archive-toast-sidebar");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
|
||||
const [toastBox, footerBox] = await Promise.all([
|
||||
page.getByTestId("workspace-archived-toast").boundingBox(),
|
||||
page.getByTestId("sidebar-footer").boundingBox(),
|
||||
]);
|
||||
if (!toastBox || !footerBox) throw new Error("Toast or footer is not laid out");
|
||||
expect(toastBox.x).toBeGreaterThanOrEqual(footerBox.x);
|
||||
expect(toastBox.x + toastBox.width).toBeLessThanOrEqual(footerBox.x + footerBox.width);
|
||||
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(footerBox.y);
|
||||
expect(footerBox.y - (toastBox.y + toastBox.height)).toBeLessThanOrEqual(16);
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveCSS("cursor", "auto");
|
||||
await expect(undoButton(page)).toHaveCSS("cursor", "pointer");
|
||||
|
||||
const [labelBox, shortcutBox] = await Promise.all([
|
||||
undoButton(page).getByText("Undo", { exact: true }).boundingBox(),
|
||||
undoButton(page).getByText(/Z$/).boundingBox(),
|
||||
]);
|
||||
if (!labelBox || !shortcutBox) throw new Error("Undo button is not laid out");
|
||||
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
|
||||
});
|
||||
|
||||
test("an archive that fails after the sidebar was replaced still shows its error", async ({
|
||||
page,
|
||||
}) => {
|
||||
const archive = await holdNextArchiveRequest(page);
|
||||
const worktree = await seedWorktree(page, "archive-late-failure");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await archive.waitForHeld();
|
||||
// Crossing the compact breakpoint swaps the sidebar, unmounting the toast's host.
|
||||
await page.setViewportSize({ width: 400, height: 720 });
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
|
||||
archive.releaseForUnknownWorkspace("workspace-the-daemon-never-had");
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(
|
||||
"Workspace not found: workspace-the-daemon-never-had",
|
||||
);
|
||||
});
|
||||
|
||||
test("the archived toast keeps Undo inside the card at the narrowest sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await seedWorktree(page, "archive-toast-narrow");
|
||||
await resizeSidebarToMinimum(page);
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
|
||||
const [toastBox, undoBox] = await Promise.all([
|
||||
page.getByTestId("workspace-archived-toast").boundingBox(),
|
||||
undoButton(page).boundingBox(),
|
||||
]);
|
||||
if (!toastBox || !undoBox) throw new Error("Toast is not laid out");
|
||||
expect(undoBox.x + undoBox.width).toBeLessThanOrEqual(toastBox.x + toastBox.width);
|
||||
expect(undoBox.y + undoBox.height).toBeLessThanOrEqual(toastBox.y + toastBox.height);
|
||||
});
|
||||
|
||||
test("archiving by shortcut with the sidebar collapsed shows the toast over the app", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await seedWorktree(page, "archive-toast-collapsed");
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
await page.keyboard.press("ControlOrMeta+b");
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).not.toBeInViewport();
|
||||
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await pressArchiveShortcut(page);
|
||||
|
||||
const toast = page.getByTestId("workspace-archived-toast");
|
||||
await expect(toast).toBeInViewport();
|
||||
const toastBox = await toast.boundingBox();
|
||||
if (!toastBox) throw new Error("Toast is not laid out");
|
||||
expect(toastBox.y).toBeLessThan(page.viewportSize()!.height / 2);
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Native SVG preview regression
|
||||
|
||||
Copy `../fixtures/native-preview.svg`, `../fixtures/native-preview-viewbox.svg`, and
|
||||
`../../../assets/images/icon.png` into a workspace on a test daemon. In the Android
|
||||
or iOS app, connect to that daemon, open the workspace's Files tab, and tap
|
||||
`native-preview.svg`. The SVG should show a yellow circle on a green rectangle.
|
||||
|
||||
With that preview open, run:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/agent-device/native-svg-preview.android.ad \
|
||||
--metro-port <port> --artifacts-dir .artifacts/svg-preview
|
||||
```
|
||||
|
||||
Run it again with `native-preview-viewbox.svg` open to cover SVG dimensions
|
||||
inferred from `viewBox`, then with the PNG open to check raster images. Close and
|
||||
reopen the SVG and rerun to cover cached file previews. Use English app language
|
||||
for the zoom button selectors. The scripts are discovered by `npm run test:e2e:mobile`; the iOS counterpart is
|
||||
`native-svg-preview.ios.ad`. They reopen the installed app with its current
|
||||
preview and leave the daemon connection unchanged.
|
||||
|
||||
Before the fix the SVG remains blank and the canvas wait fails. The PNG passes.
|
||||
The replay checks successful native dimension decoding and zoom/reset controls;
|
||||
visually verify the circle and rectangle as well.
|
||||
@@ -1,11 +0,0 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"image-file-preview-canvas\"" 10000
|
||||
wait "role=button label=\"Zoom in\"" 10000
|
||||
press "role=button label=\"Zoom in\""
|
||||
wait "role=button label=\"Reset view\" enabled=true" 10000
|
||||
press "role=button label=\"Reset view\""
|
||||
wait "role=button label=\"Reset view\" enabled=false" 10000
|
||||
close
|
||||
@@ -1,11 +0,0 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"image-file-preview-canvas\"" 10000
|
||||
wait "role=button label=\"Zoom in\"" 10000
|
||||
press "role=button label=\"Zoom in\""
|
||||
wait "role=button label=\"Reset view\" enabled=true" 10000
|
||||
press "role=button label=\"Reset view\""
|
||||
wait "role=button label=\"Reset view\" enabled=false" 10000
|
||||
close
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>
|
||||
|
Before Width: | Height: | Size: 164 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>
|
||||
|
Before Width: | Height: | Size: 188 B |
@@ -32,6 +32,8 @@ export interface RewindCompletionGate {
|
||||
clearTimelineStreamCount(): void;
|
||||
release(): void;
|
||||
timelineStreamCount(): number;
|
||||
/** Timeline reads this page sent since the last clear. */
|
||||
timelineFetchCount(): number;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -187,12 +189,20 @@ export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGa
|
||||
};
|
||||
}
|
||||
|
||||
// The rewind response and the timeline change it causes; the change removes the rewound row too.
|
||||
const REWIND_COMPLETION_MESSAGES = new Set([
|
||||
"agent.rewind.response",
|
||||
"agent.timeline.rewind",
|
||||
"agent.timeline.replacement",
|
||||
]);
|
||||
|
||||
export async function holdRewindCompletion(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<RewindCompletionGate> {
|
||||
let released = false;
|
||||
let timelineStreamCount = 0;
|
||||
let timelineFetchCount = 0;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
@@ -201,7 +211,16 @@ export async function holdRewindCompletion(
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_request" &&
|
||||
sessionMessage.agentId === agentId
|
||||
) {
|
||||
timelineFetchCount += 1;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
@@ -217,11 +236,11 @@ export async function holdRewindCompletion(
|
||||
}
|
||||
if (
|
||||
!released &&
|
||||
(sessionMessage?.type === "agent.rewind.response" ||
|
||||
sessionMessage?.type === "agent.timeline.replacement") &&
|
||||
sessionMessage &&
|
||||
REWIND_COMPLETION_MESSAGES.has(String(sessionMessage.type)) &&
|
||||
payload?.agentId === agentId
|
||||
) {
|
||||
// The replacement also signals completion and removes the rewound row/menu.
|
||||
// The rewind also signals completion and removes the rewound row/menu.
|
||||
// Hold it with the response so the pending-state assertion observes a pending rewind.
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
if (sessionMessage.type === "agent.rewind.response") resolveDelayedResponse?.();
|
||||
@@ -234,6 +253,7 @@ export async function holdRewindCompletion(
|
||||
return {
|
||||
clearTimelineStreamCount() {
|
||||
timelineStreamCount = 0;
|
||||
timelineFetchCount = 0;
|
||||
},
|
||||
release() {
|
||||
released = true;
|
||||
@@ -242,6 +262,7 @@ export async function holdRewindCompletion(
|
||||
}
|
||||
},
|
||||
timelineStreamCount: () => timelineStreamCount,
|
||||
timelineFetchCount: () => timelineFetchCount,
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -54,6 +54,116 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise
|
||||
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
|
||||
}
|
||||
|
||||
/** One painted frame of the composer's trailing controls: the slot and what sits beside it. */
|
||||
export interface TrailingActionFrame {
|
||||
atMs: number;
|
||||
/** Every trailing action on screen, as `label` or `label (disabled)`. */
|
||||
actions: string[];
|
||||
slotX: number | null;
|
||||
micX: number | null;
|
||||
ringX: number | null;
|
||||
ringWidth: number | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__trailingActionFrames?: TrailingActionFrame[];
|
||||
__trailingActionStop?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const TRAILING_ACTION_LABELS = [
|
||||
"Enable Voice mode",
|
||||
"Send message",
|
||||
"Queue message",
|
||||
"Send and interrupt",
|
||||
"Send and steer",
|
||||
"Interrupt agent",
|
||||
"Stop agent",
|
||||
];
|
||||
|
||||
/**
|
||||
* Records every painted frame of the visible composer's trailing controls. Takes one sample before
|
||||
* the first paint, so input that lands right after this resolves still has a frame to compare to.
|
||||
*/
|
||||
export async function recordTrailingActionFrames(page: Page): Promise<void> {
|
||||
await page.evaluate((labels) => {
|
||||
const frames: TrailingActionFrame[] = [];
|
||||
window.__trailingActionFrames = frames;
|
||||
const start = performance.now();
|
||||
const visibleRoot = () =>
|
||||
Array.from(document.querySelectorAll('[data-testid="message-input-root"]')).find(
|
||||
(root) => root.getClientRects().length > 0,
|
||||
);
|
||||
const sample = () => {
|
||||
const root = visibleRoot();
|
||||
const control = (selector: string) =>
|
||||
Array.from(root?.querySelectorAll(selector) ?? []).find(
|
||||
(node) => node.getClientRects().length > 0,
|
||||
);
|
||||
const actions = labels.flatMap((label) => {
|
||||
const node = control(`[aria-label="${label}"]`);
|
||||
if (!node) return [];
|
||||
return [node.getAttribute("aria-disabled") === "true" ? `${label} (disabled)` : label];
|
||||
});
|
||||
const slot = labels.map((label) => control(`[aria-label="${label}"]`)).find(Boolean);
|
||||
const mic = control('[aria-label="Start dictation"]');
|
||||
const ring = control('[aria-label^="Context window"]');
|
||||
frames.push({
|
||||
atMs: Math.round(performance.now() - start),
|
||||
actions,
|
||||
slotX: slot ? slot.getBoundingClientRect().x : null,
|
||||
micX: mic ? mic.getBoundingClientRect().x : null,
|
||||
ringX: ring ? ring.getBoundingClientRect().x : null,
|
||||
ringWidth: ring ? ring.getBoundingClientRect().width : null,
|
||||
});
|
||||
};
|
||||
sample();
|
||||
let running = true;
|
||||
const tick = () => {
|
||||
if (!running) return;
|
||||
sample();
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
window.__trailingActionStop = () => {
|
||||
running = false;
|
||||
};
|
||||
}, TRAILING_ACTION_LABELS);
|
||||
}
|
||||
|
||||
export async function readTrailingActionFrames(page: Page): Promise<TrailingActionFrame[]> {
|
||||
return page.evaluate(() => {
|
||||
window.__trailingActionStop?.();
|
||||
return window.__trailingActionFrames ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every frame of the span shows exactly one trailing action, in the one slot, moving straight from
|
||||
* `from` to `to`, with the mic and context ring where they started.
|
||||
*/
|
||||
export function expectTrailingActionSwap(
|
||||
frames: TrailingActionFrame[],
|
||||
expected: { from: string; to: string },
|
||||
): void {
|
||||
expect(frames.length).toBeGreaterThan(1);
|
||||
const [first] = frames;
|
||||
const wrongFrames = frames.filter(
|
||||
(frame) =>
|
||||
frame.actions.length !== 1 ||
|
||||
frame.slotX !== first.slotX ||
|
||||
frame.micX !== first.micX ||
|
||||
frame.ringX !== first.ringX ||
|
||||
frame.ringWidth !== first.ringWidth,
|
||||
);
|
||||
expect(wrongFrames, `frames that moved or showed the wrong action`).toEqual([]);
|
||||
const sequence = frames
|
||||
.map((frame) => frame.actions[0])
|
||||
.filter((action, index, all) => action !== all[index - 1]);
|
||||
expect(sequence).toEqual([expected.from, expected.to]);
|
||||
}
|
||||
|
||||
/** What the folded model control's labels show, read from the DOM. */
|
||||
export interface ModelTriggerLabelState {
|
||||
thinkingShown: boolean;
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
|
||||
export interface LinkedHost {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
@@ -31,31 +29,17 @@ export async function openPairingLink(page: Page, link: string): Promise<void> {
|
||||
await page.goto(link);
|
||||
}
|
||||
|
||||
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
|
||||
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
|
||||
await page.goto("/");
|
||||
const nowIso = new Date().toISOString();
|
||||
const connectionId = `relay:${host.relayEndpoint}`;
|
||||
const savedHost = {
|
||||
serverId: host.serverId,
|
||||
label: host.serverId,
|
||||
connections: [
|
||||
{
|
||||
id: connectionId,
|
||||
type: "relay",
|
||||
relayEndpoint: host.relayEndpoint,
|
||||
useTls: false,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: connectionId,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
|
||||
key: EXTRA_HOSTS_KEY,
|
||||
hosts: [savedHost],
|
||||
});
|
||||
/** Reopens a link from a fresh document while retaining hosts saved through the UI. */
|
||||
export async function reopenPairingLinkWithSavedHosts(page: Page, link: string): Promise<void> {
|
||||
for (const target of ["/", link]) {
|
||||
// The fixture otherwise replaces the registry on every document load.
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
|
||||
if (!nonce) throw new Error("Expected the e2e seed nonce before reopening a pairing link.");
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
});
|
||||
await page.goto(target);
|
||||
}
|
||||
}
|
||||
|
||||
export function hostConfirmation(page: Page): Locator {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
function isReady(value: { ready(): boolean }): boolean {
|
||||
return value.ready();
|
||||
}
|
||||
|
||||
/** Replay a queued native load payload after a real file refresh, through the image's load event. */
|
||||
export async function holdNextPreviewLoad(pane: Locator, previousUrl: string) {
|
||||
const pending = await pane.evaluateHandle((container, oldUrl: string) => {
|
||||
let image: HTMLImageElement | null = null;
|
||||
let holding = true;
|
||||
const hold = (event: Event) => {
|
||||
if (holding) event.stopImmediatePropagation();
|
||||
};
|
||||
const observer = new MutationObserver(() => {
|
||||
if (image) return;
|
||||
const candidates = container.querySelectorAll<HTMLImageElement>(
|
||||
'[data-testid="image-file-preview-image"] img',
|
||||
);
|
||||
const next = Array.from(candidates).find((candidate) => candidate.src !== oldUrl);
|
||||
if (!next) return;
|
||||
image = next;
|
||||
image.addEventListener("load", hold, true);
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(container, { subtree: true, childList: true, attributes: true });
|
||||
return {
|
||||
ready: () => image !== null && image.complete && image.naturalWidth > 0,
|
||||
deliver(source: { url: string; width: number; height: number }) {
|
||||
if (!image) throw new Error("No pending preview load");
|
||||
// Expo's web adapter reads these fields to produce the same public onLoad
|
||||
// payload that native queues before React dispatches to the current listener.
|
||||
Object.defineProperties(image, {
|
||||
currentSrc: { configurable: true, value: source.url },
|
||||
naturalWidth: { configurable: true, value: source.width },
|
||||
naturalHeight: { configurable: true, value: source.height },
|
||||
});
|
||||
try {
|
||||
holding = false;
|
||||
image.dispatchEvent(new Event("load"));
|
||||
holding = true;
|
||||
} finally {
|
||||
Reflect.deleteProperty(image, "currentSrc");
|
||||
Reflect.deleteProperty(image, "naturalWidth");
|
||||
Reflect.deleteProperty(image, "naturalHeight");
|
||||
}
|
||||
},
|
||||
release() {
|
||||
holding = false;
|
||||
observer.disconnect();
|
||||
image?.removeEventListener("load", hold, true);
|
||||
image?.dispatchEvent(new Event("load"));
|
||||
},
|
||||
};
|
||||
}, previousUrl);
|
||||
return {
|
||||
async wait() {
|
||||
await expect.poll(() => pending.evaluate(isReady)).toBe(true);
|
||||
},
|
||||
async deliver(source: { url: string; width: number; height: number }) {
|
||||
await pending.evaluate((value, payload) => value.deliver(payload), source);
|
||||
},
|
||||
async release() {
|
||||
await pending.evaluate((value) => value.release());
|
||||
await pending.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
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, pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
|
||||
const PLUGIN_ID = "composer-controls-example";
|
||||
const NARROW = { width: 500, height: 800 };
|
||||
|
||||
/** Runs `run` in an agent's composer with the composer-controls example installed. */
|
||||
export async function withComposerControlsExample(
|
||||
page: Page,
|
||||
run: (composer: {
|
||||
narrowTheWindow(): Promise<void>;
|
||||
openAgentFeatures(): Promise<void>;
|
||||
expectFeatureRow(name: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "composer-items-",
|
||||
title: "Composer items",
|
||||
});
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Composer items agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("composer-controls");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await gotoAppShell(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await run({
|
||||
narrowTheWindow: () =>
|
||||
test.step("narrow the window until the composer folds its controls away", () =>
|
||||
page.setViewportSize(NARROW)),
|
||||
openAgentFeatures: () =>
|
||||
test.step("open the agent features sheet", async () => {
|
||||
await page.getByRole("button", { name: "Open agent features", exact: true }).click();
|
||||
}),
|
||||
expectFeatureRow: (name) =>
|
||||
test.step(`the sheet has a row for "${name}"`, () =>
|
||||
expect(page.getByRole("button", { name, exact: true })).toBeVisible()),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
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 { attachComposerTestImage, openImageLightbox } from "./composer";
|
||||
import {
|
||||
contextWindowDetails,
|
||||
pressContextWindowMeter,
|
||||
seedAgentWithContextWindow,
|
||||
} from "./context-window";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
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,
|
||||
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>>;
|
||||
|
||||
function prompt(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." });
|
||||
}
|
||||
|
||||
async function createMockAgent(workspace: Workspace, title: string) {
|
||||
return workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, title: string) {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function withComposerToolsExample(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openComposerToolsInExplorer(): Promise<void>;
|
||||
typeInComposer(text: string, cursorAfter: number): Promise<void>;
|
||||
insertChecklistFromPanel(): Promise<void>;
|
||||
expectComposerText(text: string): Promise<void>;
|
||||
markForReviewFromCommandCenter(): Promise<void>;
|
||||
focusANewAgentTab(): Promise<void>;
|
||||
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" });
|
||||
const agent = await createMockAgent(workspace, "Composer tools agent");
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("composer-controls");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await run({
|
||||
openComposerToolsInExplorer: () =>
|
||||
test.step("open the Composer tools panel in the Explorer", 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: "Composer tools", exact: true })
|
||||
.click();
|
||||
await expect(page.getByText("Focused agent: mock", { exact: true })).toBeVisible();
|
||||
}),
|
||||
typeInComposer: (text, cursorAfter) =>
|
||||
test.step(`type "${text}" and put the cursor after ${cursorAfter} characters`, async () => {
|
||||
const input = prompt(page);
|
||||
await input.fill(text);
|
||||
await input.press("Home");
|
||||
for (let index = 0; index < cursorAfter; index += 1) await input.press("ArrowRight");
|
||||
}),
|
||||
insertChecklistFromPanel: () =>
|
||||
test.step("press the panel's button", () =>
|
||||
page
|
||||
.getByRole("button", { name: "Insert checklist into the focused agent", exact: true })
|
||||
.click()),
|
||||
expectComposerText: (text) =>
|
||||
test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
|
||||
markForReviewFromCommandCenter: () =>
|
||||
test.step("run the Command Center item", () =>
|
||||
runCommand(page, "Composer controls: mark for review")),
|
||||
focusANewAgentTab: () =>
|
||||
test.step("focus a new agent tab, which is a draft", () => createAgentTabFromMenu(page)),
|
||||
focusTheAgentTab: () =>
|
||||
test.step("focus the running agent's tab again", () =>
|
||||
page
|
||||
.getByTestId(`workspace-tab-agent_${agent.id}`)
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 13 } })),
|
||||
expectPanelFocusedAgent: (focused) =>
|
||||
test.step(`the panel ${focused ? "names" : "has no"} focused agent`, async () => {
|
||||
await expect(page.getByText("Focused agent: mock", { exact: true })).toHaveCount(
|
||||
focused ? 1 : 0,
|
||||
);
|
||||
await expect(page.getByText("No agent is focused", { exact: true })).toHaveCount(
|
||||
focused ? 0 : 1,
|
||||
);
|
||||
}),
|
||||
expectPanelShowsReview: (needsReview) =>
|
||||
test.step(`the panel's snapshot says needs review: ${needsReview}`, () =>
|
||||
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");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin that names an agent by id from a Command Center item while the user is in another
|
||||
* workspace: the target's workspace is not on screen, so its composer is not mounted.
|
||||
*/
|
||||
export async function withPluginWritingToAnotherWorkspace(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openTargetAgent(): Promise<void>;
|
||||
goToAnotherWorkspace(): Promise<void>;
|
||||
insertFromCommandCenter(): Promise<void>;
|
||||
returnToTargetWorkspace(): Promise<void>;
|
||||
expectComposerText(text: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-ref-", title: "Composer ref" });
|
||||
const elsewhere = await seedWorkspace({ repoPrefix: "composer-away-", title: "Composer away" });
|
||||
const target = await createMockAgent(workspace, "Target agent");
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-ref-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "composer-ref", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`export default function contribute(client) {
|
||||
client.addCommandCenterItem({
|
||||
id: "note",
|
||||
title: "Composer ref: add a note",
|
||||
icon: "Flag",
|
||||
context: "global",
|
||||
onSelect({ composers }) {
|
||||
for (const composer of composers({ agentId: ${JSON.stringify(target.id)} })) composer.insertText("A note from a plugin");
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`);
|
||||
const workspaceRow = (id: string) =>
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await run({
|
||||
openTargetAgent: () =>
|
||||
test.step("open the agent the plugin will write to", 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, so the agent's composer is not on screen", async () => {
|
||||
await workspaceRow(elsewhere.workspaceId).click();
|
||||
// The app keeps the workspace it left mounted, out of sight.
|
||||
await expect(targetTab().first()).toBeHidden();
|
||||
}),
|
||||
insertFromCommandCenter: () =>
|
||||
test.step("run the plugin's Command Center item", () =>
|
||||
runCommand(page, "Composer ref: add a note")),
|
||||
returnToTargetWorkspace: () =>
|
||||
test.step("return to the agent's workspace without reloading the app", async () => {
|
||||
await workspaceRow(workspace.workspaceId).click();
|
||||
await expect(targetTab().first()).toBeVisible({ timeout: 30_000 });
|
||||
}),
|
||||
expectComposerText: (text) =>
|
||||
test.step("its composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("composer-ref");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await elsewhere.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectWorkspaceIsolationSelected,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { waitForSidebarHydration } from "./workspace-ui";
|
||||
|
||||
/** Presses Create, typing the prompt first unless the test wants an empty one. */
|
||||
async function submitCreate(page: Page, prompt: string | undefined) {
|
||||
if (prompt !== "") return submitNewWorkspacePrompt(page, prompt);
|
||||
await page.getByTestId("message-input-root").getByRole("button", { name: "Create" }).click();
|
||||
}
|
||||
|
||||
/** An item that sets the branch and metadata from its mount effect, before the user touches anything. */
|
||||
async function replaceWithMountEffectItem(directory: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { useEffect } from "react";
|
||||
import { useNewWorkspace } from "@getpaseo/plugin/client";
|
||||
import { Action } from "@getpaseo/plugin/client/ui";
|
||||
function OnMount() {
|
||||
const newWorkspace = useNewWorkspace();
|
||||
useEffect(() => {
|
||||
void newWorkspace.setIsolation("worktree");
|
||||
void newWorkspace.setBranch("set-on-mount");
|
||||
void newWorkspace.setMetadata({ "new-workspace-issues.issue": "ON-MOUNT" });
|
||||
}, []);
|
||||
return <Action title="Mount" icon="Flag" onPress={() => {}} />;
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addNewWorkspaceItem({ id: "on-mount", Component: OnMount });
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An item whose search depends on the project: it answers with the project's name, and never
|
||||
* answers for the project named "other", so a list that still shows the first project's answer
|
||||
* there is stale.
|
||||
*/
|
||||
async function replaceWithProjectSearchItem(directory: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { useNewWorkspace } from "@getpaseo/plugin/client";
|
||||
import { Combobox } from "@getpaseo/plugin/client/ui";
|
||||
function ProjectIssues() {
|
||||
const { project } = useNewWorkspace();
|
||||
const name = project?.name ?? "";
|
||||
return (
|
||||
<Combobox
|
||||
title="Issue"
|
||||
icon="CircleDot"
|
||||
value={null}
|
||||
search={() =>
|
||||
name.includes("other")
|
||||
? new Promise(() => {})
|
||||
: Promise.resolve([{ id: name, label: "Issue of " + name }])
|
||||
}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addNewWorkspaceItem({ id: "project-issues", Component: ProjectIssues });
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
type Client = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
/** The daemon fills in the git runtime after it creates the workspace, so wait for the branch. */
|
||||
async function readCreatedWorkspace(client: Client, workspaceId: string) {
|
||||
const deadline = Date.now() + 30_000;
|
||||
for (;;) {
|
||||
const { entries } = await client.fetchWorkspaces();
|
||||
const workspace = entries.find((entry) => entry.id === workspaceId);
|
||||
const branch = workspace?.gitRuntime?.currentBranch ?? null;
|
||||
if (workspace && branch) return { branch, metadata: workspace.metadata };
|
||||
if (Date.now() > deadline) throw new Error(`Workspace ${workspaceId} never reported a branch`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
const PLUGIN_ID = "new-workspace-issues-example";
|
||||
const COMPACT = { width: 500, height: 800 };
|
||||
|
||||
/** Runs `run` on the New workspace screen with the new-workspace-issues example installed. */
|
||||
export async function withNewWorkspaceIssuesExample(
|
||||
page: Page,
|
||||
options: {
|
||||
compact: boolean;
|
||||
detachedHead?: boolean;
|
||||
setsOnMount?: boolean;
|
||||
/** Installs an item whose Issue list depends on the project instead of the example's. */
|
||||
searchesByProject?: boolean;
|
||||
/** A second project the screen can switch to. */
|
||||
otherProject?: boolean;
|
||||
},
|
||||
run: (screen: {
|
||||
chooseWorktree(): Promise<void>;
|
||||
chooseIssue(label: string): Promise<void>;
|
||||
createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>;
|
||||
switchToOtherProject(): Promise<void>;
|
||||
openIssueList(): Promise<void>;
|
||||
closeIssueList(): Promise<void>;
|
||||
/** The Issue list shows `text`, or does not show it when `shown` is false. */
|
||||
expectIssueListText(text: string, shown: boolean): Promise<void>;
|
||||
/** The name the first project's items read as `project.name`. */
|
||||
firstProjectName: string;
|
||||
expectIsolation(isolation: "local" | "worktree"): Promise<void>;
|
||||
/** Removes the plugin, leaves the screen, and opens New workspace for the project again. */
|
||||
reopenWithoutPlugin(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("new-workspace-issues");
|
||||
if (options.searchesByProject) await replaceWithProjectSearchItem(example.directory);
|
||||
if (options.setsOnMount) await replaceWithMountEffectItem(example.directory);
|
||||
const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] });
|
||||
const otherRepo = options.otherProject
|
||||
? await createTempGitRepo("new-workspace-other-", { branches: ["main"] })
|
||||
: null;
|
||||
let createdDirectory: string | null = null;
|
||||
try {
|
||||
// A detached HEAD leaves the screen with no default base to branch off.
|
||||
if (options.detachedHead) execFileSync("git", ["checkout", "--detach"], { cwd: repo.path });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
const other = otherRepo ? await openProjectViaDaemon(client, otherRepo.path) : null;
|
||||
let target = project;
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
// The sidebar rows used to reach the screen exist only at desktop width.
|
||||
if (options.compact) await page.setViewportSize(COMPACT);
|
||||
await run({
|
||||
chooseWorktree: () =>
|
||||
test.step("choose a new worktree", async () => {
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
}),
|
||||
chooseIssue: (label) =>
|
||||
test.step(`choose the issue "${label}"`, async () => {
|
||||
await page.getByRole("button", { name: "Issue", exact: true }).click();
|
||||
await page.getByText(label, { exact: true }).click();
|
||||
}),
|
||||
firstProjectName: project.projectDisplayName,
|
||||
openIssueList: () =>
|
||||
test.step("open the Issue list", async () => {
|
||||
await page.getByRole("button", { name: "Issue", exact: true }).click();
|
||||
}),
|
||||
closeIssueList: () => test.step("close the Issue list", () => page.keyboard.press("Escape")),
|
||||
expectIssueListText: (text, shown) =>
|
||||
test.step(`the Issue list ${shown ? "shows" : "does not show"} "${text}"`, async () => {
|
||||
const list = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(list.getByText(text, { exact: true })).toHaveCount(shown ? 1 : 0);
|
||||
}),
|
||||
switchToOtherProject: () =>
|
||||
test.step("switch to the other project", async () => {
|
||||
if (!other) throw new Error("Open the example with otherProject to switch projects");
|
||||
await page.getByRole("button", { name: "Workspace project" }).click();
|
||||
await page
|
||||
.getByTestId("combobox-desktop-container")
|
||||
.last()
|
||||
.getByText(other.projectDisplayName, { exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Workspace project" })).toContainText(
|
||||
other.projectDisplayName,
|
||||
);
|
||||
target = other;
|
||||
}),
|
||||
expectIsolation: (isolation) =>
|
||||
test.step(`the screen is on ${isolation}`, () =>
|
||||
expectWorkspaceIsolationSelected(page, isolation)),
|
||||
reopenWithoutPlugin: () =>
|
||||
test.step("reopen the screen without the plugin", async () => {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
// Leaving the screen drops its in-memory choice, so only the saved preference is left.
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await expect(page.getByRole("button", { name: "Mount", exact: true })).toHaveCount(0);
|
||||
}),
|
||||
createWorkspace: (createOptions) =>
|
||||
test.step("create the workspace and read what it carries", async () => {
|
||||
await submitCreate(page, createOptions?.prompt);
|
||||
const created = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: target.workspaceId,
|
||||
projectDisplayName: target.projectDisplayName,
|
||||
// The sidebar is closed at compact width.
|
||||
assertSidebarRow: !options.compact,
|
||||
});
|
||||
createdDirectory = created.workspaceDirectory;
|
||||
return readCreatedWorkspace(client, created.workspaceId);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (createdDirectory) {
|
||||
await archiveWorkspaceFromDaemon(client, createdDirectory).catch(() => undefined);
|
||||
}
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await repo.cleanup();
|
||||
await otherRepo?.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,13 @@ export function trackPluginChatRecovery(page: Page) {
|
||||
async expectRecoveredWithoutRetransmission() {
|
||||
await this.expectCatalogWithoutRetransmission();
|
||||
await expect
|
||||
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
|
||||
.poll(() =>
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "fetch_agent_timeline_response" ||
|
||||
message.type === "agent.timeline.set_subscription.response",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -110,6 +110,18 @@ function sidebarViews(
|
||||
label: { name: "Unlabelled", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
// Wait for the named label folder alongside the built-in unlabelled bucket.
|
||||
// Counts alone can match stale Inbox/Later rows from the preceding move.
|
||||
await expect(
|
||||
slot.getByRole("button", { name: "Toggle Unlabelled", exact: true }),
|
||||
).toHaveCount(2);
|
||||
// Observe both label rows before removing Later so a stale single row
|
||||
// cannot satisfy the final readiness check.
|
||||
const moveWorkspace = slot.getByRole("button", {
|
||||
name: `Move workspace ${workspaceId}`,
|
||||
exact: true,
|
||||
});
|
||||
await expect(moveWorkspace).toHaveCount(2);
|
||||
await client.setWorkspaceLabel({
|
||||
workspaceId,
|
||||
label: { name: "Later", color: "red" },
|
||||
@@ -122,9 +134,9 @@ function sidebarViews(
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Unlabelled"]);
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
// The daemon reply does not guarantee the browser has applied the removal.
|
||||
await expect(moveWorkspace).toHaveCount(1);
|
||||
await moveWorkspace.click();
|
||||
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
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, expectReconnectingToastVisible } from "./workspace-ui";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
setTabVisibility,
|
||||
} from "./workspace-ui";
|
||||
|
||||
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
|
||||
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;
|
||||
after: number;
|
||||
tail: number;
|
||||
subscriptions: number;
|
||||
fetches: number;
|
||||
}
|
||||
|
||||
export interface BackgroundTimelineTurns {
|
||||
@@ -42,17 +60,140 @@ export function rememberTimelineRequestCounts(
|
||||
agentId,
|
||||
after: gate.getTimelineRequestCount("after", agentId),
|
||||
tail: gate.getTimelineRequestCount("tail", agentId),
|
||||
subscriptions: gate.getClientRequestCount(SUBSCRIPTION_REQUEST),
|
||||
fetches: gate.getClientRequestCount(TIMELINE_REQUEST),
|
||||
};
|
||||
}
|
||||
|
||||
export function expectOneResumeCheckWithoutTail(
|
||||
export function expectResumeAnsweredWithoutFetch(
|
||||
gate: DaemonWebSocketGate,
|
||||
before: TimelineRequestCounts,
|
||||
): void {
|
||||
expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(1);
|
||||
expect(gate.getTimelineRequestCount("after", before.agentId) - before.after).toBe(0);
|
||||
expect(gate.getTimelineRequestCount("tail", before.agentId) - before.tail).toBe(0);
|
||||
}
|
||||
|
||||
/** Resolves once the daemon has told the app where this chat stands, whichever way it asked. */
|
||||
export async function waitForResumeAnswer(
|
||||
gate: DaemonWebSocketGate,
|
||||
before: TimelineRequestCounts,
|
||||
): Promise<void> {
|
||||
await Promise.race([
|
||||
gate.waitForServerMessage(SUBSCRIPTION_RESPONSE, before.subscriptions + 1),
|
||||
gate.waitForServerMessage(TIMELINE_RESPONSE, before.fetches + 1),
|
||||
]);
|
||||
}
|
||||
|
||||
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>> {
|
||||
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(recordUpdatingToastTiming);
|
||||
await page.reload();
|
||||
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> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function hideTabAndReturn(page: Page): Promise<void> {
|
||||
await setTabVisibility(page, "hidden");
|
||||
await waitForNextPaint(page);
|
||||
await setTabVisibility(page, "visible");
|
||||
}
|
||||
|
||||
export function expectResumeOverflowFallsBackToOneTail(
|
||||
gate: DaemonWebSocketGate,
|
||||
before: TimelineRequestCounts,
|
||||
@@ -69,15 +210,34 @@ export async function disconnectViewedTimeline(
|
||||
await expectReconnectingToastVisible(page);
|
||||
}
|
||||
|
||||
export async function restoreViewedTimelineWithHeldResponse(
|
||||
export async function restoreViewedTimelineHoldingCatchUp(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
): Promise<void> {
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
gate.holdNextServerMessage(TIMELINE_RESPONSE);
|
||||
gate.restore();
|
||||
await gate.waitForHeldServerMessage();
|
||||
await expectReconnectingToastGone(page);
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function releaseHeldCatchUp(page: Page, gate: DaemonWebSocketGate): Promise<void> {
|
||||
gate.releaseHeldServerMessage();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function restoreViewedTimelineWithHeldResponse(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
): Promise<void> {
|
||||
await restoreViewedTimelineHoldingCatchUp(page, gate);
|
||||
await releaseHeldCatchUp(page, gate);
|
||||
}
|
||||
|
||||
export async function restoreUnchangedViewedTimeline(
|
||||
page: Page,
|
||||
gate: DaemonWebSocketGate,
|
||||
): Promise<void> {
|
||||
gate.restore();
|
||||
await expectReconnectingToastGone(page);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "../fixtures";
|
||||
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
|
||||
|
||||
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
|
||||
const ARCHIVE_SHORTCUT =
|
||||
process.platform === "darwin" ? "Meta+Shift+Backspace" : "Control+Shift+Backspace";
|
||||
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
|
||||
const UNDO_WINDOW_MS = 6000;
|
||||
|
||||
@@ -46,6 +49,25 @@ export async function archiveFromRowButton(page: Page, workspaceId: string): Pro
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Drags the sidebar's edge past its minimum, leaving it as narrow as it goes. */
|
||||
export async function resizeSidebarToMinimum(page: Page): Promise<void> {
|
||||
const handle = await page.getByTestId("left-sidebar-resize-handle").boundingBox();
|
||||
if (!handle) throw new Error("Sidebar resize handle is not laid out");
|
||||
const y = handle.y + handle.height / 2;
|
||||
await page.mouse.move(handle.x + handle.width / 2, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handle.x - 30, y, { steps: 8 });
|
||||
await page.mouse.move(10, y, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => (await page.getByTestId("sidebar-footer").boundingBox())?.width)
|
||||
.toBeLessThanOrEqual(200);
|
||||
}
|
||||
|
||||
export async function pressArchiveShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press(ARCHIVE_SHORTCUT);
|
||||
}
|
||||
|
||||
export async function pressUndoShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press(UNDO_SHORTCUT);
|
||||
}
|
||||
@@ -76,6 +98,51 @@ export async function expectDirectoryPresence(directory: string, present: boolea
|
||||
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
|
||||
}
|
||||
|
||||
export interface HeldArchiveRequest {
|
||||
waitForHeld(): Promise<void>;
|
||||
/** Sends the held request to the daemon naming a workspace it has no record of, so it refuses. */
|
||||
releaseForUnknownWorkspace(workspaceId: string): void;
|
||||
}
|
||||
|
||||
/** Keeps the next archive request from reaching the daemon until the test releases it. */
|
||||
export async function holdNextArchiveRequest(page: Page): Promise<HeldArchiveRequest> {
|
||||
let held: { request: SessionRequest; forward: (raw: string) => void } | null = null;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const request = parseSessionRequest(message);
|
||||
if (!held && request?.type === "archive_workspace_request") {
|
||||
held = { request, forward: (raw) => server.send(raw) };
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => browser.send(message));
|
||||
});
|
||||
return {
|
||||
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
|
||||
releaseForUnknownWorkspace: (workspaceId) => {
|
||||
if (!held) throw new Error("No archive request is held");
|
||||
held.forward(JSON.stringify({ type: "session", message: { ...held.request, workspaceId } }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionRequest {
|
||||
type: string;
|
||||
requestId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function parseSessionRequest(message: string | Buffer): SessionRequest | null {
|
||||
try {
|
||||
const envelope = JSON.parse(typeof message === "string" ? message : message.toString("utf8"));
|
||||
return envelope?.type === "session" ? envelope.message : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GatedTeardown {
|
||||
/** The `paseo.json` config whose worktree teardown parks until released. */
|
||||
paseoConfig: Record<string, unknown>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -91,6 +91,20 @@ export async function expectWorkspaceHeader(
|
||||
});
|
||||
}
|
||||
|
||||
export async function setTabVisibility(
|
||||
page: Page,
|
||||
visibility: DocumentVisibilityState,
|
||||
): Promise<void> {
|
||||
await page.evaluate((state) => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => state === "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, visibility);
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
|
||||
@@ -18,6 +18,8 @@ final class WindowLayoutView: ExpoView {
|
||||
private var barGuide: UILayoutGuide?
|
||||
private var barEdge = "none"
|
||||
private var bottomBarGuide: UILayoutGuide?
|
||||
private var topBarGuide: UILayoutGuide?
|
||||
private var occlusions: [CGRect] = []
|
||||
private var measuredBounds = CGRect.zero
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
@@ -38,6 +40,7 @@ final class WindowLayoutView: ExpoView {
|
||||
super.didMoveToWindow()
|
||||
previousPayload = nil
|
||||
bottomBarGuide = nil
|
||||
topBarGuide = nil
|
||||
setNeedsUpdateConstraints()
|
||||
scheduleUpdate()
|
||||
}
|
||||
@@ -62,6 +65,8 @@ final class WindowLayoutView: ExpoView {
|
||||
if #available(iOS 27.1, *) {
|
||||
// UIKit tracks these reads and invalidates constraints when regions change.
|
||||
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
|
||||
// Frames already include the system's interactive margins.
|
||||
occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil }
|
||||
let edge: String
|
||||
switch traitCollection.verticalBarEdge {
|
||||
case .leading: edge = "leading"
|
||||
@@ -73,6 +78,11 @@ final class WindowLayoutView: ExpoView {
|
||||
// screen's corners rather than the rectangular safe area.
|
||||
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
|
||||
}
|
||||
if topBarGuide == nil {
|
||||
// Where a top bar rests. Beside a partial status region it can start above the
|
||||
// rectangular safe area; content still keeps out of the occlusions.
|
||||
topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44))
|
||||
}
|
||||
if edge != barEdge {
|
||||
barEdge = edge
|
||||
barGuide = edge == "none" ? nil : layoutGuide(
|
||||
@@ -106,11 +116,17 @@ final class WindowLayoutView: ExpoView {
|
||||
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
|
||||
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
|
||||
}
|
||||
var topBar: Any = NSNull()
|
||||
if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 {
|
||||
topBar = rect(frame)
|
||||
}
|
||||
let payload: [String: Any] = [
|
||||
"bounds": rect(bounds),
|
||||
"verticalBarEdge": barEdge,
|
||||
"verticalBar": barPayload,
|
||||
"bottomClearance": bottomClearance,
|
||||
"topBar": topBar,
|
||||
"occlusions": occlusions.map(rect),
|
||||
"divisions": divisions.map(rect)
|
||||
]
|
||||
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.4",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function normalizeAgentCommandsCwd(cwd: string): string {
|
||||
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
|
||||
"thinking",
|
||||
draftConfig.thinkingOptionId ?? null,
|
||||
"features",
|
||||
draftConfig.featureValues ?? null,
|
||||
draftConfig.providerOptions ?? null,
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||
import { isControlActive } from "./option-controls";
|
||||
import {
|
||||
draftControlsRequest,
|
||||
openDraftOptions,
|
||||
ownDraftOptions,
|
||||
viewDraftOptions,
|
||||
} from "./draft-options-model";
|
||||
|
||||
const SPEED: AgentOptionControl = {
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Standard" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
};
|
||||
const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" };
|
||||
|
||||
function answer(
|
||||
provider: string,
|
||||
requestKey: string,
|
||||
options: Record<string, unknown>,
|
||||
controls = [SPEED, WEB_SEARCH],
|
||||
) {
|
||||
return { provider, requestKey, controls, options };
|
||||
}
|
||||
|
||||
describe("draft options model", () => {
|
||||
it("draws a legacy Fast profile as the Fast tier the daemon resolved", () => {
|
||||
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "fast" } });
|
||||
model.applyResolved(answer("codex", "k1", { service_tier: "priority" }));
|
||||
|
||||
const view = viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" });
|
||||
|
||||
expect(view?.options).toEqual({ service_tier: "priority" });
|
||||
expect(view && isControlActive(SPEED, view.options)).toBe(true);
|
||||
// The daemon still gets the stored value to resolve, and so does create.
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
|
||||
});
|
||||
|
||||
it("applying a profile from another provider keeps its options through the provider change", () => {
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(answer("claude", "k1", {}));
|
||||
|
||||
model.applyProfile({
|
||||
provider: "codex",
|
||||
options: { sandbox_mode: "read-only", service_tier: "fast" },
|
||||
});
|
||||
// The form follows the profile to its provider.
|
||||
model.observeProvider("codex");
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({
|
||||
sandbox_mode: "read-only",
|
||||
service_tier: "fast",
|
||||
});
|
||||
expect(model.getState().resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("choosing another provider by hand starts the options over", () => {
|
||||
const model = openDraftOptions({ provider: "codex", seed: { sandbox_mode: "read-only" } });
|
||||
model.setOption("web_search", true);
|
||||
|
||||
model.observeProvider("claude");
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toBeUndefined();
|
||||
expect(model.getState().resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("never pairs a manually chosen provider with the previous provider's options", () => {
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(
|
||||
answer("claude", "k1", { fast_mode: true }, [
|
||||
{ type: "toggle", key: "fast_mode", label: "Fast" },
|
||||
]),
|
||||
);
|
||||
model.setOption("fast_mode", true);
|
||||
const form = { provider: "claude", cwd: "/repo" };
|
||||
expect(draftControlsRequest(model.getState(), form)).toMatchObject({
|
||||
provider: "claude",
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
|
||||
// The form shows OpenCode on its next render; the model has not observed it yet.
|
||||
expect(draftControlsRequest(model.getState(), { ...form, provider: "opencode" })).toBeNull();
|
||||
|
||||
model.observeProvider("opencode");
|
||||
const request = draftControlsRequest(model.getState(), { ...form, provider: "opencode" });
|
||||
expect(request).toEqual({ provider: "opencode", cwd: "/repo" });
|
||||
});
|
||||
|
||||
it("stops sending a toggle once the model in use draws no control for it", () => {
|
||||
const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
|
||||
const model = openDraftOptions({ provider: "claude", seed: { sandbox_mode: "read-only" } });
|
||||
model.applyResolved(answer("claude", "opus", { fast_mode: false }, [FAST_MODE]));
|
||||
model.setOption("fast_mode", true);
|
||||
expect(ownDraftOptions(model.getState())).toEqual({
|
||||
sandbox_mode: "read-only",
|
||||
fast_mode: true,
|
||||
});
|
||||
|
||||
// The form moves to a model without Fast; its answer lists no such control.
|
||||
model.applyResolved(answer("claude", "haiku", {}, []));
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ sandbox_mode: "read-only" });
|
||||
|
||||
// Back on a model that draws the control, the toggle applies again. The daemon's answer
|
||||
// for the request without it said Fast was off; the request that carries it follows.
|
||||
model.applyResolved(answer("claude", "opus-again", { fast_mode: false }, [FAST_MODE]));
|
||||
expect(ownDraftOptions(model.getState())).toEqual({
|
||||
sandbox_mode: "read-only",
|
||||
fast_mode: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("always sends an option no control has drawn", () => {
|
||||
const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(answer("claude", "k1", {}, [FAST_MODE]));
|
||||
|
||||
model.setOption("token_budget", 5);
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 });
|
||||
model.applyResolved(answer("claude", "k2", {}, []));
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 });
|
||||
});
|
||||
|
||||
it("keeps a draft's seed when the first provider is chosen", () => {
|
||||
const model = openDraftOptions({ provider: null, seed: { web_search: true } });
|
||||
|
||||
model.observeProvider("claude");
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ web_search: true });
|
||||
});
|
||||
|
||||
it("sends a remembered value to the daemon, and draws only what comes back", () => {
|
||||
const model = openDraftOptions({ provider: "codex" });
|
||||
model.setRemembered({ service_tier: "fast", retired: true });
|
||||
// Nothing is known about the provider's controls yet.
|
||||
expect(ownDraftOptions(model.getState())).toBeUndefined();
|
||||
|
||||
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
|
||||
// The answer to the first request does not show the remembered value as if it applied.
|
||||
expect(
|
||||
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
|
||||
).toEqual({ service_tier: "default" });
|
||||
|
||||
model.applyResolved(answer("codex", "k2", { service_tier: "priority" }));
|
||||
expect(
|
||||
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
|
||||
).toEqual({ service_tier: "priority" });
|
||||
});
|
||||
|
||||
it("does not let a remembered value override the seed", () => {
|
||||
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "default" } });
|
||||
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
|
||||
model.setRemembered({ service_tier: "fast" });
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "default" });
|
||||
});
|
||||
|
||||
it("lays a toggle over the previous answer until the new one arrives", () => {
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(answer("claude", "k1", { web_search: false }));
|
||||
|
||||
model.setOption("web_search", true);
|
||||
|
||||
expect(
|
||||
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
|
||||
).toEqual({ web_search: true });
|
||||
model.applyResolved(answer("claude", "k2", { web_search: true }));
|
||||
expect(
|
||||
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
|
||||
).toEqual({ web_search: true });
|
||||
});
|
||||
|
||||
it("draws nothing for a provider the answer is not for", () => {
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(answer("claude", "k1", {}));
|
||||
|
||||
expect(viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" })).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import type {
|
||||
AgentOptionControl,
|
||||
AgentSessionConfig,
|
||||
ProviderOptions,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import { withoutLostControls } from "./lost-controls";
|
||||
|
||||
/**
|
||||
* The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers
|
||||
* make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon
|
||||
* (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias,
|
||||
* default or legacy key is resolved here.
|
||||
*
|
||||
* - `seed`: options the draft started from (a profile, a forked agent).
|
||||
* - `remembered`: the provider's last-used control values.
|
||||
* - `edits`: toggles made in this draft.
|
||||
*
|
||||
* An edit of `null` removes the key from every layer, the way a patch does on a running agent, so
|
||||
* the provider's default applies again.
|
||||
*
|
||||
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
|
||||
* and the options in one step, so the provider change that follows does not clear them.
|
||||
*/
|
||||
export interface ResolvedDraftOptions {
|
||||
provider: string;
|
||||
/** Names the request that produced this answer; see `viewDraftOptions`. */
|
||||
requestKey: string;
|
||||
controls: AgentOptionControl[];
|
||||
options: ProviderOptions;
|
||||
}
|
||||
|
||||
export interface DraftOptionsState {
|
||||
provider: string | null;
|
||||
seed: ProviderOptions;
|
||||
remembered: ProviderOptions;
|
||||
edits: ProviderOptions;
|
||||
resolved: ResolvedDraftOptions | null;
|
||||
/** Every key an answer for this provider has drawn as a control. */
|
||||
drawn: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface DraftOptionsModel {
|
||||
getState: () => DraftOptionsState;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** The provider the draft's form now has. A different one than the model's is a manual choice. */
|
||||
observeProvider: (provider: string | null) => void;
|
||||
applyProfile: (profile: { provider: string; options: ProviderOptions }) => void;
|
||||
setRemembered: (remembered: ProviderOptions) => void;
|
||||
setOption: (key: string, value: unknown) => void;
|
||||
applyResolved: (resolved: ResolvedDraftOptions) => void;
|
||||
}
|
||||
|
||||
export function openDraftOptions(input: {
|
||||
provider: string | null;
|
||||
seed?: ProviderOptions;
|
||||
/**
|
||||
* The provider the draft's form has now. An edit belongs to it even before the screen has
|
||||
* rendered the change, so `setOption` observes it first.
|
||||
*/
|
||||
currentProvider?: () => string | null;
|
||||
}): DraftOptionsModel {
|
||||
let state: DraftOptionsState = {
|
||||
provider: input.provider,
|
||||
seed: input.seed ?? {},
|
||||
remembered: {},
|
||||
edits: {},
|
||||
resolved: null,
|
||||
drawn: new Set(),
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function publish(update: (current: DraftOptionsState) => DraftOptionsState): void {
|
||||
const next = update(state);
|
||||
if (next === state) return;
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function observeProvider(provider: string | null): void {
|
||||
publish((current) => {
|
||||
if (provider === current.provider) return current;
|
||||
// The first provider a draft picks does not discard what it was opened with.
|
||||
if (current.provider === null) return { ...current, provider };
|
||||
return { ...current, provider, seed: {}, edits: {}, resolved: null, drawn: new Set() };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
observeProvider,
|
||||
applyProfile: (profile) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
provider: profile.provider,
|
||||
seed: profile.options,
|
||||
edits: {},
|
||||
resolved: current.provider === profile.provider ? current.resolved : null,
|
||||
drawn: current.provider === profile.provider ? current.drawn : new Set(),
|
||||
})),
|
||||
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
|
||||
setOption: (key, value) => {
|
||||
if (input.currentProvider) observeProvider(input.currentProvider());
|
||||
publish((current) =>
|
||||
Object.is(current.edits[key], value)
|
||||
? current
|
||||
: { ...current, edits: { ...current.edits, [key]: value } },
|
||||
);
|
||||
},
|
||||
applyResolved: (resolved) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
resolved,
|
||||
drawn: new Set([...current.drawn, ...resolved.controls.map((control) => control.key)]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the draft would create with, for the daemon to resolve. A last-used value applies
|
||||
* once the provider has told us it still draws a control for that key, and never over the seed.
|
||||
* An edit of a key an earlier answer drew as a control stops applying when the latest answer
|
||||
* draws none for it (the model changed to one without that control) and applies again when a
|
||||
* control for it returns. An edit of a key no answer has drawn is an option without a control
|
||||
* and always applies.
|
||||
*/
|
||||
export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined {
|
||||
const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key));
|
||||
const remembered = Object.fromEntries(
|
||||
Object.entries(state.remembered).filter(
|
||||
([key]) => controlKeys.has(key) && !(key in state.seed),
|
||||
),
|
||||
);
|
||||
const edits = withoutLostControls(state.edits, state.drawn, controlKeys);
|
||||
const own = withoutRemovals({ ...state.seed, ...remembered, ...edits });
|
||||
return Object.keys(own).length > 0 ? own : undefined;
|
||||
}
|
||||
|
||||
function withoutRemovals(options: ProviderOptions): ProviderOptions {
|
||||
return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== null));
|
||||
}
|
||||
|
||||
export type DraftControlsRequest = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
|
||||
>;
|
||||
|
||||
/** What the draft's form has chosen; the options come from the model. */
|
||||
export interface DraftControlsForm {
|
||||
provider: string | null;
|
||||
cwd: string;
|
||||
modeId?: string | null;
|
||||
modelId?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The listing request for the draft's form, built from one snapshot of the model. The form
|
||||
* reaches a new provider before the model has observed it, and the model still holds the
|
||||
* previous provider's options until it does; that render has no request, so a request never
|
||||
* pairs one provider with another's options.
|
||||
*/
|
||||
export function draftControlsRequest(
|
||||
state: DraftOptionsState,
|
||||
form: DraftControlsForm,
|
||||
): DraftControlsRequest | null {
|
||||
if (!form.provider || !form.cwd || form.provider !== state.provider) return null;
|
||||
const providerOptions = ownDraftOptions(state);
|
||||
return {
|
||||
provider: form.provider,
|
||||
cwd: form.cwd,
|
||||
...(form.modeId ? { modeId: form.modeId } : {}),
|
||||
...(form.modelId ? { model: form.modelId } : {}),
|
||||
...(form.thinkingOptionId ? { thinkingOptionId: form.thinkingOptionId } : {}),
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the controls draw: the daemon's answer for this provider. The last answer stays up while
|
||||
* a newer request is in flight, with the toggles made since laid over it, so a switch does not
|
||||
* wait for the round trip. It never carries a seed or remembered value the daemon has not
|
||||
* resolved.
|
||||
*/
|
||||
export function viewDraftOptions(
|
||||
state: DraftOptionsState,
|
||||
current: { provider: string | null; requestKey: string },
|
||||
): { controls: AgentOptionControl[]; options: ProviderOptions } | undefined {
|
||||
const { resolved } = state;
|
||||
if (!resolved || resolved.provider !== current.provider) return undefined;
|
||||
return {
|
||||
controls: resolved.controls,
|
||||
options:
|
||||
resolved.requestKey === current.requestKey
|
||||
? resolved.options
|
||||
: withoutRemovals({ ...resolved.options, ...state.edits }),
|
||||
};
|
||||
}
|
||||
|
||||
const NO_OPTIONS: ProviderOptions = {};
|
||||
|
||||
/**
|
||||
* The options the draft will create with, for a plugin or a control to read: the daemon's answer
|
||||
* for this provider when there is one, otherwise the draft's own layers, so an edit shows before
|
||||
* the daemon has answered.
|
||||
*/
|
||||
export function shownDraftOptions(
|
||||
state: DraftOptionsState,
|
||||
current: { provider: string | null; requestKey: string },
|
||||
): ProviderOptions {
|
||||
return viewDraftOptions(state, current)?.options ?? ownDraftOptions(state) ?? NO_OPTIONS;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAgentFeatureIcon } from "./icons";
|
||||
|
||||
describe("getAgentFeatureIcon", () => {
|
||||
it("draws the Lucide icon a plugin provider names", () => {
|
||||
const icon = getAgentFeatureIcon("Mic");
|
||||
expect(icon).not.toBe(Settings2);
|
||||
expect(getAgentFeatureIcon("Mic")).toBe(icon);
|
||||
});
|
||||
|
||||
it("falls back to the generic icon for a missing or unknown name", () => {
|
||||
expect(getAgentFeatureIcon()).toBe(Settings2);
|
||||
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ShieldQuestionMark,
|
||||
Zap,
|
||||
} from "lucide-react-native";
|
||||
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export interface AgentControlIconProps {
|
||||
@@ -61,6 +62,9 @@ export function getAgentModeOptionIcon(
|
||||
return icon ? MODE_ICONS[icon] : undefined;
|
||||
}
|
||||
|
||||
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
|
||||
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
|
||||
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
|
||||
if (!icon) return Settings2;
|
||||
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
|
||||
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
/**
|
||||
* The options that still apply after an answer from the daemon. A key an earlier answer drew as a
|
||||
* control and the latest answer does not (the model changed to one without that control) is
|
||||
* dropped, and applies again if a control for it returns. A key no answer has drawn is an option
|
||||
* without a control and always applies.
|
||||
*/
|
||||
export function withoutLostControls(
|
||||
options: ProviderOptions,
|
||||
drawn: ReadonlySet<string>,
|
||||
current: ReadonlySet<string>,
|
||||
): ProviderOptions {
|
||||
return Object.fromEntries(
|
||||
Object.entries(options).filter(([key]) => !drawn.has(key) || current.has(key)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
getControlTooltip,
|
||||
isControlActive,
|
||||
readSelectOption,
|
||||
readToggleOption,
|
||||
} from "./option-controls";
|
||||
|
||||
const speed: AgentOptionControl = {
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
};
|
||||
const fast: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
|
||||
|
||||
describe("option controls", () => {
|
||||
it("reads a select from options and falls back to the control's default", () => {
|
||||
if (speed.type !== "select") throw new Error("select expected");
|
||||
expect(readSelectOption(speed, { service_tier: "priority" })).toBe("priority");
|
||||
expect(readSelectOption(speed, {})).toBe("default");
|
||||
expect(readSelectOption(speed, { service_tier: "unknown-tier" })).toBe("default");
|
||||
});
|
||||
|
||||
it("reads a toggle from options and is off when unset", () => {
|
||||
if (fast.type !== "toggle") throw new Error("toggle expected");
|
||||
expect(readToggleOption(fast, { fast_mode: true })).toBe(true);
|
||||
expect(readToggleOption(fast, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("is active when the option differs from the control's default", () => {
|
||||
expect(isControlActive(speed, { service_tier: "priority" })).toBe(true);
|
||||
expect(isControlActive(speed, { service_tier: "default" })).toBe(false);
|
||||
expect(isControlActive(speed, {})).toBe(false);
|
||||
expect(isControlActive(fast, { fast_mode: true })).toBe(true);
|
||||
expect(isControlActive(fast, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers a control's tooltip over its label", () => {
|
||||
expect(getControlTooltip({ label: "Plan", tooltip: "Toggle plan mode" })).toBe(
|
||||
"Toggle plan mode",
|
||||
);
|
||||
expect(getControlTooltip({ label: "Custom" })).toBe("Custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
AgentOptionToggleControl,
|
||||
ProviderOptions,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
|
||||
/**
|
||||
* The composer's option controls. A control only describes how to draw one option key; the
|
||||
* value is read from `options`, which the daemon resolved (defaults, aliases, what runs).
|
||||
*/
|
||||
export interface ComposerOptionControls {
|
||||
controls: AgentOptionControl[];
|
||||
options: ProviderOptions;
|
||||
onSetOption: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export function readToggleOption(
|
||||
control: AgentOptionToggleControl,
|
||||
options: ProviderOptions,
|
||||
): boolean {
|
||||
const value = options[control.key];
|
||||
return typeof value === "boolean" ? value : (control.defaultValue ?? false);
|
||||
}
|
||||
|
||||
/** The selected choice's value, or the control's default when the option names no choice. */
|
||||
export function readSelectOption(
|
||||
control: AgentOptionSelectControl,
|
||||
options: ProviderOptions,
|
||||
): string | null {
|
||||
const value = options[control.key];
|
||||
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
|
||||
return value;
|
||||
}
|
||||
return control.defaultValue ?? null;
|
||||
}
|
||||
|
||||
/** Whether the option is set to something other than the control's default. */
|
||||
export function isControlActive(control: AgentOptionControl, options: ProviderOptions): boolean {
|
||||
if (control.type === "toggle") return readToggleOption(control, options);
|
||||
const value = readSelectOption(control, options);
|
||||
return value !== null && value !== control.defaultValue;
|
||||
}
|
||||
|
||||
export function getControlTooltip(control: Pick<AgentOptionControl, "label" | "tooltip">): string {
|
||||
return control.tooltip ?? control.label;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => {
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
|
||||
modelId: "",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
featureValues: {},
|
||||
options: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,8 +81,8 @@ describe("toAgentConfigApply", () => {
|
||||
|
||||
it("omits an empty feature map so the agent keeps its own feature values", () => {
|
||||
expect(
|
||||
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
|
||||
).not.toHaveProperty("featureValues");
|
||||
toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))),
|
||||
).not.toHaveProperty("options");
|
||||
});
|
||||
|
||||
it("never carries a provider, which a running agent cannot change", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function trimmed(value: string | undefined): string {
|
||||
@@ -25,7 +25,7 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen
|
||||
modelId: trimmed(profile.model),
|
||||
modeId: trimmed(profile.modeId),
|
||||
thinkingOptionId: trimmed(profile.thinkingOptionId),
|
||||
featureValues: profile.featureValues ?? {},
|
||||
options: profile.options ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf
|
||||
...(profile.modelId ? { modelId: profile.modelId } : {}),
|
||||
...(profile.modeId ? { modeId: profile.modeId } : {}),
|
||||
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
|
||||
...(Object.keys(profile.featureValues).length > 0
|
||||
? { featureValues: profile.featureValues }
|
||||
: {}),
|
||||
...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFeatureRequestKey,
|
||||
buildControlsRequestKey,
|
||||
openAgentProfileForm,
|
||||
type AgentProfileFormModel,
|
||||
} from "./profile-form-model";
|
||||
@@ -70,6 +70,21 @@ function optionValues(options: readonly { value: string }[]): string[] {
|
||||
return options.map((option) => option.value);
|
||||
}
|
||||
|
||||
const FAST = {
|
||||
controls: [{ type: "toggle" as const, key: "fast_mode", label: "Fast" }],
|
||||
options: { fast_mode: false },
|
||||
};
|
||||
const NO_CONTROLS = { controls: [], options: {} };
|
||||
|
||||
function toggledOnOpus(): AgentProfileFormModel {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName("Fast");
|
||||
selectClaude(model);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
|
||||
model.setOption("fast_mode", true);
|
||||
return model;
|
||||
}
|
||||
|
||||
describe("openAgentProfileForm", () => {
|
||||
it("starts a create form empty and cannot submit", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
@@ -159,6 +174,7 @@ describe("openAgentProfileForm", () => {
|
||||
provider: "codex",
|
||||
model: "gpt-5.2-codex",
|
||||
modeId: "read-only",
|
||||
options: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +189,7 @@ describe("openAgentProfileForm", () => {
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { webSearch: true },
|
||||
options: { webSearch: true },
|
||||
notes: "Visual work only.",
|
||||
},
|
||||
});
|
||||
@@ -229,10 +245,11 @@ describe("openAgentProfileForm", () => {
|
||||
selectClaude(model);
|
||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||
model.setThinking("think-hard", { label: "Think hard" });
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
model.setFeatureValue("webSearch", true);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||
options: { webSearch: false },
|
||||
});
|
||||
model.setOption("webSearch", true);
|
||||
|
||||
model.setProvider("codex", { label: "Codex" });
|
||||
const state = model.getState();
|
||||
@@ -242,8 +259,9 @@ describe("openAgentProfileForm", () => {
|
||||
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
|
||||
expect(state.thinkingOptionId).toBe("");
|
||||
expect(state.thinkingDisplay).toBeNull();
|
||||
expect(state.featureValues).toEqual({});
|
||||
expect(state.features).toEqual([]);
|
||||
expect(state.options).toEqual({});
|
||||
expect(state.optionEdits).toEqual({});
|
||||
expect(state.controls).toEqual([]);
|
||||
expect(state.name).toBe("Test writer");
|
||||
});
|
||||
|
||||
@@ -334,23 +352,28 @@ describe("openAgentProfileForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("features", () => {
|
||||
it("asks for features per provider/model/mode/thinking combination", () => {
|
||||
const WEB_SEARCH_CONTROLS = {
|
||||
controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }],
|
||||
options: { webSearch: false },
|
||||
};
|
||||
|
||||
describe("controls", () => {
|
||||
it("asks for controls per provider/model/mode/thinking combination", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
expect(model.getState().featureRequest).toEqual({
|
||||
expect(model.getState().controlsRequest).toEqual({
|
||||
provider: "claude",
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think",
|
||||
});
|
||||
const firstKey = model.getState().featureRequestKey;
|
||||
const firstKey = model.getState().controlsRequestKey;
|
||||
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
|
||||
expect(model.getState().featureRequestKey).not.toBe(firstKey);
|
||||
expect(model.getState().featureRequest).toEqual({
|
||||
expect(model.getState().controlsRequestKey).not.toBe(firstKey);
|
||||
expect(model.getState().controlsRequest).toEqual({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
modeId: "plan",
|
||||
@@ -361,77 +384,235 @@ describe("openAgentProfileForm", () => {
|
||||
it("moves from pending to complete only for the current request", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
const key = model.getState().featureRequestKey ?? "";
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
const key = model.getState().controlsRequestKey ?? "";
|
||||
expect(model.getState().controlsResolution).toBe("pending");
|
||||
|
||||
model.applyFeatures("stale-key", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
expect(model.getState().features).toEqual([]);
|
||||
model.applyControls("stale-key", {
|
||||
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||
options: { webSearch: false },
|
||||
});
|
||||
expect(model.getState().controlsResolution).toBe("pending");
|
||||
expect(model.getState().controls).toEqual([]);
|
||||
|
||||
model.applyFeatures(key, [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
expect(model.getState().featureResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showFeaturesField).toBe(true);
|
||||
model.applyControls(key, {
|
||||
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||
options: { webSearch: false },
|
||||
});
|
||||
expect(model.getState().controlsResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showControlsField).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects a set feature value back into the rendered feature", () => {
|
||||
it("reflects a set option back into the rendered controls", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName("Searcher");
|
||||
selectClaude(model);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||
|
||||
model.setOption("webSearch", true);
|
||||
|
||||
expect(model.getState().controls[0]).toMatchObject({ key: "webSearch" });
|
||||
expect(model.getState().controlOptions).toEqual({ webSearch: true });
|
||||
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
|
||||
});
|
||||
|
||||
it("keeps stored options while the controls are still pending", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: { id: "p1", name: "UI work", provider: "claude", options: { webSearch: true } },
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
|
||||
expect(model.getState().controlsResolution).toBe("pending");
|
||||
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
|
||||
});
|
||||
|
||||
it("keeps options no control draws, so editing a profile does not drop them", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
options: { webSearch: true, sandbox_mode: "read-only" },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||
model.setOption("webSearch", false);
|
||||
|
||||
expect(model.getState().submitValue?.options).toEqual({
|
||||
webSearch: false,
|
||||
sandbox_mode: "read-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("draws the options the daemon resolved from the stored ones, then the edits", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: { id: "p1", name: "Fast", provider: "codex", options: { service_tier: "fast" } },
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
|
||||
// The request carries the stored options so the daemon can resolve the legacy tier.
|
||||
expect(model.getState().controlsRequest?.providerOptions).toEqual({ service_tier: "fast" });
|
||||
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||
controls: [
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Standard" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
},
|
||||
],
|
||||
options: { service_tier: "priority" },
|
||||
});
|
||||
|
||||
expect(model.getState().controlOptions).toEqual({ service_tier: "priority" });
|
||||
// Nothing was edited, so the stored value is saved as it was.
|
||||
expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" });
|
||||
});
|
||||
|
||||
it("sends an edit to the daemon with the stored options, and draws the answer", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "Fast",
|
||||
provider: "codex",
|
||||
options: { service_tier: "fast", sandbox_mode: "read-only" },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
const speed = {
|
||||
controls: [
|
||||
{
|
||||
type: "select" as const,
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Standard" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
},
|
||||
],
|
||||
options: { service_tier: "priority", sandbox_mode: "read-only" },
|
||||
};
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", speed);
|
||||
|
||||
model.setOption("service_tier", "default");
|
||||
|
||||
// The controls stay up while the daemon answers, with the toggle laid over the last answer.
|
||||
expect(model.getState().controls).toHaveLength(1);
|
||||
expect(model.getState().controlOptions).toMatchObject({ service_tier: "default" });
|
||||
// The request carries everything the profile would save, so the daemon resolves it.
|
||||
expect(model.getState().controlsRequest?.providerOptions).toEqual({
|
||||
service_tier: "default",
|
||||
sandbox_mode: "read-only",
|
||||
});
|
||||
expect(model.getState().controlsResolution).toBe("pending");
|
||||
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||
...speed,
|
||||
options: { service_tier: "default", sandbox_mode: "read-only" },
|
||||
});
|
||||
expect(model.getState().controlsResolution).toBe("complete");
|
||||
expect(model.getState().controlOptions).toEqual({
|
||||
service_tier: "default",
|
||||
sandbox_mode: "read-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("draws no controls for the previous model while another model's answer is pending", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||
|
||||
model.setFeatureValue("webSearch", true);
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
|
||||
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
|
||||
expect(model.getState().featureValues).toEqual({ webSearch: true });
|
||||
expect(model.getState().controls).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps stored feature values while the feature list is still pending", () => {
|
||||
it("does not save a toggle once the model that drew it is replaced by one without it", () => {
|
||||
const model = toggledOnOpus();
|
||||
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
|
||||
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
|
||||
|
||||
// Empty, not absent: the daemon reads a profile write without `options` as one from an
|
||||
// app that predates them and keeps the stored options.
|
||||
expect(model.getState().submitValue?.options).toEqual({});
|
||||
expect(model.getState().controlsRequest?.providerOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("saves a toggle again when the model that draws it returns", () => {
|
||||
const model = toggledOnOpus();
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
|
||||
|
||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
|
||||
|
||||
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
|
||||
});
|
||||
|
||||
it("does not save a stored toggle profile either", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
name: "Fast",
|
||||
provider: "claude",
|
||||
featureValues: { webSearch: true },
|
||||
model: "claude-opus-5",
|
||||
options: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
|
||||
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
|
||||
|
||||
expect(model.getState().submitValue?.options).toEqual({});
|
||||
});
|
||||
|
||||
it("prunes stored values the provider no longer reports", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
featureValues: { webSearch: true, retiredFlag: true },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
it("never drops an option no control has drawn", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName("Fast");
|
||||
selectClaude(model);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
|
||||
model.setOption("token_budget", 8000);
|
||||
model.setOption("fast_mode", true);
|
||||
|
||||
expect(model.getState().featureValues).toEqual({ webSearch: true });
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
|
||||
|
||||
expect(model.getState().submitValue?.options).toEqual({ token_budget: 8000 });
|
||||
});
|
||||
|
||||
it("keeps a toggle when the daemon cannot list controls", () => {
|
||||
const model = toggledOnOpus();
|
||||
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
|
||||
|
||||
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
|
||||
});
|
||||
|
||||
it("resolves an unavailable feature listing without showing the field", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
|
||||
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
|
||||
|
||||
expect(model.getState().featureResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showFeaturesField).toBe(false);
|
||||
expect(model.getState().controlsResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showControlsField).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,15 +645,15 @@ describe("openAgentProfileForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeatureRequestKey", () => {
|
||||
describe("buildControlsRequestKey", () => {
|
||||
it("is null without a provider", () => {
|
||||
expect(buildFeatureRequestKey(null)).toBeNull();
|
||||
expect(buildControlsRequestKey(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("separates the fields so a value cannot bleed across positions", () => {
|
||||
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
|
||||
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
|
||||
buildFeatureRequestKey({ provider: "claude", model: "a" }),
|
||||
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
|
||||
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
|
||||
buildControlsRequestKey({ provider: "claude", model: "a" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
ProviderOptions,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentSelectOption,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { withoutLostControls } from "@/agent-controls/lost-controls";
|
||||
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
|
||||
/**
|
||||
@@ -57,11 +58,15 @@ export interface AgentProfileFormSnapshot {
|
||||
}
|
||||
|
||||
/**
|
||||
* The inputs a feature listing needs. Features are provider-scoped and change
|
||||
* with model/mode/thinking, so every one of those is part of the request.
|
||||
* The inputs a controls listing needs. Controls are provider-scoped and change
|
||||
* with model/mode/thinking, so every one of those is part of the request. The
|
||||
* profile's own options ride along so the daemon can resolve them (a legacy
|
||||
* `service_tier: "fast"` is drawn as the model's Fast tier); they are the same
|
||||
* for the life of one provider, so they are not part of the key.
|
||||
*/
|
||||
export interface AgentProfileFeatureRequest {
|
||||
export interface AgentProfileControlsRequest {
|
||||
provider: string;
|
||||
providerOptions?: ProviderOptions;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
@@ -73,7 +78,7 @@ export interface AgentProfileFormDisclosure {
|
||||
showModelField: boolean;
|
||||
showModeField: boolean;
|
||||
showThinkingField: boolean;
|
||||
showFeaturesField: boolean;
|
||||
showControlsField: boolean;
|
||||
}
|
||||
|
||||
export interface AgentProfileFormState {
|
||||
@@ -86,13 +91,22 @@ export interface AgentProfileFormState {
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
/** The options the profile was opened with, including ones no control draws. */
|
||||
options: ProviderOptions;
|
||||
/** Changes made in this form, over `options`. */
|
||||
optionEdits: ProviderOptions;
|
||||
|
||||
providerOptions: AgentProfileFormOption[];
|
||||
modelOptions: AgentProfileFormOption[];
|
||||
modeOptions: AgentProfileFormOption[];
|
||||
thinkingOptions: AgentProfileFormOption[];
|
||||
features: AgentFeature[];
|
||||
controls: AgentOptionControl[];
|
||||
/** What the controls show: the options the daemon resolved from `options`, then the edits. */
|
||||
controlOptions: ProviderOptions;
|
||||
/** Every key an answer for this provider has drawn as a control. */
|
||||
drawnControlKeys: ReadonlySet<string>;
|
||||
/** The keys the latest answer draws as controls. */
|
||||
answeredControlKeys: ReadonlySet<string>;
|
||||
|
||||
providerDisplay: AgentProfileFormDisplay | null;
|
||||
modelDisplay: AgentProfileFormDisplay | null;
|
||||
@@ -100,9 +114,9 @@ export interface AgentProfileFormState {
|
||||
thinkingDisplay: AgentProfileFormDisplay | null;
|
||||
|
||||
catalogResolution: AgentProfileResolutionStatus;
|
||||
featureResolution: AgentProfileResolutionStatus;
|
||||
featureRequest: AgentProfileFeatureRequest | null;
|
||||
featureRequestKey: string | null;
|
||||
controlsResolution: AgentProfileResolutionStatus;
|
||||
controlsRequest: AgentProfileControlsRequest | null;
|
||||
controlsRequestKey: string | null;
|
||||
|
||||
disclosure: AgentProfileFormDisclosure;
|
||||
isSubmitting: boolean;
|
||||
@@ -118,9 +132,12 @@ export interface AgentProfileFormModel {
|
||||
/** Late input: the host-scoped provider catalog. Never touches selections. */
|
||||
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
|
||||
/** Late input: the feature list for one request. Stale keys are ignored. */
|
||||
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
|
||||
applyControls: (
|
||||
requestKey: string,
|
||||
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
|
||||
) => void;
|
||||
/** Resolve a request that produced no usable features (provider error). */
|
||||
applyFeaturesUnavailable: (requestKey: string) => void;
|
||||
applyControlsUnavailable: (requestKey: string) => void;
|
||||
setName: (value: string) => void;
|
||||
setAppearance: (value: { icon: string; color: string }) => void;
|
||||
setNotes: (value: string) => void;
|
||||
@@ -128,7 +145,7 @@ export interface AgentProfileFormModel {
|
||||
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
|
||||
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
|
||||
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
|
||||
setFeatureValue: (featureId: string, value: unknown) => void;
|
||||
setOption: (key: string, value: unknown) => void;
|
||||
setSubmitting: (value: boolean) => void;
|
||||
setSubmitError: (value: string | null) => void;
|
||||
}
|
||||
@@ -312,19 +329,39 @@ function refineDisplay(
|
||||
return current ?? { label: selectedId };
|
||||
}
|
||||
|
||||
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
|
||||
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null {
|
||||
if (!state.provider) {
|
||||
return null;
|
||||
}
|
||||
const ownOptions = ownOptionsOf(state);
|
||||
return {
|
||||
provider: state.provider,
|
||||
...(state.modelId ? { model: state.modelId } : {}),
|
||||
...(state.modeId ? { modeId: state.modeId } : {}),
|
||||
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
||||
...(Object.keys(ownOptions).length > 0 ? { providerOptions: ownOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
|
||||
/**
|
||||
* Everything the profile would save as its options; the daemon resolves them for the form. A
|
||||
* toggle the model in use does not draw is not saved (see `withoutLostControls`).
|
||||
*/
|
||||
function ownOptionsOf(
|
||||
state: Pick<
|
||||
AgentProfileFormState,
|
||||
"options" | "optionEdits" | "drawnControlKeys" | "answeredControlKeys"
|
||||
>,
|
||||
): ProviderOptions {
|
||||
return withoutLostControls(
|
||||
{ ...state.options, ...state.optionEdits },
|
||||
state.drawnControlKeys,
|
||||
state.answeredControlKeys,
|
||||
);
|
||||
}
|
||||
|
||||
/** The part of a request that decides which controls exist; options only change their values. */
|
||||
function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
@@ -336,6 +373,18 @@ export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | nul
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function buildControlsRequestKey(
|
||||
request: AgentProfileControlsRequest | null,
|
||||
): string | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
const scope = buildControlsScopeKey(request);
|
||||
return request.providerOptions
|
||||
? `${scope}|${JSON.stringify(Object.entries(request.providerOptions).sort(([a], [b]) => a.localeCompare(b)))}`
|
||||
: scope;
|
||||
}
|
||||
|
||||
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
|
||||
const name = state.name.trim();
|
||||
const notes = state.notes.trim();
|
||||
@@ -350,16 +399,18 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
|
||||
...(state.modelId ? { model: state.modelId } : {}),
|
||||
...(state.modeId ? { modeId: state.modeId } : {}),
|
||||
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
||||
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
|
||||
// Always sent, empty included: the daemon reads a write without `options` as one from an
|
||||
// app that predates them and keeps the profile's stored options.
|
||||
options: ownOptionsOf(state),
|
||||
...(notes ? { notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFeatureStatus(
|
||||
featureRequestKey: string | null,
|
||||
function resolveControlsStatus(
|
||||
controlsRequestKey: string | null,
|
||||
featuresAreCurrent: boolean,
|
||||
): AgentProfileResolutionStatus {
|
||||
if (featureRequestKey === null) {
|
||||
if (controlsRequestKey === null) {
|
||||
return "idle";
|
||||
}
|
||||
return featuresAreCurrent ? "complete" : "pending";
|
||||
@@ -392,25 +443,29 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
|
||||
modelId,
|
||||
modeId: profile.modeId ?? "",
|
||||
thinkingOptionId: profile.thinkingOptionId ?? "",
|
||||
featureValues: { ...profile.featureValues },
|
||||
options: { ...profile.options },
|
||||
optionEdits: {},
|
||||
providerOptions: [],
|
||||
modelOptions: [],
|
||||
modeOptions: [],
|
||||
thinkingOptions: [],
|
||||
features: [],
|
||||
controls: [],
|
||||
controlOptions: {},
|
||||
drawnControlKeys: new Set(),
|
||||
answeredControlKeys: new Set(),
|
||||
providerDisplay: seedDisplay(provider),
|
||||
modelDisplay: seedDisplay(modelId),
|
||||
modeDisplay: seedDisplay(profile.modeId),
|
||||
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
|
||||
catalogResolution: "idle",
|
||||
featureResolution: "idle",
|
||||
featureRequest: null,
|
||||
featureRequestKey: null,
|
||||
controlsResolution: "idle",
|
||||
controlsRequest: null,
|
||||
controlsRequestKey: null,
|
||||
disclosure: {
|
||||
showModelField: false,
|
||||
showModeField: false,
|
||||
showThinkingField: false,
|
||||
showFeaturesField: false,
|
||||
showControlsField: false,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: null,
|
||||
@@ -422,28 +477,45 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
|
||||
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
|
||||
let entries: readonly ProviderSnapshotEntry[] = [];
|
||||
let catalogResolution: AgentProfileResolutionStatus = "idle";
|
||||
let resolvedFeatureKey: string | null = null;
|
||||
let resolvedFeatures: AgentFeature[] = [];
|
||||
let resolvedControlsKey: string | null = null;
|
||||
let resolvedScopeKey: string | null = null;
|
||||
let resolvedControls: AgentOptionControl[] = [];
|
||||
let resolvedOptions: ProviderOptions = {};
|
||||
let drawnKeys: ReadonlySet<string> = new Set();
|
||||
let answeredKeys: ReadonlySet<string> = new Set();
|
||||
let listeners = new Set<() => void>();
|
||||
let closed = false;
|
||||
|
||||
function derive(incoming: AgentProfileFormState): AgentProfileFormState {
|
||||
const models = resolveModels(entries, incoming.provider);
|
||||
const modes = resolveModes(entries, incoming.provider);
|
||||
const next = seedSelections(incoming, {
|
||||
entry: findEntry(entries, incoming.provider),
|
||||
models,
|
||||
modes,
|
||||
});
|
||||
const next: AgentProfileFormState = {
|
||||
...seedSelections(incoming, {
|
||||
entry: findEntry(entries, incoming.provider),
|
||||
models,
|
||||
modes,
|
||||
}),
|
||||
drawnControlKeys: drawnKeys,
|
||||
answeredControlKeys: answeredKeys,
|
||||
};
|
||||
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
|
||||
const featureRequest = buildFeatureRequest(next);
|
||||
const featureRequestKey = buildFeatureRequestKey(featureRequest);
|
||||
const controlsRequest = buildControlsRequest(next);
|
||||
const controlsRequestKey = buildControlsRequestKey(controlsRequest);
|
||||
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
|
||||
const featuresAreCurrent =
|
||||
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
|
||||
const features = featuresAreCurrent
|
||||
? applyFeatureValues(resolvedFeatures, next.featureValues)
|
||||
: [];
|
||||
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
|
||||
controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey;
|
||||
// A toggle changes only the options, so the controls stay up while the daemon answers and the
|
||||
// toggle shows over the last answer. A change of provider, model, mode or thinking draws none
|
||||
// until its own answer arrives.
|
||||
const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey;
|
||||
const controls = controlsApply ? resolvedControls : [];
|
||||
let controlOptions: ProviderOptions = next.options;
|
||||
if (controlsApply) {
|
||||
controlOptions = featuresAreCurrent
|
||||
? resolvedOptions
|
||||
: { ...resolvedOptions, ...next.optionEdits };
|
||||
}
|
||||
const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent);
|
||||
|
||||
const withOptions: AgentProfileFormState = {
|
||||
...next,
|
||||
@@ -451,7 +523,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
modelOptions: buildModelOptions(models),
|
||||
modeOptions: buildModeOptions(modes),
|
||||
thinkingOptions: buildThinkingOptions(thinking),
|
||||
features,
|
||||
controls,
|
||||
controlOptions,
|
||||
providerDisplay: refineDisplay(
|
||||
next.providerDisplay,
|
||||
next.provider,
|
||||
@@ -479,9 +552,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
})(),
|
||||
),
|
||||
catalogResolution,
|
||||
featureResolution,
|
||||
featureRequest,
|
||||
featureRequestKey,
|
||||
controlsResolution,
|
||||
controlsRequest,
|
||||
controlsRequestKey,
|
||||
};
|
||||
|
||||
// A field appears once the catalog can populate it, and stays visible while
|
||||
@@ -492,7 +565,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
|
||||
showThinkingField:
|
||||
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
|
||||
showFeaturesField: hasProvider && features.length > 0,
|
||||
showControlsField: hasProvider && controls.length > 0,
|
||||
};
|
||||
const canSubmit =
|
||||
withOptions.name.trim().length > 0 &&
|
||||
@@ -531,23 +604,26 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
catalogResolution = "complete";
|
||||
publish((current) => current);
|
||||
},
|
||||
applyFeatures: (requestKey, features) => {
|
||||
if (requestKey !== state.featureRequestKey) {
|
||||
applyControls: (requestKey, resolved) => {
|
||||
if (requestKey !== state.controlsRequestKey) {
|
||||
return;
|
||||
}
|
||||
resolvedFeatureKey = requestKey;
|
||||
resolvedFeatures = [...features];
|
||||
publish((current) => ({
|
||||
...current,
|
||||
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
|
||||
}));
|
||||
resolvedControlsKey = requestKey;
|
||||
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
|
||||
resolvedControls = [...resolved.controls];
|
||||
resolvedOptions = resolved.options;
|
||||
answeredKeys = new Set(resolved.controls.map((control) => control.key));
|
||||
drawnKeys = new Set([...drawnKeys, ...answeredKeys]);
|
||||
publish((current) => current);
|
||||
},
|
||||
applyFeaturesUnavailable: (requestKey) => {
|
||||
if (requestKey !== state.featureRequestKey) {
|
||||
applyControlsUnavailable: (requestKey) => {
|
||||
if (requestKey !== state.controlsRequestKey) {
|
||||
return;
|
||||
}
|
||||
resolvedFeatureKey = requestKey;
|
||||
resolvedFeatures = [];
|
||||
resolvedControlsKey = requestKey;
|
||||
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
|
||||
resolvedControls = [];
|
||||
resolvedOptions = {};
|
||||
publish((current) => current);
|
||||
},
|
||||
setName: (value) => publish((current) => ({ ...current, name: value })),
|
||||
@@ -562,6 +638,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
// Everything below the provider is provider-scoped: a model id, mode id,
|
||||
// thinking id or feature id from the old provider means nothing here.
|
||||
// Clearing them is enough — `derive` seeds the new provider's defaults.
|
||||
drawnKeys = new Set();
|
||||
answeredKeys = new Set();
|
||||
return {
|
||||
...current,
|
||||
provider: providerId,
|
||||
@@ -572,7 +650,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
modeDisplay: null,
|
||||
thinkingOptionId: "",
|
||||
thinkingDisplay: null,
|
||||
featureValues: {},
|
||||
options: {},
|
||||
optionEdits: {},
|
||||
};
|
||||
}),
|
||||
setModel: (modelId, display) =>
|
||||
@@ -598,10 +677,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
publish((current) => ({ ...current, modeId, modeDisplay: display })),
|
||||
setThinking: (thinkingOptionId, display) =>
|
||||
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
|
||||
setFeatureValue: (featureId, value) =>
|
||||
setOption: (key, value) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
featureValues: { ...current.featureValues, [featureId]: value },
|
||||
optionEdits: { ...current.optionEdits, [key]: value },
|
||||
})),
|
||||
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
|
||||
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
|
||||
|
||||
@@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: {
|
||||
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
|
||||
}
|
||||
|
||||
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
|
||||
const featureCount = Object.keys(input.profile.options ?? {}).length;
|
||||
if (featureCount > 0) {
|
||||
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
|
||||
}
|
||||
|
||||
@@ -117,9 +117,7 @@ export function useAgentProfilePicker(
|
||||
modelId: resolved.modelId,
|
||||
modeId: resolved.modeId,
|
||||
thinkingOptionId: resolved.thinkingOptionId,
|
||||
...(Object.keys(resolved.featureValues).length > 0
|
||||
? { featureValues: resolved.featureValues }
|
||||
: {}),
|
||||
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}),
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
|
||||
}, [entries, input.model]);
|
||||
}
|
||||
|
||||
export function useAgentProfileFormFeatures(input: {
|
||||
export function useAgentProfileFormControls(input: {
|
||||
serverId: string;
|
||||
model: AgentProfileFormModel;
|
||||
state: AgentProfileFormState;
|
||||
@@ -35,14 +35,14 @@ export function useAgentProfileFormFeatures(input: {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(input.serverId);
|
||||
const request = input.state.featureRequest;
|
||||
const requestKey = input.state.featureRequestKey;
|
||||
const request = input.state.controlsRequest;
|
||||
const requestKey = input.state.controlsRequestKey;
|
||||
|
||||
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
|
||||
// page's data while the next request is in flight, which would hand the
|
||||
// features of one provider/model combination to the key of another.
|
||||
const featuresQuery = useFetchQuery({
|
||||
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
|
||||
const controlsQuery = useFetchQuery({
|
||||
queryKey: ["agentProfileControls", input.serverId, requestKey],
|
||||
dataShape: "value",
|
||||
staleTimeMs: 5 * 60 * 1000,
|
||||
enabled: Boolean(client && isConnected && request),
|
||||
@@ -51,28 +51,28 @@ export function useAgentProfileFormFeatures(input: {
|
||||
if (!client || !request) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
|
||||
const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.features ?? [];
|
||||
return { controls: payload.controls ?? [], options: payload.options ?? {} };
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isError } = featuresQuery;
|
||||
const { data, isError } = controlsQuery;
|
||||
const { model } = input;
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestKey || !data) {
|
||||
return;
|
||||
}
|
||||
model.applyFeatures(requestKey, data);
|
||||
model.applyControls(requestKey, data);
|
||||
}, [data, model, requestKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestKey || !isError) {
|
||||
return;
|
||||
}
|
||||
model.applyFeaturesUnavailable(requestKey);
|
||||
model.applyControlsUnavailable(requestKey);
|
||||
}, [isError, model, requestKey]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement }
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,7 +25,7 @@ import type {
|
||||
} from "../internal/profile-form-model";
|
||||
import {
|
||||
useAgentProfileFormCatalog,
|
||||
useAgentProfileFormFeatures,
|
||||
useAgentProfileFormControls,
|
||||
} from "../internal/use-profile-form-inputs";
|
||||
import {
|
||||
useAgentProfileFormModel,
|
||||
@@ -140,7 +141,7 @@ function OpenAgentProfileEditModal({
|
||||
const model = useAgentProfileFormModel(snapshot);
|
||||
const state = useAgentProfileFormState(model);
|
||||
useAgentProfileFormCatalog({ serverId, model });
|
||||
useAgentProfileFormFeatures({ serverId, model, state });
|
||||
useAgentProfileFormControls({ serverId, model, state });
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
@@ -327,16 +328,17 @@ function OpenAgentProfileEditModal({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.disclosure.showFeaturesField ? (
|
||||
{state.disclosure.showControlsField ? (
|
||||
<Field
|
||||
label={t("settings.host.agentProfiles.featuresLabel")}
|
||||
testID="agent-profile-features-field"
|
||||
>
|
||||
<View style={styles.featureCard} testID="agent-profile-features-card">
|
||||
{state.features.map((feature, index) => (
|
||||
<AgentProfileFeatureRow
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
{state.controls.map((control, index) => (
|
||||
<AgentProfileControlRow
|
||||
key={control.key}
|
||||
control={control}
|
||||
options={state.controlOptions}
|
||||
isFirst={index === 0}
|
||||
disabled={state.isSubmitting}
|
||||
size={controlSize}
|
||||
@@ -399,14 +401,16 @@ function OpenAgentProfileEditModal({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfileFeatureRow({
|
||||
feature,
|
||||
function AgentProfileControlRow({
|
||||
control,
|
||||
options,
|
||||
isFirst,
|
||||
disabled,
|
||||
size,
|
||||
model,
|
||||
}: {
|
||||
feature: AgentFeature;
|
||||
control: AgentOptionControl;
|
||||
options: ProviderOptions;
|
||||
isFirst: boolean;
|
||||
disabled: boolean;
|
||||
size: ControlSize;
|
||||
@@ -417,74 +421,75 @@ function AgentProfileFeatureRow({
|
||||
[isFirst],
|
||||
);
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => model.setFeatureValue(feature.id, value),
|
||||
[feature.id, model],
|
||||
(value: boolean) => model.setOption(control.key, value),
|
||||
[control.key, model],
|
||||
);
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => model.setFeatureValue(feature.id, value),
|
||||
[feature.id, model],
|
||||
(value: string) => model.setOption(control.key, value),
|
||||
[control.key, model],
|
||||
);
|
||||
const selectOptions = useMemo<SelectFieldOption<string>[]>(
|
||||
() =>
|
||||
feature.type === "select"
|
||||
? feature.options.map((option) => ({
|
||||
id: option.id,
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
...(option.description ? { description: option.description } : {}),
|
||||
testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
|
||||
control.type === "select"
|
||||
? control.choices.map((choice) => ({
|
||||
id: choice.value,
|
||||
value: choice.value,
|
||||
label: choice.label,
|
||||
...(choice.description ? { description: choice.description } : {}),
|
||||
testID: `agent-profile-feature-option-${control.key}-${choice.value}`,
|
||||
}))
|
||||
: [],
|
||||
[feature],
|
||||
[control],
|
||||
);
|
||||
const selectedValue = control.type === "select" ? readSelectOption(control, options) : null;
|
||||
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
|
||||
if (feature.type !== "select" || !feature.value) {
|
||||
if (control.type !== "select" || !selectedValue) {
|
||||
return null;
|
||||
}
|
||||
const option = feature.options.find((candidate) => candidate.id === feature.value);
|
||||
return { label: option?.label ?? feature.value };
|
||||
}, [feature]);
|
||||
const choice = control.choices.find((candidate) => candidate.value === selectedValue);
|
||||
return { label: choice?.label ?? selectedValue };
|
||||
}, [control, selectedValue]);
|
||||
|
||||
if (feature.type === "select") {
|
||||
if (control.type === "select") {
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||
<SelectField
|
||||
label={feature.label}
|
||||
value={feature.value}
|
||||
label={control.label}
|
||||
value={selectedValue}
|
||||
selectedDisplay={selectedDisplay}
|
||||
options={selectOptions}
|
||||
onChange={handleSelect}
|
||||
placeholder={feature.label}
|
||||
emptyText={feature.label}
|
||||
placeholder={control.label}
|
||||
emptyText={control.label}
|
||||
disabled={disabled}
|
||||
{...(feature.description ? { hint: feature.description } : {})}
|
||||
title={feature.label}
|
||||
{...(control.description ? { hint: control.description } : {})}
|
||||
title={control.label}
|
||||
size={size}
|
||||
testID={`agent-profile-feature-field-${feature.id}`}
|
||||
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
|
||||
testID={`agent-profile-feature-field-${control.key}`}
|
||||
triggerTestID={`agent-profile-feature-trigger-${control.key}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||
<View style={styles.featureMeta}>
|
||||
<Text style={styles.featureLabel} numberOfLines={1}>
|
||||
{feature.label}
|
||||
{control.label}
|
||||
</Text>
|
||||
{feature.description ? (
|
||||
{control.description ? (
|
||||
<Text style={styles.featureDescription} numberOfLines={2}>
|
||||
{feature.description}
|
||||
{control.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Switch
|
||||
value={feature.value === true}
|
||||
value={readToggleOption(control, options)}
|
||||
onValueChange={handleToggle}
|
||||
disabled={disabled}
|
||||
accessibilityLabel={feature.label}
|
||||
testID={`agent-profile-feature-switch-${feature.id}`}
|
||||
accessibilityLabel={control.label}
|
||||
testID={`agent-profile-feature-switch-${control.key}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ function message(seq: number, text = "target"): StreamItem {
|
||||
kind: "assistant_message",
|
||||
text,
|
||||
timestamp: new Date(0),
|
||||
timelineCursor: { epoch: "epoch", seq },
|
||||
timelineCursor: { epoch: "epoch", seq, anchorSeq: seq },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ it("loads an older location and searches a response merged across suppressed tas
|
||||
source.map((item, index) => ({
|
||||
event: { type: "timeline" as const, provider: "claude" as const, item },
|
||||
timestamp: new Date(0),
|
||||
timelineCursor: { epoch: "epoch", seq: index + 1 },
|
||||
timelineCursor: { epoch: "epoch", seq: index + 1, anchorSeq: index + 1 },
|
||||
})),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("useChatOutline", () => {
|
||||
kind: "user_message",
|
||||
text: "live prompt",
|
||||
timestamp: new Date(seq),
|
||||
timelineCursor: { epoch: "epoch-1", seq },
|
||||
timelineCursor: { epoch: "epoch-1", seq, anchorSeq: seq },
|
||||
});
|
||||
rerender({ tail: [loadedPrompt(2)] });
|
||||
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2));
|
||||
@@ -198,7 +198,7 @@ describe("useChatOutline", () => {
|
||||
kind: "assistant_message",
|
||||
text: "chunk",
|
||||
timestamp: new Date(),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
|
||||
},
|
||||
],
|
||||
timelineEpoch: "epoch-1",
|
||||
@@ -219,7 +219,7 @@ describe("useChatOutline", () => {
|
||||
kind: "user_message",
|
||||
text: "Replaced conversation",
|
||||
timestamp: new Date(2),
|
||||
timelineCursor: { epoch: "epoch-2", seq: 1 },
|
||||
timelineCursor: { epoch: "epoch-2", seq: 1, anchorSeq: 1 },
|
||||
},
|
||||
],
|
||||
timelineEpoch: "epoch-2",
|
||||
@@ -275,7 +275,7 @@ describe("useChatOutline", () => {
|
||||
kind: "user_message" as const,
|
||||
text: "prompt",
|
||||
timestamp: new Date(1),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
|
||||
},
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -326,7 +326,7 @@ describe("useChatOutline", () => {
|
||||
kind: "user_message" as const,
|
||||
text: "prompt",
|
||||
timestamp: new Date(1),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 },
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ tail, mountedHistoryMessageIds }) =>
|
||||
@@ -381,7 +381,7 @@ describe("useChatOutline", () => {
|
||||
kind: "user_message" as const,
|
||||
text: "prompt",
|
||||
timestamp: new Date(2),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 },
|
||||
};
|
||||
const revealLoadedMessage = vi.fn();
|
||||
revealLoadedMessage.mockReturnValue(false);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { act, createRef, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import { MessagePreview } from "@/components/user-message";
|
||||
import { DisclosureRow } from "./disclosure";
|
||||
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
|
||||
|
||||
// Load translations so the toggles expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
// The plan body renders through the app's Markdown renderer, which reads through React Query.
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
interface Hold {
|
||||
rowId: string;
|
||||
triggerEdge: DisclosureTriggerEdge;
|
||||
/** Whether the expanded content was already on screen when the strategy received the hold. */
|
||||
appliedBefore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A timeline row whose viewport records each hold. Like the strategies, it runs `apply` itself, so
|
||||
* a toggle that skips the hold still changes state but leaves no record.
|
||||
*/
|
||||
function mountRow(rowId: string, isApplied: () => boolean, children: ReactNode) {
|
||||
const holds: Hold[] = [];
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdRow: (heldRowId, apply, triggerEdge) => {
|
||||
holds.push({ rowId: heldRowId, triggerEdge, appliedBefore: isApplied() });
|
||||
apply();
|
||||
},
|
||||
};
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
viewportRef.current = viewport;
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DisclosureRow rowId={rowId} viewportRef={viewportRef}>
|
||||
{children}
|
||||
</DisclosureRow>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
return holds;
|
||||
}
|
||||
|
||||
const view = () => within(container);
|
||||
|
||||
const preLine = { whiteSpace: "pre-line" } as const;
|
||||
|
||||
async function waitForLayout() {
|
||||
await act(async () => {
|
||||
for (let frame = 0; frame < 2; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("timeline disclosures hold their row", () => {
|
||||
it("routes user message show more and show less through the row's hold", async () => {
|
||||
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
|
||||
const holds = mountRow(
|
||||
"message-1",
|
||||
() => view().queryByRole("button", { name: "Show less" }) !== null,
|
||||
<MessagePreview>
|
||||
<span style={preLine}>{longText}</span>
|
||||
</MessagePreview>,
|
||||
);
|
||||
// The preview learns it overflows from its first layout.
|
||||
await waitForLayout();
|
||||
|
||||
act(() => view().getByRole("button", { name: "Show more" }).click());
|
||||
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
|
||||
|
||||
act(() => view().getByRole("button", { name: "Show less" }).click());
|
||||
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
|
||||
|
||||
expect(holds).toEqual([
|
||||
// Show more sits under the text it opens.
|
||||
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: false },
|
||||
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes plan card collapse and expand through the row's hold", () => {
|
||||
const header = () => view().getByRole("button", { name: "Approved plan" });
|
||||
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
|
||||
const holds = mountRow(
|
||||
"plan-1",
|
||||
isExpanded,
|
||||
<PlanCard title="Plan" text="Step one" outcome="approved" />,
|
||||
);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(false);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(true);
|
||||
|
||||
expect(holds).toEqual([
|
||||
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: true },
|
||||
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("toggles a plan card outside a timeline row, as the pending permission card renders", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PlanCard title="Plan" text="Step one" outcome="pending" />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
const header = () => view().getByRole("button", { name: "Plan" });
|
||||
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(false);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react";
|
||||
import type { StreamViewportHandle } from "./strategy";
|
||||
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
|
||||
|
||||
/**
|
||||
* Runs a disclosure's state change. Inside a timeline row, the row that owns the
|
||||
* trigger keeps its screen position through the change; elsewhere `apply` runs as is.
|
||||
* Runs a disclosure's state change. Inside a timeline row, the trigger keeps its screen
|
||||
* position through the change; elsewhere `apply` runs as is. A trigger below the content it
|
||||
* opens passes `triggerEdge: "bottom"`.
|
||||
*/
|
||||
export type DisclosureHold = (apply: () => void) => void;
|
||||
export type DisclosureHold = (
|
||||
apply: () => void,
|
||||
options?: { triggerEdge?: DisclosureTriggerEdge },
|
||||
) => void;
|
||||
|
||||
const applyInPlace: DisclosureHold = (apply) => apply();
|
||||
|
||||
@@ -21,10 +25,10 @@ export function DisclosureRow({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const hold = useMemo<DisclosureHold>(
|
||||
() => (apply) => {
|
||||
() => (apply, options) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) {
|
||||
viewport.holdRow(rowId, apply);
|
||||
viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top");
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { act, useSyncExternalStore } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { View } from "react-native";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { disclosureAnchor } from "./native-disclosure-hold";
|
||||
|
||||
// The native chat timeline runs on the patched LegendList (patches/@legendapp+list+3.7.1.patch).
|
||||
// A disclosure hold keeps only the held row in place: `shouldRestorePosition` accepts no other row.
|
||||
@@ -130,7 +131,16 @@ function mountList(heldRowId: string) {
|
||||
return position - state.scroll;
|
||||
};
|
||||
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
|
||||
return { render, scrollTo, heldRowTop, rowSize };
|
||||
const rowBottom = (rowId: string) => {
|
||||
const state = listRef.current?.getState();
|
||||
const position = state?.positionByKey(rowId);
|
||||
const size = state?.sizes.get(rowId);
|
||||
if (state === undefined || position === undefined || size === undefined) {
|
||||
throw new Error(`Row ${rowId} is not laid out`);
|
||||
}
|
||||
return position + size - state.scroll;
|
||||
};
|
||||
return { render, scrollTo, heldRowTop, rowSize, rowBottom };
|
||||
}
|
||||
|
||||
it("anchors a data change on a held row whose top is above the viewport", async () => {
|
||||
@@ -171,6 +181,72 @@ it("anchors a size change on a held row whose top is above the viewport", async
|
||||
expect(list.heldRowTop()).toBe(-20);
|
||||
});
|
||||
|
||||
// A long message's show more sits under the text it opens, on the bottom edge of its row. The
|
||||
// strategy anchors the row below it, whose top is that bottom edge.
|
||||
const bottomTriggerRows: Row[] = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `row-${index}`,
|
||||
height: index === 3 ? 60 : 40,
|
||||
}));
|
||||
|
||||
function mountBottomTriggerHold(rowId: string) {
|
||||
const anchor = disclosureAnchor(bottomTriggerRows, rowId, "bottom");
|
||||
if (anchor.kind !== "row") throw new Error(`Row ${rowId} has no row below it`);
|
||||
return mountList(anchor.rowId);
|
||||
}
|
||||
|
||||
it("keeps the bottom of a row whose trigger sits under its growing content", async () => {
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(100);
|
||||
const triggerBottom = list.rowBottom("row-3");
|
||||
expect(triggerBottom).toBe(80);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row whose top is above the viewport", async () => {
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(140);
|
||||
const triggerBottom = list.rowBottom("row-3");
|
||||
expect(triggerBottom).toBe(40);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row that ends below the viewport", async () => {
|
||||
// In the app the row ends with the message's timestamp under the trigger, so a trigger near the
|
||||
// bottom of the viewport has its row's bottom, and the row below, out of view.
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(40);
|
||||
const rowBottom = list.rowBottom("row-3");
|
||||
expect(rowBottom).toBe(140);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(rowBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(rowBottom);
|
||||
});
|
||||
|
||||
it("keeps measured row sizes while the list is not laid out", async () => {
|
||||
// A hidden retained tab is display: none. Its rows still render, and a row measured there
|
||||
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
import {
|
||||
createNativeDisclosureHold,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type DisclosureAnchor,
|
||||
} from "./native-disclosure-hold";
|
||||
|
||||
function createFrames() {
|
||||
let nextId = 1;
|
||||
@@ -39,22 +44,22 @@ describe("native disclosure hold", () => {
|
||||
it("holds the row until two frames pass without an item size change", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.heldRowId()).toBe("turn-1");
|
||||
expect(hold.anchorRowId()).toBe("turn-1");
|
||||
expect(releases).toEqual([]);
|
||||
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.heldRowId()).toBeNull();
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual(["released"]);
|
||||
});
|
||||
|
||||
it("restarts the quiet frames on every item size change", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
frames.runFrame();
|
||||
hold.noteItemSizeChanged();
|
||||
frames.runFrame();
|
||||
@@ -79,11 +84,11 @@ describe("native disclosure hold", () => {
|
||||
it("moves the hold to the latest toggled row and releases once", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
frames.runFrame();
|
||||
hold.hold("turn-2");
|
||||
hold.hold({ kind: "row", rowId: "turn-2" });
|
||||
|
||||
expect(hold.heldRowId()).toBe("turn-2");
|
||||
expect(hold.anchorRowId()).toBe("turn-2");
|
||||
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
@@ -94,12 +99,137 @@ describe("native disclosure hold", () => {
|
||||
it("drops the hold without releasing when cancelled", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.cancel();
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.heldRowId()).toBeNull();
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("holds the content end until two quiet frames pass, with no row to anchor on", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "end" });
|
||||
frames.runFrame();
|
||||
hold.noteItemSizeChanged();
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual([]);
|
||||
|
||||
frames.runFrame();
|
||||
|
||||
expect(releases).toEqual(["released"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-frame disclosure hold", () => {
|
||||
it("anchors the row a reply landed as while the toggle waited for its frame", () => {
|
||||
const { frames, hold } = createHarness();
|
||||
let anchor: DisclosureAnchor = { kind: "end" };
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => anchor,
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
expect(applied).toEqual([]);
|
||||
|
||||
anchor = { kind: "row", rowId: "turn-2" };
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBe("turn-2");
|
||||
expect(applied).toEqual([{ kind: "row", rowId: "turn-2" }]);
|
||||
});
|
||||
|
||||
it("still lands the toggle when the hold is cancelled before its frame", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => ({ kind: "end" }),
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
hold.cancel();
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
expect(releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("lands a waiting toggle before the next hold starts", () => {
|
||||
const { frames, hold } = createHarness();
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => ({ kind: "end" }),
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
frames.runFrame();
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
expect(hold.anchorRowId()).toBe("turn-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disclosure anchor", () => {
|
||||
const rows = [{ id: "message-1" }, { id: "turn-1" }, { id: "message-2" }];
|
||||
|
||||
it("keeps the top of a row whose trigger sits on its top edge", () => {
|
||||
expect(disclosureAnchor(rows, "turn-1", "top")).toEqual({ kind: "row", rowId: "turn-1" });
|
||||
});
|
||||
|
||||
it("keeps the top of the row below a row whose trigger sits on its bottom edge", () => {
|
||||
expect(disclosureAnchor(rows, "message-1", "bottom")).toEqual({
|
||||
kind: "row",
|
||||
rowId: "turn-1",
|
||||
});
|
||||
});
|
||||
|
||||
// A prompt the agent has not answered yet is the last row; the running indicator is the footer.
|
||||
it("keeps the content end for a bottom-edge trigger on the last row", () => {
|
||||
expect(disclosureAnchor(rows, "message-2", "bottom")).toEqual({ kind: "end" });
|
||||
});
|
||||
|
||||
// The strategy re-derives this each render, so a reply lets go of the end in the commit it lands.
|
||||
it("stops keeping the content end once a reply lands below the row", () => {
|
||||
expect(disclosureAnchor([...rows, { id: "turn-2" }], "message-2", "bottom")).toEqual({
|
||||
kind: "row",
|
||||
rowId: "turn-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("end hold distance", () => {
|
||||
const viewportHeight = 600;
|
||||
|
||||
it("keeps the distance from the end the toggle started at", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 2000, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(20);
|
||||
});
|
||||
|
||||
it("shrinks to the scroll room a collapse leaves", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 610, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(10);
|
||||
});
|
||||
|
||||
it("stays at the end when output grows the content after a collapse reached the end", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 600, viewportHeight });
|
||||
distance.noteContentSize({ contentHeight: 700, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import type { DisclosureTriggerEdge } from "./strategy";
|
||||
|
||||
/** What a disclosure hold keeps in place: the top of a row, or the end of the content. */
|
||||
export type DisclosureAnchor = { kind: "row"; rowId: string } | { kind: "end" };
|
||||
|
||||
/**
|
||||
* The native viewport's disclosure hold. While a row is held, it is the only row the list may
|
||||
* anchor on. The hold ends once item sizes stop changing for `quietFrames` frames: the list
|
||||
* measures the toggled row and any rows it inserted over one or more frames, and end following
|
||||
* must not resume between the change and the list's last layout correction.
|
||||
* The native viewport's disclosure hold. While it holds a row anchor, that row is the only row the
|
||||
* list may anchor on (`disclosureAnchor`). The hold ends once item sizes stop changing for
|
||||
* `quietFrames` frames: the list measures the toggled row and any rows it inserted over one or more
|
||||
* frames, and end following must not resume between the change and the list's last layout
|
||||
* correction.
|
||||
*/
|
||||
export interface NativeDisclosureHold {
|
||||
heldRowId(): string | null;
|
||||
hold(rowId: string): void;
|
||||
/** The row the list may anchor on; null outside a hold and while the hold keeps the end. */
|
||||
anchorRowId(): string | null;
|
||||
hold(anchor: DisclosureAnchor): void;
|
||||
/**
|
||||
* Holds on the next frame and runs `apply` there, for a toggle that has to land after a commit
|
||||
* already on its way. `resolveAnchor` runs in that frame, because rows may land while it waits.
|
||||
* A later hold or a cancel lands a waiting toggle right away.
|
||||
*/
|
||||
holdNextFrame(
|
||||
resolveAnchor: () => DisclosureAnchor,
|
||||
apply: (anchor: DisclosureAnchor) => void,
|
||||
): void;
|
||||
noteItemSizeChanged(): void;
|
||||
/** Drops the hold without releasing it. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
@@ -17,9 +34,10 @@ export function createNativeDisclosureHold(input: {
|
||||
cancelFrame: (id: number) => void;
|
||||
onRelease: () => void;
|
||||
}): NativeDisclosureHold {
|
||||
let heldRowId: string | null = null;
|
||||
let anchor: DisclosureAnchor | null = null;
|
||||
let frameId: number | null = null;
|
||||
let remainingFrames = 0;
|
||||
let waitingToggle: { frameId: number; land: () => void } | null = null;
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (frameId !== null) {
|
||||
@@ -35,7 +53,7 @@ export function createNativeDisclosureHold(input: {
|
||||
return;
|
||||
}
|
||||
frameId = null;
|
||||
heldRowId = null;
|
||||
anchor = null;
|
||||
input.onRelease();
|
||||
};
|
||||
|
||||
@@ -45,20 +63,80 @@ export function createNativeDisclosureHold(input: {
|
||||
frameId = input.requestFrame(tick);
|
||||
};
|
||||
|
||||
const landWaitingToggle = () => {
|
||||
if (!waitingToggle) return;
|
||||
const { frameId: waitingFrameId, land } = waitingToggle;
|
||||
waitingToggle = null;
|
||||
input.cancelFrame(waitingFrameId);
|
||||
land();
|
||||
};
|
||||
|
||||
const startHold = (nextAnchor: DisclosureAnchor) => {
|
||||
anchor = nextAnchor;
|
||||
restartCountdown();
|
||||
};
|
||||
|
||||
return {
|
||||
heldRowId: () => heldRowId,
|
||||
hold(rowId) {
|
||||
heldRowId = rowId;
|
||||
restartCountdown();
|
||||
anchorRowId: () => (anchor?.kind === "row" ? anchor.rowId : null),
|
||||
hold(nextAnchor) {
|
||||
landWaitingToggle();
|
||||
startHold(nextAnchor);
|
||||
},
|
||||
holdNextFrame(resolveAnchor, apply) {
|
||||
landWaitingToggle();
|
||||
const waitingFrameId = input.requestFrame(() => {
|
||||
waitingToggle = null;
|
||||
const nextAnchor = resolveAnchor();
|
||||
startHold(nextAnchor);
|
||||
apply(nextAnchor);
|
||||
});
|
||||
waitingToggle = { frameId: waitingFrameId, land: () => apply(resolveAnchor()) };
|
||||
},
|
||||
noteItemSizeChanged() {
|
||||
if (heldRowId !== null) {
|
||||
if (anchor !== null) {
|
||||
restartCountdown();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
landWaitingToggle();
|
||||
stopCountdown();
|
||||
heldRowId = null;
|
||||
anchor = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the list keeps in place while a disclosure toggles `rowId`. LegendList restores row tops, so
|
||||
* a trigger on the bottom edge of its row anchors the row below: that row's top is the toggled
|
||||
* row's bottom. The last row's bottom is the end of the content.
|
||||
*/
|
||||
export function disclosureAnchor(
|
||||
rows: readonly { id: string }[],
|
||||
rowId: string,
|
||||
triggerEdge: DisclosureTriggerEdge,
|
||||
): DisclosureAnchor {
|
||||
if (triggerEdge === "top") return { kind: "row", rowId };
|
||||
const index = rows.findIndex((row) => row.id === rowId);
|
||||
const below = rows[index + 1];
|
||||
return below ? { kind: "row", rowId: below.id } : { kind: "end" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The list's distance from the end during an end hold. The end pin keeps the distance through every
|
||||
* content size change, and the scroll view clamps it to the scroll room the content has at that
|
||||
* moment. A clamp is permanent: content that grows afterwards does not bring the distance back.
|
||||
*/
|
||||
export function createEndHoldDistance(distanceAtToggle: number): EndHoldDistance {
|
||||
let distance = distanceAtToggle;
|
||||
return {
|
||||
noteContentSize({ contentHeight, viewportHeight }) {
|
||||
distance = Math.min(distance, Math.max(0, contentHeight - viewportHeight));
|
||||
},
|
||||
current: () => distance,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EndHoldDistance {
|
||||
noteContentSize(input: { contentHeight: number; viewportHeight: number }): void;
|
||||
current(): number;
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ describe("stream presentation through installed plugins", () => {
|
||||
const source = harness.source();
|
||||
const live = source.head[0];
|
||||
if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message");
|
||||
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } };
|
||||
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 } };
|
||||
const present = createStreamPresentation();
|
||||
present({ ...presentationOptions, tail: [], head: [live], transform: undefined });
|
||||
const repointed = rows(
|
||||
@@ -330,7 +330,7 @@ describe("stream presentation through installed plugins", () => {
|
||||
{
|
||||
id: streamed[0]!.id,
|
||||
text: "Streamed answer",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 42 },
|
||||
timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -517,7 +517,7 @@ describe("timeline presentation", () => {
|
||||
messageId: "provider-message",
|
||||
clientMessageId: "client-message",
|
||||
turnId: "turn-1",
|
||||
timelineCursor: { epoch: "epoch", seq: 12 },
|
||||
timelineCursor: { epoch: "epoch", seq: 12, anchorSeq: 12 },
|
||||
});
|
||||
const presentMessage = () =>
|
||||
present({
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("reading anchor", () => {
|
||||
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
|
||||
const replacements = new Map([["tool", "prompt"]]);
|
||||
expect(anchor.getRowId(replacements)).toBe("prompt");
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048, height: 100 }, replacements)).toBe(1000);
|
||||
// Projection does not consume the correction; an unmounted host keeps the pin.
|
||||
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
|
||||
expect(anchor.getRowId()).toBe("tool");
|
||||
@@ -59,13 +59,32 @@ describe("reading anchor", () => {
|
||||
it("keeps a pinned row in place instead of the row under the reading line", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.pin(rows[2]!);
|
||||
anchor.pin(rows[2]!, "top", 600);
|
||||
// The row being read grows; only the pinned row below it holds its position.
|
||||
expect(
|
||||
anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 800 }, { ...rows[2]!, top: 1300 }]),
|
||||
).toBe(900);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a row pinned by a trigger below its content", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.pin(rows[2]!, "bottom", 600);
|
||||
// The pinned row grows downward; its bottom, where the trigger sits, holds its position.
|
||||
const grown = { ...rows[2]!, height: rows[2]!.height + 300 };
|
||||
expect(anchor.reconcile(600, [rows[0]!, rows[1]!, grown])).toBe(900);
|
||||
});
|
||||
|
||||
it("measures a pinned row's correction from the offset at the toggle", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(900, rows);
|
||||
anchor.pin(rows[2]!, "bottom", 900);
|
||||
// The row shrinks and the browser clamps the offset to the shorter content before the
|
||||
// reconcile reads it.
|
||||
const shrunk = { ...rows[2]!, height: rows[2]!.height - 300 };
|
||||
expect(anchor.reconcile(700, [rows[0]!, rows[1]!, shrunk])).toBe(600);
|
||||
});
|
||||
|
||||
it("leaves expansion of the row being read in place", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
@@ -109,8 +128,8 @@ describe("reading anchor", () => {
|
||||
it("projects a prepend range without consuming the correction before layout commits", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { DisclosureTriggerEdge } from "./strategy";
|
||||
|
||||
interface RowGeometry {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const edgeOf = (row: RowGeometry, edge: DisclosureTriggerEdge) =>
|
||||
edge === "top" ? row.top : row.top + row.height;
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
export interface ReadingRowReplacements {
|
||||
@@ -14,7 +19,16 @@ const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
export function createReadingAnchor() {
|
||||
let anchor: { id: string; top: number } | null = null;
|
||||
// The row being read keeps its top. A disclosure pins the edge of its row the trigger sits on,
|
||||
// with the offset at the toggle: a toggle that shrinks the content lets the browser clamp the
|
||||
// offset before the next reconcile reads it.
|
||||
let anchor: {
|
||||
id: string;
|
||||
edge: DisclosureTriggerEdge;
|
||||
position: number;
|
||||
pinnedScrollTop?: number;
|
||||
} | null = null;
|
||||
const anchorTop = (row: RowGeometry) => ({ id: row.id, edge: "top" as const, position: row.top });
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
@@ -22,9 +36,12 @@ export function createReadingAnchor() {
|
||||
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||
const project = (
|
||||
scrollTop: number,
|
||||
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||
row: RowGeometry | undefined,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||
) =>
|
||||
anchor && row && row.id === getRowId(replacements)
|
||||
? (anchor.pinnedScrollTop ?? scrollTop) + edgeOf(row, anchor.edge) - anchor.position
|
||||
: scrollTop;
|
||||
return {
|
||||
getRowId,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
@@ -33,14 +50,14 @@ export function createReadingAnchor() {
|
||||
reset() {
|
||||
anchor = null;
|
||||
},
|
||||
// A disclosure names the row that owns its trigger. That row keeps its top
|
||||
// through the toggle, even when another row sits under the reading line.
|
||||
pin(row: Pick<RowGeometry, "id" | "top">) {
|
||||
anchor = { id: row.id, top: row.top };
|
||||
// A disclosure names the row that owns its trigger and the edge the trigger sits on. That
|
||||
// edge stays in place through the toggle, even when another row sits under the reading line.
|
||||
pin(row: RowGeometry, edge: DisclosureTriggerEdge, scrollTop: number) {
|
||||
anchor = { id: row.id, edge, position: edgeOf(row, edge), pinnedScrollTop: scrollTop };
|
||||
},
|
||||
scroll(scrollTop: number) {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
anchor = next ? anchorTop(next) : null;
|
||||
},
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
@@ -57,7 +74,7 @@ export function createReadingAnchor() {
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
anchor = next ? anchorTop(next) : null;
|
||||
return correctedTop;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,10 +35,20 @@ import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
import {
|
||||
createNativeDisclosureHold,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type EndHoldDistance,
|
||||
} from "./native-disclosure-hold";
|
||||
import { EndPinnedScrollView } from "./native-end-pin";
|
||||
import { createNewRowEstimates } from "./new-row-estimates";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import type {
|
||||
DisclosureTriggerEdge,
|
||||
StreamRenderInput,
|
||||
StreamStrategy,
|
||||
StreamViewportHandle,
|
||||
} from "./strategy";
|
||||
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
|
||||
import {
|
||||
isHistoryStartLoadingOperation,
|
||||
@@ -306,8 +316,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isAtEnd = useStableEvent(
|
||||
() => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX,
|
||||
);
|
||||
// A hold on the content end mounts the end pin while detached (`holdRow`). The release reads the
|
||||
// tracked distance instead of the offset: the content size reaches JS before the pin's scroll does.
|
||||
const [endHold, setEndHold] = useState<{ rowId: string; distance: EndHoldDistance } | null>(null);
|
||||
const releaseDisclosure = useStableEvent(() => {
|
||||
bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() });
|
||||
setEndHold(null);
|
||||
bottomAnchorController.releaseDisclosure({
|
||||
isAtEnd: endHold ? endHold.distance.current() <= AT_END_OFFSET_PX : isAtEnd(),
|
||||
});
|
||||
});
|
||||
const [disclosureHold] = useState(() =>
|
||||
createNativeDisclosureHold({
|
||||
@@ -317,16 +333,54 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onRelease: releaseDisclosure,
|
||||
}),
|
||||
);
|
||||
useEffect(() => () => disclosureHold.cancel(), [disclosureHold]);
|
||||
// The hold detaches the controller in the same event as `apply`, so the toggle's commit already
|
||||
// has end following off and LegendList anchoring on the held row. The row's measurement trigger
|
||||
// makes that same commit carry the row's new height.
|
||||
const holdRow = useStableEvent((rowId: string, apply: () => void) => {
|
||||
disclosureHold.hold(rowId);
|
||||
bottomAnchorController.holdForDisclosure();
|
||||
apply();
|
||||
rowLayoutSyncRegistry.get(rowId)?.();
|
||||
const cancelDisclosureHold = useStableEvent(() => {
|
||||
disclosureHold.cancel();
|
||||
setEndHold(null);
|
||||
});
|
||||
useEffect(() => () => cancelDisclosureHold(), [cancelDisclosureHold]);
|
||||
// The hold detaches the controller in the same event as `apply`, so the toggle's commit already
|
||||
// has end following off and LegendList anchoring on the held row, or on the row below it for a
|
||||
// trigger on the row's bottom edge. The row's measurement trigger makes that same commit carry
|
||||
// the row's new height. While following, a bottom trigger keeps following: the end pin holds the
|
||||
// content end, and with it the bottom of the row, in the frame the new height mounts.
|
||||
// The last row's bottom is the content end, which LegendList cannot anchor. The end pin can, from
|
||||
// any offset, but only once it is mounted: a pin that mounts with the new height anchors nothing.
|
||||
// So the toggle lands a frame after the pin's commit, whose mount runs before the frame's timers.
|
||||
const latestDisclosureAnchor = useStableEvent(
|
||||
(rowId: string, triggerEdge: DisclosureTriggerEdge) =>
|
||||
disclosureAnchor(rows, rowId, triggerEdge),
|
||||
);
|
||||
const holdRow = useStableEvent(
|
||||
(rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => {
|
||||
const applyAndMeasure = () => {
|
||||
apply();
|
||||
rowLayoutSyncRegistry.get(rowId)?.();
|
||||
};
|
||||
if (triggerEdge === "bottom" && bottomAnchorController.mode === "sticky-bottom") {
|
||||
applyAndMeasure();
|
||||
return;
|
||||
}
|
||||
const anchor = disclosureAnchor(rows, rowId, triggerEdge);
|
||||
bottomAnchorController.holdForDisclosure();
|
||||
if (anchor.kind === "row" || !isListPlacedRef.current) {
|
||||
setEndHold(null);
|
||||
disclosureHold.hold(anchor);
|
||||
applyAndMeasure();
|
||||
return;
|
||||
}
|
||||
setEndHold({
|
||||
rowId,
|
||||
distance: createEndHoldDistance(distanceFromEnd(streamViewportMetricsRef.current)),
|
||||
});
|
||||
disclosureHold.holdNextFrame(
|
||||
() => latestDisclosureAnchor(rowId, triggerEdge),
|
||||
(latestAnchor) => {
|
||||
if (latestAnchor.kind === "row") setEndHold(null);
|
||||
applyAndMeasure();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const handleItemSizeChanged = useStableEvent(
|
||||
(info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => {
|
||||
newRowEstimates.noteMeasured(info.itemData, info.size);
|
||||
@@ -334,8 +388,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
},
|
||||
);
|
||||
const shouldRestorePosition = useStableEvent((item: StreamItem) => {
|
||||
const heldRowId = disclosureHold.heldRowId();
|
||||
return heldRowId === null || item.id === heldRowId;
|
||||
const anchorRowId = disclosureHold.anchorRowId();
|
||||
return anchorRowId === null || item.id === anchorRowId;
|
||||
});
|
||||
// One owner keeps the scroll offset at any moment:
|
||||
// - Before the list has placed its rows for the agent: LegendList's initial scroll to the end. It
|
||||
@@ -349,16 +403,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// scrolls to the end its JS sizes predict, which runs ahead of the native mount, so it lands
|
||||
// short of the real end and the controller reads that as the user scrolling away.
|
||||
// - A disclosure hold: the held row. The hold detaches the controller, which removes the pin, and
|
||||
// LegendList keeps only the held row in place.
|
||||
// LegendList keeps only the held row in place, or the row below it when the trigger sits on the
|
||||
// held row's bottom edge. A bottom-edge hold while following leaves the end pin in charge, and
|
||||
// one on the last row mounts the pin for the hold: the row's bottom is the content end.
|
||||
// - A user scroll: the finger. The controller detaches for the gesture, so streamed lines grow below
|
||||
// the rows the user is dragging, and resumes following if the gesture ends at the end.
|
||||
// - Detached: the visible rows. LegendList keeps the first visible row in place, so history loading
|
||||
// above or rows growing below leave what the user is reading where it is.
|
||||
const isFollowingEnd = bottomAnchorController.mode === "sticky-bottom";
|
||||
const pinsEnd = isFollowingEnd && isListPlaced;
|
||||
// An end hold pins the end only while its row's bottom is the end. A reply landing below the row
|
||||
// drops the pin in the commit that mounts the reply, so the reply grows below the held row.
|
||||
const isEndHeld =
|
||||
endHold !== null && disclosureAnchor(rows, endHold.rowId, "bottom").kind === "end";
|
||||
const isEndPinned = isFollowingEnd || isEndHeld;
|
||||
const pinsEnd = isEndPinned && isListPlaced;
|
||||
const maintainVisibleContentPosition = useMemo(
|
||||
() => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isFollowingEnd, shouldRestorePosition],
|
||||
() => (isEndPinned ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isEndPinned, shouldRestorePosition],
|
||||
);
|
||||
// A new identity re-renders LegendList's scroll component, which is how the pin follows the mode.
|
||||
const renderScrollComponent = useCallback(
|
||||
@@ -505,7 +566,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// The gesture takes the viewport from a disclosure hold. Size changes from a streaming row can
|
||||
// keep the hold's quiet frames from ever passing, and a held row that scrolls away would leave
|
||||
// no row LegendList may anchor a history page on. The gesture's end decides following.
|
||||
disclosureHold.cancel();
|
||||
cancelDisclosureHold();
|
||||
bottomAnchorController.beginUserScroll();
|
||||
historyStartPagination.rearm();
|
||||
});
|
||||
@@ -581,6 +642,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentHeight: nextContentHeight,
|
||||
contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null,
|
||||
};
|
||||
endHold?.distance.noteContentSize(streamViewportMetricsRef.current);
|
||||
bottomAnchorController.handleContentSizeChange();
|
||||
// Content growing below a detached viewport moves the end away without a scroll event.
|
||||
if (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import type { DisclosureTriggerEdge, StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -91,10 +91,15 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
|
||||
return {
|
||||
render: (segments: Partial<StreamRenderInput["segments"]>) => act(() => render(segments)),
|
||||
hold: (rowId: string, apply: () => void) =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply)),
|
||||
hold: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge = "top") =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply, triggerEdge)),
|
||||
rowTop: (id: string) =>
|
||||
rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
rowBottom: (id: string) =>
|
||||
rowElement(id).getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().top,
|
||||
scrollTo: (top: number) => {
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
distanceFromEnd: () =>
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
|
||||
// Resize observers and the hold's settle run on animation frames.
|
||||
@@ -139,6 +144,53 @@ describe("web disclosure hold", () => {
|
||||
expect(timeline.rowTop("message-5")).toBe(triggerTop);
|
||||
});
|
||||
|
||||
// A long message's show more sits under the text it opens, on the bottom edge of its row.
|
||||
it("keeps the bottom of a bottom-trigger row through show more and show less while following output", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
await timeline.settle();
|
||||
const triggerBottom = timeline.rowBottom("message-5");
|
||||
|
||||
timeline.hold(
|
||||
"message-5",
|
||||
() => timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }),
|
||||
"bottom",
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
|
||||
|
||||
timeline.hold("message-5", () => timeline.render({ historyMounted: history }), "bottom");
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row through show more and show less when scrolled up", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
await timeline.settle();
|
||||
timeline.scrollTo(60);
|
||||
await timeline.settle();
|
||||
// message-2 is cut off at the top; message-3 is fully in view.
|
||||
for (const index of [2, 3]) {
|
||||
const triggerBottom = timeline.rowBottom(`message-${index}`);
|
||||
const grown = history.map((item, position) =>
|
||||
position === index - 1 ? row(index, 240) : item,
|
||||
);
|
||||
|
||||
timeline.hold(`message-${index}`, () => timeline.render({ historyMounted: grown }), "bottom");
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
|
||||
|
||||
timeline.hold(
|
||||
`message-${index}`,
|
||||
() => timeline.render({ historyMounted: history }),
|
||||
"bottom",
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
|
||||
}
|
||||
});
|
||||
|
||||
it("resumes following when collapsing an anchored row lands back at the end", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
|
||||
@@ -418,6 +418,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
height: measurement.size,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
@@ -1172,11 +1173,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdRow: (rowId, apply) => {
|
||||
holdRow: (rowId, apply, triggerEdge) => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
const row = measureRows()?.find((candidate) => candidate.id === rowId);
|
||||
if (row) readingAnchor.pin(row);
|
||||
const scrollTop = scrollContainerRef.current?.scrollTop;
|
||||
if (row && scrollTop !== undefined) readingAnchor.pin(row, triggerEdge, scrollTop);
|
||||
disclosurePendingRef.current = true;
|
||||
apply();
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user